diff --git a/AGENTS.md b/AGENTS.md index b6c3f156..80974ad9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -218,6 +218,11 @@ name is the *caller*, not the implementer. that), so a function whose signature already says it takes the one-line docstring and no block at all — the guide's own escape, and what most private helpers here want. Half a signature restated is what neither rule accepts. +- **A name is linked as the site links it**: ``[`name`][]`` where the module + imports it, ``[`name`][dotted.path]`` where it does not. A name from another + package is plain code. The docstrings are + [the Python API](docs/reference/api.md), and `pixi run docs-test` refuses a + link that lands nowhere, rendered or not; the suite refuses a Sphinx role. - **The gate is `src/`**, where a docstring is a contract with a caller. Under `tests/`, `bench/`, `tools/` and `examples/` the `D` rules are off and so are the bullets above: there a docstring argues for one assertion or narrates a diff --git a/CHANGELOG.md b/CHANGELOG.md index 47fe4c23..7fa8c8fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ it releases that version ([RELEASING.md](https://github.com/fluxopt/specsolve/bl ## Upcoming version +- docs: the Python API page renders every public name from its docstring ([#1766](https://github.com/fluxopt/specsolve/pull/1766)) - docs(sweeps): the sweep reference says how many sessions a sweep opens on a remote Gurobi ([#1771](https://github.com/fluxopt/specsolve/pull/1771)) ## 0.1.0 (2026-09-25) diff --git a/docs/about/architecture.md b/docs/about/architecture.md index 7d7e5f34..5482fe3d 100644 --- a/docs/about/architecture.md +++ b/docs/about/architecture.md @@ -207,7 +207,9 @@ reaches the plan. The names, by role: - the three types a verb hands back, `Model`, `Result` and `Sweep`; - the error tree under `SpecsolveError`, `NoSolutionError` and `SpecsolveWarning`. -What each one takes and returns is [the Python API](../reference/api.md). +What each one takes and returns is its docstring, which +[the Python API](../reference/api.md) renders. The docstrings are the reference, +so there is no second hand-written copy of it to drift. `evaluate`, which reads a spec of parameters and expressions as arithmetic, needs no solver installed. diff --git a/docs/reference/api.md b/docs/reference/api.md index 4479a796..1c5d1812 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -1,8 +1,7 @@ # Python API -This page describes what each verb takes, returns, guarantees and refuses, for -anyone who runs a spec from Python. A *spec* is the YAML file; what it may -contain is +This page is the reference for running a spec from Python: every public name, +rendered from its docstring. A *spec* is the YAML file; what it may contain is [the language](https://mathspec.readthedocs.io/en/latest/reference/language/). ```python @@ -16,45 +15,158 @@ result.primal('p') # a polars.DataFrame result.dual('power_balance') ``` -## The verbs +## Reference -Every verb takes the spec first and, except `check`, the *sources* second: the -tables that carry its numbers. The [glossary](glossary.md) defines *model*, -*result*, *sink* and the other house terms this page uses. +Every public name, rendered from its docstring. The [glossary](glossary.md) +defines *model*, *result*, *sink* and the other house terms the entries use. -| | | -|---|---| -| `sps.check(spec, sink=None)` | parse, validate and lower; attach no data. With a `sink`, also say whether that sink takes it. Returns the lowered `Program`, for reading the plan — no verb takes one back | -| `mathspec.to_spec(spec)` | the file as written, for editing and typesetting; the language's own verb | -| `sps.build(spec, sources)` | attach data and build; returns a `Model` | -| `sps.solve(spec, sources, solver_name='highs', solver_options=None)` | build and solve in one call; returns a `Result` | -| `sps.evaluate(spec, sources, expression)` | a spec of parameters and expressions, no variables: one expression read as arithmetic, with no solver; returns its frame | -| `sps.solve_over(spec, sources, axis, ...)` | solve once per slice and fold the answers: [sweeps](sweeps.md) | -| `sps.write(spec, sources, out)` | build and stream to a file; the suffix picks the format | -| `archive=` on `sps.solve`, `model.solve`, `sps.solve_over` | write the spec, its data and this answer as one zip: [Archiving a model](#archiving-a-model) | -| `sps.load_archive(path, into=None)` | an archive back whole as a `SolveArchive`, or a `SweepArchive` where its sources were cut | -| `sps.load_result(directory)` | an answer `result.save(dir)` wrote, back as a `Result` | -| `sps.load_sweep(directory)` | a sweep `sweep.save(dir)` or `solve_over(spill_to=)` wrote, back as a `Sweep` | -| `sps.scan_archive` / `scan_result` / `scan_sweep` | the same three left on disk and read as they are asked for: [loading or scanning](#loading-or-scanning) | -| `model.row(name, **coordinate)` | one built constraint row: terms, comparison, right-hand side | -| `mathspec.to_latex` / `to_typst` / `to_markdown` | the math as a document: [typeset](https://mathspec.readthedocs.io/en/latest/reference/typeset/) | -| `sps.Model` / `sps.Result` / `sps.Sweep` | the types the verbs hand back, importable so a wrapper can annotate its signature. The spec going *in* is `mathspec.Spec` | - -## Errors and warnings - -**Every error is one tree, rooted at `SpecsolveError`.** `LanguageError` (with -`SchemaError`, `DimensionError`) is a fault in the -spec. `DataError` is a fault in the data attached to it. `LayoutError` is a -directory or an archive that is not a layout this package reads. -`NoSolutionError` is a solve that left nothing to read. A spec the language +### Run a spec + +::: specsolve.check + options: + heading_level: 4 + +::: specsolve.build + options: + heading_level: 4 + +::: specsolve.solve + options: + heading_level: 4 + +::: specsolve.write + options: + heading_level: 4 + +::: specsolve.evaluate + options: + heading_level: 4 + +### Run it many times + +The fold and its two axes; [sweeps](sweeps.md) says how a sweep is cut and read. + +::: specsolve.solve_over + options: + heading_level: 4 + +::: specsolve.EachCoordinate + options: + heading_level: 4 + +::: specsolve.EachWindow + options: + heading_level: 4 + +### What comes back + +::: specsolve.Model + options: + heading_level: 4 + +::: specsolve.Result + options: + heading_level: 4 + +::: specsolve.Sweep + options: + heading_level: 4 + +The rows and frames those hand back: how a solve terminated, what the build +and its solves took, and what a slice of a sweep took. + +::: specsolve.relational.result.Diagnostics + options: + heading_level: 4 + +::: specsolve.relational.parquet.Record + options: + heading_level: 4 + +::: specsolve.relational.parquet.Metrics + options: + heading_level: 4 + +::: specsolve.relational.parquet.SliceMetrics + options: + heading_level: 4 + +### Carry an answer + +::: specsolve.SolveArchive + options: + heading_level: 4 + +::: specsolve.SweepArchive + options: + heading_level: 4 + +::: specsolve.load_archive + options: + heading_level: 4 + +::: specsolve.load_result + options: + heading_level: 4 + +::: specsolve.load_sweep + options: + heading_level: 4 + +::: specsolve.scan_archive + options: + heading_level: 4 + +::: specsolve.scan_result + options: + heading_level: 4 + +::: specsolve.scan_sweep + options: + heading_level: 4 + +### Errors and warnings + +Every error is one tree, rooted at `SpecsolveError`. A spec the language accepts and specsolve cannot build raises `SpecsolveError` itself, and its -message names the rewrite that builds the same model. -Which one you get: -[errors](https://mathspec.readthedocs.io/en/latest/reference/language/errors/#which-error-you-get). +message names the rewrite. `LanguageError`, with `SchemaError` and +`DimensionError`, is a fault in the spec, and is the language's own: +[which error you get](https://mathspec.readthedocs.io/en/latest/reference/language/errors/#which-error-you-get). + +::: specsolve.SpecsolveError + options: + heading_level: 4 + +::: specsolve.LanguageError + options: + heading_level: 4 + +::: specsolve.SchemaError + options: + heading_level: 4 + +::: specsolve.DimensionError + options: + heading_level: 4 + +The rest are specsolve's: + +::: specsolve.DataError + options: + heading_level: 4 + +::: specsolve.LayoutError + options: + heading_level: 4 + +::: specsolve.NoSolutionError + options: + heading_level: 4 + +::: specsolve.SpecsolveWarning + options: + heading_level: 4 -**`SpecsolveWarning` is the one warning category**, and carries `check`'s advice. -`warnings.simplefilter('error', sps.SpecsolveWarning)` makes a spec repository -fail CI on it. ## The spec argument diff --git a/mkdocs.yml b/mkdocs.yml index 3ecb4ae2..68fd04ff 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -208,6 +208,32 @@ markdown_extensions: plugins: - search - markdown-exec + - autorefs + - mkdocstrings: + default_handler: python + handlers: + python: + paths: [src] + # The language's errors are re-exported from mathspec, and render from its source. + load_external_modules: true + options: + # The tree documents attributes with `#:` comments. + extensions: + - griffe_sphinx + docstring_style: google + docstring_section_style: spacy + filters: + - "!^_" + merge_init_into_class: true + show_root_heading: true + show_root_full_path: false + show_source: false + show_bases: false + show_if_no_docstring: true + signature_crossrefs: true + scoped_crossrefs: true + show_signature_annotations: false + separate_signature: true extra_css: - stylesheets/extra.css diff --git a/pyproject.toml b/pyproject.toml index cabe6078..1c35711b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -69,6 +69,9 @@ codspeed = [ ] docs = [ "zensical==0.0.62", + "mkdocstrings==1.0.6", + "mkdocstrings-python==2.0.9", + "griffe-sphinx==0.3.0", "markdown-exec==1.12.3", "pymdown-extensions==11.0.1", "pygments==2.20.0", @@ -167,7 +170,7 @@ cmd = "pytest tests/test_expression_sweep.py -q -n auto --sweep-depth 3 --sweep- [tool.pixi.feature.docs.tasks] docs = { cmd = "python -m zensical serve", description = "The site at 127.0.0.1:8000, live-reloading" } docs-build = { cmd = "python -m zensical build --strict", description = "The site, built the way CI builds it" } -docs-test = { cmd = "pytest tests/test_docs_math.py -q", description = "The site's math, rendered — what a strict build does not check" } +docs-test = { cmd = "pytest tests/test_docs_math.py tests/test_docstring_links.py -q", description = "The site's math, rendered, and every docstring link — what a strict build does not check" } [tool.pixi.feature.bench.tasks] density = { cmd = "pytest bench --benchmark-memory --sizes d100 d50 d25 d08 --benchmark-json=bench/results/density.json", description = "The density sweep" } diff --git a/src/specsolve/api.py b/src/specsolve/api.py index e19b9437..fb661f7f 100644 --- a/src/specsolve/api.py +++ b/src/specsolve/api.py @@ -2,10 +2,10 @@ Math is defined in YAML only — there is no Python API for constructing specs, and the logical plan is internal. Five verbs take a spec: ``check``, ``build`` -(YAML + sources → a :class:`Model`), ``solve``, ``write``, and ``evaluate`` for a +(YAML + sources → a [`Model`][]), ``solve``, ``write``, and ``evaluate`` for a spec with no variables. ``load_result`` reads back an -answer :meth:`Result.save` wrote and ``scan_result`` leaves it on disk; the -question and the answer as one archive is :class:`specsolve.archive.SolveArchive`. +answer [`Result.save`][] wrote and ``scan_result`` leaves it on disk; the +question and the answer as one archive is [`specsolve.archive.SolveArchive`][]. A spec is validated at load time, lowered to the plan, and executed relationally (docs/about/architecture.md). @@ -87,7 +87,7 @@ def check(spec: Buildable, sink: str | None = None) -> Program: The lowered program: what a build reads rows off, and what every verb here takes back without parsing the file again. It is the language's own type — typeset it, or read its declarations, through - :mod:`mathspec`. + `mathspec`. Raises: LanguageError: A construct outside the streaming language. @@ -110,11 +110,11 @@ def check(spec: Buildable, sink: str | None = None) -> Program: def _refuse_a_decision(program: Program) -> None: - """Refuse a spec that declares a decision — :func:`evaluate` is arithmetic, not a solve. + """Refuse a spec that declares a decision — [`evaluate`][] is arithmetic, not a solve. A variable has no value until a solver picks one, so an expression over one cannot be evaluated as arithmetic, and a constraint or an objective is a law - that picks it rather than a quantity to read. Naming :func:`solve` is the + that picks it rather than a quantity to read. Naming [`solve`][] is the whole rewrite. Raises: @@ -145,17 +145,17 @@ def evaluate(spec: Buildable, sources: Mapping[str, Source], expression: str | M dimensions, parameters, relations and ``expressions:``. Each expression reads only the attached data, so it has a value with no solve and no chosen point. This attaches *sources* and values one expression, the way - :meth:`~specsolve.relational.result.Result.evaluate` does at a solution. The + [`evaluate`][specsolve.relational.result.Result.evaluate] does at a solution. The language it is read through — what loads, what is refused, how a construct prints and lowers — is the one a spec that solves is read through; only the variables are absent. - A spec that declares variables is a problem to solve, and belongs to :func:`solve`: an + A spec that declares variables is a problem to solve, and belongs to [`solve`][]: an expression over a decision has no value until the decision is made. Args: - spec: As :func:`check` takes it — a YAML path, a mapping, or a ``Spec``. - sources: As :func:`build` takes them: parameter names to tables or + spec: As [`check`][] takes it — a YAML path, a mapping, or a ``Spec``. + sources: As [`build`][] takes them: parameter names to tables or parquet paths, and dimension names to their labels. expression: What one ``expressions:`` entry takes — a name the spec declares, an expression string, or the mapping carrying ``cases:`` @@ -184,24 +184,23 @@ def evaluate(spec: Buildable, sources: Mapping[str, Source], expression: str | M class Model: - """A spec with your data attached to it — what :func:`build` returns. + """A spec with your data attached to it — what [`build`][] returns. Three nouns, each arrow adding one thing: a ``Program`` is the math, - a ``Model`` is the math with your data, a ``Result`` is one answer. + a ``Model`` is the math with your data, a ``Result`` is one answer: + ``check`` → ``Program`` → ``build`` → ``Model`` → ``solve`` → ``Result``. - ``check`` → ``Program`` → ``build`` → ``Model`` → ``solve`` → ``Result`` - - One build feeds any number of sinks — :meth:`solve` and :meth:`write` on - the same object — :meth:`update` puts new numbers on it without re-reading - the YAML or re-lowering the plan, and :meth:`diagnostics` says what it did. - Nothing has to be released; :meth:`close` hands a large model back early. + One build feeds any number of sinks — [`solve`][] and [`write`][] on + the same object — [`update`][] puts new numbers on it without re-reading + the YAML or re-lowering the plan, and [`diagnostics`][] says what it did. + Nothing has to be released; [`close`][] hands a large model back early. """ def __init__(self, spec: Buildable, sources: Mapping[str, Source]) -> None: self._spec = declared(spec) self._program = lowered(self._spec) #: Which *document* this answers, so two answers can be told to have - #: answered the same one. The data is :meth:`_model_digest`. + #: answered the same one. The data is [`_model_digest`][]. self._spec_digest = digest_of(self._spec.to_yaml()) self._sources = dict(sources) self._engine = PolarsEngine() @@ -239,15 +238,15 @@ def update(self, sources: Mapping[str, Source]) -> Model: ``build(spec, sources | x)`` answers, whatever changed. Data that moves a mask renumbers labels, so the model is rebuilt and solved cold instead of pushed onto a loaded solver, and - :attr:`~specsolve.relational.result.Diagnostics.loads` says which ran. + [`loads`][specsolve.relational.result.Diagnostics.loads] says which ran. Results taken before the update keep reading: each owns the frames it reads, and an update builds new ones rather than touching those. A retained result keeps its build's label frames alive until it is - dropped or :meth:`~specsolve.relational.result.Result.close` is called. + dropped or [`close`][specsolve.relational.result.Result.close] is called. Args: - sources: Only what changed; the rest keeps what :func:`build` + sources: Only what changed; the rest keeps what [`build`][] attached. A dimension's labels as well as a parameter, which is how a coordinate set grows. @@ -276,15 +275,15 @@ def solve( model skips the hand-off and only its numbers are pushed. Whether the *work* that solver did is kept too is *keep*, off by default. How much this solve actually kept is its - :attr:`~specsolve.relational.result.Result.kept`. + [`kept`][specsolve.relational.result.Result.kept]. Args: solver_name: ``highs``, which ships with the package, or ``gurobi``, which needs the ``[gurobi]`` extra. solver_options: Forwarded to the solver verbatim, in its own vocabulary (``{'time_limit': 60}``). - keep: How much of the session this solve may keep — one of - :data:`~specsolve.relational.result.KEEPS`. ``solver``, the + keep: How much of the session this solve may keep: ``solver``, + ``progress`` or ``nothing``. ``solver``, the default, reuses the solver holding the model and discards the work it did; ``progress`` keeps that work too, which is what an iterating driver moving one step at a time wants; @@ -294,19 +293,18 @@ def solve( moved is loaded again whatever was asked. archive: Where to write the whole thing — the spec, the data attached to it **now**, and this answer — so that - :func:`~specsolve.archive.load_archive` gives all three back and + [`load_archive`][specsolve.archive.load_archive] gives all three back and the model solves again from the file alone. A ``.zip`` suffix packs it into one file and anything else is a directory. What the build and its solves have spent goes in beside the answer, - as :class:`~specsolve.relational.parquet.Metrics`. + as [`Metrics`][specsolve.relational.parquet.Metrics]. Returns: The solution, holding this model. Raises: SpecsolveError: A solver name nothing serves, one this environment - cannot run, or a *keep* outside - :data:`~specsolve.relational.result.KEEPS`. + cannot run, or a *keep* other than those three. LayoutError: An *archive* directory that already holds something, refused before the solve rather than after it. """ @@ -331,7 +329,7 @@ def _archive(self, out: Path, answered: Result) -> None: """Write this model, what is attached to it now, and *answered* to *out*. The metrics row is written beside the answer here rather than by - :meth:`Result.save`: a result is one solve, and the diagnostics the + [`Result.save`][]: a result is one solve, and the diagnostics the metrics come from span the model's whole life. """ with beside(out) as scratch: @@ -347,7 +345,7 @@ def write(self, path: str | Path) -> None: Raises: ValueError: A suffix nothing writes. SpecsolveError: A construct the format has no section for, the same as - :func:`check`'s ``sink=`` answer. + [`check`][]'s ``sink=`` answer. """ self._engine.write(path) @@ -356,7 +354,7 @@ def row(self, name: str, /, **coordinate: Label) -> ConstraintRow: The verb for *this row is wrong and I do not know why*. ``to_latex`` and its siblings render the spec as math before any data, and - :meth:`~specsolve.relational.result.Result.dual` gives a row's number + [`dual`][specsolve.relational.result.Result.dual] gives a row's number without its terms; this gives the row the build actually produced, at the coordinate you name. @@ -395,7 +393,7 @@ def evaluator( ) -> Callable[[str | Mapping[str, object]], pl.DataFrame]: """An ad-hoc expression reader over a *saved* solution, put back against this build. - What an archive and a sweep hand :meth:`~specsolve.relational.result.Result.evaluate` + What an archive and a sweep hand [`evaluate`][specsolve.relational.result.Result.evaluate] for a quantity the file never named: the saved frames are laid back in this build's label order, and the reader is the one a live solve gives. A build, never a solve. @@ -414,7 +412,7 @@ def _model_digest(self) -> str: """Which model this build *is* — the document and the data attached to it now. What a saved answer carries as - :attr:`~specsolve.relational.parquet.Record.model_digest`, and what one read + [`model_digest`][specsolve.relational.parquet.Record.model_digest], and what one read back is checked against. Over the built tables, so it is an identity for the pair rather than an invariant of the mathematics: the same program over a differently ordered dimension builds a different label order and @@ -425,7 +423,7 @@ def _model_digest(self) -> str: def diagnostics(self) -> Diagnostics: """What this build and its solves did that the answer does not show. - Answerable after :meth:`close`, and after a build that raised: every + Answerable after [`close`][], and after a build that raised: every field is a count, a clock or a small frame the engine keeps, not a read of the model it releases. A raise leaves the sizes at zero — they are taken once a model is whole — and everything measured before it stands. @@ -454,7 +452,7 @@ def build(spec: Buildable, sources: Mapping[str, Source]) -> Model: """Attach *sources* to *spec* and build it — the model with your data on it. Args: - spec: As :func:`check` takes it. + spec: As [`check`][] takes it. sources: Parameter names to parquet paths or in-memory tables, and dimension names to their labels — an index table, a parquet path, or a bare sequence — wherever the YAML declares none. @@ -482,22 +480,22 @@ def solve( """Build *spec* and solve it in one call. The one-shot spelling: a caller who will solve the same spec again with - new numbers wants :func:`build` and :meth:`Model.update`. + new numbers wants [`build`][] and [`Model.update`][]. There is no ``keep`` here — this builds the model it solves, so the solve is the first of that model's life and - :attr:`~specsolve.relational.result.Result.kept` is always ``nothing``. - Choosing what to keep is :meth:`Model.solve`. + [`kept`][specsolve.relational.result.Result.kept] is always ``nothing``. + Choosing what to keep is [`Model.solve`][]. Args: - spec: As :func:`check` takes it. - sources: As :func:`build` takes them. + spec: As [`check`][] takes it. + sources: As [`build`][] takes them. solver_name: ``highs``, which ships with the package, or ``gurobi``, which needs the ``[gurobi]`` extra. solver_options: Forwarded to the solver verbatim, in its own vocabulary (``{'time_limit': 60}``). archive: Where to write the spec, its data and this answer, as - :meth:`Model.solve` takes it — a ``.zip``, or a directory. + [`Model.solve`][] takes it — a ``.zip``, or a directory. Returns: The solution, self-contained: it owns the frames it reads, so the built @@ -523,8 +521,8 @@ def write( """Build *spec* and stream it to a file, in the format *out*'s suffix names. Args: - spec: As :func:`check` takes it. - sources: As :func:`build` takes them. + spec: As [`check`][] takes it. + sources: As [`build`][] takes them. out: Where to write; ``.lp`` and ``.mps`` are what ship. Returns: @@ -543,16 +541,16 @@ def write( def _whole(file: Path) -> pl.LazyFrame: - """*file* read into memory, behind the :class:`polars.LazyFrame` a saved frame is held as. + """*file* read into memory, behind the `polars.LazyFrame` a saved frame is held as. - The :data:`Reading` a ``load_`` uses: the bytes are here when it returns. + The [`Reading`][] a ``load_`` uses: the bytes are here when it returns. """ return pl.read_parquet(file).lazy() #: How a saved frame is read — the one difference between ``load_`` and -#: ``scan_``. :func:`_whole` reads it now, so what comes back owes the -#: directory nothing; :func:`polars.scan_parquet` reads it at the first +#: ``scan_``. [`_whole`][] reads it now, so what comes back owes the +#: directory nothing; `polars.scan_parquet` reads it at the first #: collect, so the directory has to outlive what was read off it. type Reading = Callable[[Path], pl.LazyFrame] @@ -582,7 +580,7 @@ def read() -> pl.DataFrame: def load_result(directory: str | Path) -> Result: - """Read back an answer :meth:`Result.save` wrote — a solve, off disk. + """Read back an answer [`Result.save`][] wrote — a solve, off disk. Every reader answers what it answered in the session that solved: the values, the duals and activities, each named expression, and the reason @@ -592,21 +590,21 @@ def load_result(directory: str | Path) -> Result: one solved today. Two things do not come back, both being facts about a session rather than - about an answer: :attr:`~specsolve.relational.result.Result.kept` reads + about an answer: [`kept`][specsolve.relational.result.Result.kept] reads ``nothing``, this result holding no solver, and the solver's verbatim wording behind a refusal is not recorded — the termination condition is. A solve that reached no objective wrote null and reads back as ``nan``, - which is what :attr:`~specsolve.relational.result.Result.objective` has to + which is what [`objective`][specsolve.relational.result.Result.objective] has to return, being a float. Args: - directory: Where :meth:`~specsolve.relational.result.Result.save` wrote + directory: Where [`save`][specsolve.relational.result.Result.save] wrote it. One that came out of an archive is - :func:`~specsolve.archive.load_archive`'s to find. + [`load_archive`][specsolve.archive.load_archive]'s to find. Returns: The result, read whole: the frames are in memory when this returns, so - it owes *directory* nothing. :func:`scan_result` is the same answer left + it owes *directory* nothing. [`scan_result`][] is the same answer left on disk. Raises: @@ -620,9 +618,9 @@ def load_result(directory: str | Path) -> Result: def scan_result(directory: str | Path) -> Result: """The answer under *directory*, read as its readers are called rather than now. - :func:`load_result`'s other half, and the same value: every reader answers + [`load_result`][]'s other half, and the same value: every reader answers what that one's does. What differs is when the bytes move — each frame is - a :func:`polars.scan_parquet` of the file it lies in, so an answer far + a `polars.scan_parquet` of the file it lies in, so an answer far larger than memory is readable a name at a time, and one whose names go unread costs nothing to open. @@ -630,10 +628,10 @@ def scan_result(directory: str | Path) -> Result: name read after the directory is gone raises where the scan is collected. Args: - directory: As :func:`load_result` takes it. + directory: As [`load_result`][] takes it. Raises: - LayoutError: As :func:`load_result` raises it. + LayoutError: As [`load_result`][] raises it. """ return _answer_under(Path(directory), pl.scan_parquet) @@ -641,7 +639,7 @@ def scan_result(directory: str | Path) -> Result: def _answer_under(out: Path, read: Reading) -> Result: """The saved answer under *out*, its frames read *read*'s way. - Shared body of :func:`load_result` and :func:`scan_result`; only the + Shared body of [`load_result`][] and [`scan_result`][]; only the reading differs. """ record_file = out / RECORD_FILE @@ -702,13 +700,13 @@ def _refuse_another_model(answer: Result, model: Model) -> None: def attach_readers(answer: Result, spec: Buildable, sources: Mapping[str, Source]) -> Result: - """*answer* with an undeclared expression readable through :meth:`~specsolve.relational.result.Result.evaluate`, over *spec* and *sources* rebuilt. + """*answer* with an undeclared expression readable through [`evaluate`][specsolve.relational.result.Result.evaluate], over *spec* and *sources* rebuilt. Reading a quantity the file never named lowers the spec as written, so the model is rebuilt (a build, never a solve) and the saved primal and dual put back in order against it. **The rebuild is checked against the answer**: one built from other data than the solve ran on is refused rather than read - (:func:`_refuse_another_model`). The declared readers a save wrote are untouched; + ([`_refuse_another_model`][]). The declared readers a save wrote are untouched; only an expression outside them reaches the rebuilt evaluator. *answer* is returned unchanged where the solve left no values. @@ -716,10 +714,10 @@ def attach_readers(answer: Result, spec: Buildable, sources: Mapping[str, Source and cached. Args: - answer: A saved solve, as :func:`load_result` or :func:`scan_result` + answer: A saved solve, as [`load_result`][] or [`scan_result`][] read it back. - spec: The model the answer solved, as :func:`build` takes it. - sources: What it was solved with, as :func:`build` takes them. + spec: The model the answer solved, as [`build`][] takes it. + sources: What it was solved with, as [`build`][] takes them. """ if not answer._primals: return answer diff --git a/src/specsolve/archive.py b/src/specsolve/archive.py index eab6e9d2..fe0588cf 100644 --- a/src/specsolve/archive.py +++ b/src/specsolve/archive.py @@ -1,10 +1,10 @@ """Reading an archive back: the spec, the data it was solved with, and what came back. -:func:`load_archive` reads it whole; :func:`scan_archive` leaves the frames on +[`load_archive`][] reads it whole; [`scan_archive`][] leaves the frames on disk and reads each at the call that asks for it. Either gives back a -:class:`SolveArchive` for one solve, or a :class:`SweepArchive` where the +[`SolveArchive`][] for one solve, or a [`SweepArchive`][] where the sources were cut. Nothing here writes one: ``archive=`` on the verbs that -solve does, through :mod:`specsolve.layout`. +solve does, through [`specsolve.layout`][]. """ from __future__ import annotations @@ -52,13 +52,13 @@ class SolveArchive: Attributes: spec: The spec as written. sources: What was attached, keyed as the file declares it: a table - from :func:`load_archive`, the path to one from :func:`scan_archive`. + from [`load_archive`][], the path to one from [`scan_archive`][]. answer: What came back. source_digests: ``(run, source, digest)``, one row per source, so two archives of one spec over different numbers name the input that moved. metrics: What reaching the answer took, as one - :class:`~specsolve.relational.parquet.Metrics`. + [`Metrics`][specsolve.relational.parquet.Metrics]. """ spec: Spec @@ -78,13 +78,13 @@ class SweepArchive: Attributes: spec: The spec as written. sources: What the sweep was given, uncut. A table or a path, as - :class:`SolveArchive` holds them. + [`SolveArchive`][] holds them. axis: What cut them. carry: ``{parameter: variable}`` the slices were chained with, empty where they were not. answer: Every slice's answer, keyed by slice. Held from - :func:`load_archive`, spilled from :func:`scan_archive`. - source_digests: As :class:`SolveArchive` holds it, of the uncut + [`load_archive`][], spilled from [`scan_archive`][]. + source_digests: As [`SolveArchive`][] holds it, of the uncut sources. """ @@ -107,8 +107,8 @@ def load_archive(path: str | Path, into: str | Path | None = None) -> SolveArchi archive, which is read where it lies. Returns: - A :class:`SweepArchive` where the archive carries an axis, a - :class:`SolveArchive` where it does not. + A [`SweepArchive`][] where the archive carries an axis, a + [`SolveArchive`][] where it does not. Raises: LanguageError: A ``spec.yaml`` the language does not accept. @@ -130,7 +130,7 @@ def scan_archive(path: str | Path, into: str | Path | None = None) -> SolveArchi """Read an archive back off disk: the sources as paths, each frame read at the call that asks for it. The members have to outlive the value, so *into* is required for a zip - and kept. The same values and the same errors as :func:`load_archive`, + and kept. The same values and the same errors as [`load_archive`][], and ``LayoutError`` for a zip with no *into*. """ return _read(opened(path, into), whole=False) diff --git a/src/specsolve/assumptions.py b/src/specsolve/assumptions.py index 2dd613fe..d04ad5b1 100644 --- a/src/specsolve/assumptions.py +++ b/src/specsolve/assumptions.py @@ -3,13 +3,13 @@ Everything decidable without data is decided at load, and an ``assumptions:`` entry is what is left over: a predicate only the numbers can answer. The language states each one — the predicate, the coordinates it is checked at, -and the sentence :func:`~mathspec.program.assumption_message` refuses in — and +and the sentence `assumption_message` refuses in — and every condition a ``piecewise:`` method puts on its breakpoints arrives the same way. What is decided here is whether the data holds it, and where not. The mask walk answers, which is the relational engine's: a second reading of a predicate at the door would drift from the one the rows are built with. Called -from :func:`~specsolve.sources.tidy_sources`, so both lanes pass through it by +from [`tidy_sources`][specsolve.sources.tidy_sources], so both lanes pass through it by entering the one door. """ @@ -42,7 +42,7 @@ def validate_assumptions(program: Program, sources: Mapping[str, pl.LazyFrame]) Args: program: The lowered spec — every assumption by the name a refusal quotes. - sources: What :func:`~specsolve.sources.tidy_sources` holds once every + sources: What [`tidy_sources`][specsolve.sources.tidy_sources] holds once every parameter, relation and index is read. Raises: diff --git a/src/specsolve/errors.py b/src/specsolve/errors.py index 89f943fe..94cc5a66 100644 --- a/src/specsolve/errors.py +++ b/src/specsolve/errors.py @@ -1,10 +1,10 @@ """The run half of the exception hierarchy, and the whole of it re-exported. -The spec half — :class:`LanguageError` and what derives from it, decidable at +The spec half — `LanguageError` and what derives from it, decidable at load time with no data attached — belongs to ``mathspec`` and is re-exported here, so one ``except`` clause covers the package. The run half is defined here: -:class:`DataError` is a fine file with the wrong thing attached to it, and -:class:`NoSolutionError` a solve with nothing to read back. +[`DataError`][] is a fine file with the wrong thing attached to it, and +[`NoSolutionError`][] a solve with nothing to read back. A message lives here only where the engine and the test oracle both raise it. One raiser keeps its message beside itself. @@ -55,7 +55,7 @@ class NoSolutionError(SpecsolveError): """The solve returned no values to read — infeasible, unbounded, errored. A scenario sweep catches this and records the outcome; a - :class:`LanguageError` instead means the file needs editing. + `LanguageError` instead means the file needs editing. """ diff --git a/src/specsolve/expressions.py b/src/specsolve/expressions.py index 02f069af..17b42d15 100644 --- a/src/specsolve/expressions.py +++ b/src/specsolve/expressions.py @@ -20,11 +20,11 @@ from mathspec.program import Expression #: The name a single unnamed expression is spliced under. Stepped over rather -#: than overwritten where a spec declares it — :func:`_free_name`. +#: than overwritten where a spec declares it — [`_free_name`][]. _EVALUATED = '_evaluated' #: The one section a caller may hand in. Every other declaration needs data or -#: builds rows, and neither is a read — see :func:`_entries`. +#: builds rows, and neither is a read — see [`_splice`][]. _SECTION = 'expressions' diff --git a/src/specsolve/frames.py b/src/specsolve/frames.py index b5035706..5673d9ae 100644 --- a/src/specsolve/frames.py +++ b/src/specsolve/frames.py @@ -99,7 +99,7 @@ def is_multi_indexed(obj: Source) -> bool: def _series_to_frame(series: PandasSeries, dims: Sequence[str]) -> pd.DataFrame | None: """A pandas Series with its one index level promoted to a column. - One level is all a Series can carry here — :func:`is_multi_indexed` refuses + One level is all a Series can carry here — [`is_multi_indexed`][] refuses the rest — so it runs along one dimension as a dict and a sequence do, and any other arity is declined rather than reported. diff --git a/src/specsolve/lanes.py b/src/specsolve/lanes.py index 48e863b7..72ac8026 100644 --- a/src/specsolve/lanes.py +++ b/src/specsolve/lanes.py @@ -60,18 +60,18 @@ def __arrow_c_stream__(self, requested_schema: object = None) -> object: ... #: A pandas Series of any dtype a source may carry: an index's -#: (:data:`mathspec.program.DimensionDtype`) or a parameter's values' -#: (:data:`~mathspec.program.ParameterDtype`), which is where ``bool`` comes +#: (`mathspec.program.DimensionDtype`) or a parameter's values' +#: (`ParameterDtype`), which is where ``bool`` comes #: from. Spelled out because pandas types ``Series`` invariantly: a bare #: ``pd.Series`` is ``Series[Any]``, and no single parameter stands for the five. type PandasSeries = pd.Series[float] | pd.Series[int] | pd.Series[bool] | pd.Series[str] | pd.Series[datetime] #: A label along a dimension, and so a slice's key: the Python type of each -#: dtype an index may declare (:data:`mathspec.program.DimensionDtype`). +#: dtype an index may declare (`mathspec.program.DimensionDtype`). type Label = int | float | str | datetime #: Anything a verb takes under one name of ``sources``. A parameter: a parquet -#: path, a table — polars, pandas, or any :class:`ArrowTable` — or one of the +#: path, a table — polars, pandas, or any [`ArrowTable`][] — or one of the #: plain-Python shapes a hand-written model reaches for, a ``{label: value}`` #: map, a sequence in the dimension's own label order, and one number for #: every coordinate. A dimension's index: a table carrying a column named @@ -123,8 +123,8 @@ def _case_collision(program: Program) -> str | None: def lowered(spec: Buildable) -> Program: """*spec* as a program, refusing what this package cannot build or keep apart. - Every door lowers through here, so what :func:`check` refuses - :func:`build` and an archive refuse too. Nothing is expanded here: a spec + Every door lowers through here, so what [`check`][] refuses + [`build`][] and an archive refuse too. Nothing is expanded here: a spec still carrying a ``piecewise:`` block is refused, naming ``Spec.expand``, because which formulations to write out is the caller's to say. diff --git a/src/specsolve/relational/engines/polars/assembly.py b/src/specsolve/relational/engines/polars/assembly.py index 0d0113ce..f000db9d 100644 --- a/src/specsolve/relational/engines/polars/assembly.py +++ b/src/specsolve/relational/engines/polars/assembly.py @@ -60,7 +60,7 @@ class Measured: """What one build measured about itself, and a rebuild replaces wholesale. - It outlives :class:`BuiltModel`: ``close()`` releases the frames and + It outlives [`BuiltModel`][]: ``close()`` releases the frames and diagnostics still answer, so everything here is a count or a small frame rather than a read of the model. Most of it is taken as it is measured, so a build that raises still reports what it got to; the three sizes are written @@ -98,7 +98,7 @@ class BuiltModel: program: program.Program attached: AttachedSources - #: One :class:`~specsolve.relational.engines.polars.labels.Labelled` per + #: One [`Labelled`][specsolve.relational.engines.polars.labels.Labelled] per #: declaration, one map per label space: columns and rows are numbered #: independently, and a model may name a variable and a constraint alike. variables: dict[str, labels.Labelled] @@ -111,8 +111,8 @@ class Assembly: Every counter here is one a declaration *advances* — a variable claims the next run of columns, a constraint the next run of rows — so they cannot be - on the frozen product. :meth:`run` turns the lot into a - :class:`BuiltModel`, and nothing outside this class writes to any of it. + on the frozen product. [`run`][] turns the lot into a + [`BuiltModel`][], and nothing outside this class writes to any of it. """ def __init__(self, program: program.Program, attached: AttachedSources, measured: Measured) -> None: @@ -143,7 +143,7 @@ def run(self) -> BuiltModel: The matrix and ``rows`` leave in ``(row, col)`` order, as ``Handoff`` promises its sinks. The stack already has it — each share leaves sorted and owns the next run of rows — so the order is *checked* with - one linear scan rather than sorted. :func:`_row_starts` reads the CSR + one linear scan rather than sorted. [`_row_starts`][] reads the CSR index off that order, after which ``row`` is dropped from the matrix. """ cols = [self._build_variable(name, v) for name, v in self.program.variables.items()] @@ -286,13 +286,13 @@ def _build_constraint( """One constraint as its ``rows``, its share of the matrix, and its quadratic share. Terms normalise to the left, constants to the right. Whether the data - is there where the row reads it is :mod:`coverage`'s to answer, in the + is there where the row reads it is [`coverage`][]'s to answer, in the order its table gives: the two questions an aggregation would hide are asked of the pieces and the parameters first, and the rows pass then carries the third. Duplicates from ``Sum`` and ``GroupSum`` — which project rather than - aggregate — and from ``x + 2 * x`` collapse in :meth:`_matrix_share`'s + aggregate — and from ``x + 2 * x`` collapse in [`_matrix_share`][]'s terminal aggregate, read off the data rather than reasoned from how the fragments were reshaped. @@ -367,7 +367,7 @@ def _quadratic_share( ) -> pl.DataFrame | None: """One constraint's quadratic entries as ``(row, col_l, col_r, coeff)``, in that order. - Pairs are ordered by column index for :meth:`_objective_quadratic`'s + Pairs are ordered by column index for [`_objective_quadratic`][]'s reason. """ if not quads: @@ -396,7 +396,7 @@ def _drop_termless_rows( coefficient — and all three drop the row. *kept* is the row set the share had terms for, which is - :meth:`_matrix_share`'s to answer: the share it returns has been pruned + [`_matrix_share`][]'s to answer: the share it returns has been pruned of zero coefficients, so a row missing from it may have had every term and every one of them zero. That row stays — ``0 >= 10`` is infeasible — where a row that never had a term goes. @@ -471,7 +471,7 @@ def _objective_quadratic( leaves here is the algebra and the conversion is theirs. **It leaves sorted, and that is a contract**: - :attr:`~specsolve.relational.sinks.handoff.Handoff.structure` hashes it, and + [`structure`][specsolve.relational.sinks.handoff.Handoff.structure] hashes it, and the join hands pairs back in whatever order the data made. """ if not quads: @@ -549,7 +549,7 @@ def _without_zeros(matrix: pl.DataFrame) -> pl.DataFrame: **A pruned share can no longer say which rows had terms**, and a row whose every coefficient is zero still asserts something — ``0 >= 10`` is - infeasible — so :meth:`Assembly._matrix_share` reads that row set off each + infeasible — so [`Assembly._matrix_share`][] reads that row set off each frame before pruning it. Nulls cannot be here: a null coefficient is an undefined divisor, refused before this runs. """ @@ -576,7 +576,7 @@ def _collapsed( crosses chunk boundaries that ``is_sorted`` does not. Unordered, a repeat is probed by *space*, the dense label count a single - integer key was drawn from (:func:`_repeats_a_label`), which is what the + integer key was drawn from ([`_repeats_a_label`][]), which is what the objective's stack has; adjacency proves nothing there. Returns: @@ -619,7 +619,7 @@ def _in_key_order(keys: tuple[str, ...]) -> pl.Expr: def _ordered_rows(matrix: pl.DataFrame) -> pl.Series: """The distinct ``row`` labels of a matrix already ordered by ``row``. - Only ever called on what :func:`_collapsed` handed back ordered, which + Only ever called on what [`_collapsed`][] handed back ordered, which has *established* that order — by probe, by sort, or by the aggregate's own sort. diff --git a/src/specsolve/relational/engines/polars/attaching.py b/src/specsolve/relational/engines/polars/attaching.py index 85f01930..eda5f92b 100644 --- a/src/specsolve/relational/engines/polars/attaching.py +++ b/src/specsolve/relational/engines/polars/attaching.py @@ -1,9 +1,9 @@ """What a caller's ``sources`` become: the frames the engine reads by name. -The door (:func:`~specsolve.sources.tidy_sources`) has already read and checked +The door ([`tidy_sources`][specsolve.sources.tidy_sources]) has already read and checked every source; this gives each the shape the query is written against, and encodes the string dimensions. Everything downstream reads -:class:`AttachedSources` and nothing else. +[`AttachedSources`][] and nothing else. **It is frozen.** Written once by the passes below, then read to construct the compiler and the labeller — unlike the one registry that is *live*, the diff --git a/src/specsolve/relational/engines/polars/compiler.py b/src/specsolve/relational/engines/polars/compiler.py index a756ec20..2fbcc027 100644 --- a/src/specsolve/relational/engines/polars/compiler.py +++ b/src/specsolve/relational/engines/polars/compiler.py @@ -1,7 +1,7 @@ """Logical plan → polars. Lazy: nothing is read, nothing is executed. The language compiles a spec to a plan; this compiles the plan to a query, -in the :class:`~specsolve.relational.engines.polars.scope.Scope` the model's names +in the [`Scope`][specsolve.relational.engines.polars.scope.Scope] the model's names resolve in. Column conventions, relied on by the engine: @@ -96,9 +96,9 @@ class PolarsCompiler: """Turn plan nodes into polars queries over the model's tidy frames. ``scope`` is what every query is written against - (:class:`~specsolve.relational.engines.polars.scope.Scope`). ``solution`` is + ([`Scope`][specsolve.relational.engines.polars.scope.Scope]). ``solution`` is set on the compiler a read builds and on no other: with it every variable - and every ``dual(c)`` compiles to a value (:class:`Solution`). + and every ``dual(c)`` compiles to a value ([`Solution`][]). """ scope: Scope @@ -139,7 +139,7 @@ def _aligned_bound( ) -> pl.LazyFrame | None: """*frame* with *param* attached **by position**, or ``None`` to join. - Each parameter row's slot is its :meth:`row_major` position and its + Each parameter row's slot is its [`row_major`][specsolve.relational.engines.polars.scope.Scope.row_major] position and its value is scattered there — the table's row order is nothing, and ``_scattered`` refuses a product any slot of which nothing wrote. @@ -205,7 +205,7 @@ def product(a: CompiledExpression, b: CompiledExpression) -> CompiledExpression: A constant piece of the product owes a factor's parameters only where the *other* factor carries no variable: a parameter a variable stands with in a product is a coefficient, whichever - piece it lands in (:attr:`TermFragment.parameters`). + piece it lands in ([`TermFragment.parameters`][]). """ assert not ((a.quads and b.terms) or (b.quads and a.terms) or (a.quads and b.quads)), ( f'in {context}: a product of degree 3 reached the compiler' @@ -272,7 +272,7 @@ def shaped( An output row of a node that is not one-to-one mixes several input slots, so absence has to reach the operand before the rewrite - consumes it (:func:`propagate_absence`); :func:`fan_in` says which. + consumes it ([`propagate_absence`][]); [`fan_in`][] says which. """ inner = ev(e.operand) if fan_in(e) != 'one-to-one': @@ -345,7 +345,7 @@ def cases(e: program.Cases) -> CompiledExpression: out: the language proved them apart before any data attached, so a coordinate is carried by exactly one of them and the rest are empty there. Adding is therefore the whole of it, and the same - concatenation :class:`~mathspec.program.Add` does. + concatenation `Add` does. """ built = [region(r) for r in e.regions] return CompiledExpression( @@ -422,7 +422,7 @@ def _variable_fragment(self, name: str) -> TermFragment: ``keyed_by`` is stated rather than left to its ``None`` default, because dims are rewritten downstream while the presence frame is not - — the hazard :class:`Presence` names. + — the hazard [`Presence`][] names. """ dims = self.scope.program.variables[name].dims frame = self.scope.variables[name].frame.select( @@ -464,7 +464,7 @@ def _dual_fragment(self, name: str) -> TermFragment: Raises: SpecsolveError: The solve left no duals — the sentence - :meth:`~specsolve.relational.result.Result.dual` gives. + [`dual`][specsolve.relational.result.Result.dual] gives. """ solution = self.solution assert solution is not None @@ -504,7 +504,7 @@ def added( """Const *fragments* added per coordinate onto *carrier* — its columns, then ``cval``. *carrier* is the coordinate product the sum stands over, one row per - coordinate of :meth:`spanned`, restricted by the caller to where every + coordinate of [`spanned`][specsolve.relational.engines.polars.scope.Scope.spanned], restricted by the caller to where every variable under the fragments exists — the rows a constraint over the same expression would keep. *absent* is what a piece with no value at a coordinate adds: ``zero``, what a read reports; ``hole``, the same @@ -556,7 +556,7 @@ def _group_fragment(self, p: TermFragment, g: program.GroupSum, context: str) -> relation fans out instead — a member related to several targets lands a term in each — which is the many-to-many sum the language reads it as. A group is a sum, so it constructs rather than ``replace``s — see - :meth:`_sum_fragment`. + [`_sum_fragment`][]. Several reads ride the same join. """ @@ -574,7 +574,7 @@ def _empty_groups(self, p: TermFragment, g: program.GroupSum) -> pl.LazyFrame: A group with no members contributes nothing, so on a constant side it holds a *value* — the empty sum — and not a hole. The two are the same - missing row to :func:`coverage.constant_side`'s check, + missing row to [`coverage.constant_side`][]'s check, which reads what the fragment produced and cannot see why a label is absent, so the value is written down here where the reason is known. @@ -601,7 +601,7 @@ def _empty_groups(self, p: TermFragment, g: program.GroupSum) -> pl.LazyFrame: def _at_fragment(self, p: TermFragment, a: program.Pullback, context: str) -> TermFragment: """Spread the consumed dims back out over the produced ones — the adjoint of a group. - The same mapping table as :meth:`_group_fragment`, joined on the other + The same mapping table as [`_group_fragment`][], joined on the other columns, so the join **fans out**: one row per consumed tuple lands on every produced tuple sharing it. Still one equi-join against a table the frame holds, so the locality class does not move. @@ -622,7 +622,7 @@ def _at_fragment(self, p: TermFragment, a: program.Pullback, context: str) -> Te def _pulled_back_presences(self, p: TermFragment, a: program.Pullback) -> tuple[Presence, ...]: """Where a pullback's variables exist, keyed by the fine dims they now span. - Two absences reach the fine coordinate and :meth:`_remap_fragment`'s + Two absences reach the fine coordinate and [`_remap_fragment`][]'s inner join swallows both — the operand's own, and the **relation's**, where the map has no row for the key. Unreported, the term merely vanishes and its row survives to assert `x <= 0` where the model said @@ -632,7 +632,7 @@ def _pulled_back_presences(self, p: TermFragment, a: program.Pullback) -> tuple[ rather than a restriction admitting everything. The key is stated rather than left implied because a later product widens the fragment's dims while this frame keeps the columns that matter — the hazard - :class:`Presence` names. + [`Presence`][] names. """ joined = a.direction.joined_dims fine = (*joined, *a.direction.produced_dims) @@ -658,10 +658,10 @@ def pulled(presence: Presence) -> Presence: def _remap_fragment(self, p: TermFragment, node: program.GroupSum | program.Pullback) -> TermFragment: """Trade the dims *node*'s direction consumes for the ones it produces, through its relation. - One inner equi-join against :func:`mapping`, keyed as :func:`walk_join` + One inner equi-join against [`mapping`][], keyed as [`walk_join`][] says. A group consumes the dims its direction is over - (:meth:`_group_fragment`); a pullback reads the same table backwards - (:meth:`_at_fragment`). + ([`_group_fragment`][]); a pullback reads the same table backwards + ([`_at_fragment`][]). """ frame, dims = walk_join(p.frame, mapping(self.scope.data.relations, node.direction), node, p.dims, p.carried) return TermFragment(dims, frame, p.kind, region=region_over(p.region, dims), parameters=p.parameters) diff --git a/src/specsolve/relational/engines/polars/coverage.py b/src/specsolve/relational/engines/polars/coverage.py index 522b7ae1..f75b04a5 100644 --- a/src/specsolve/relational/engines/polars/coverage.py +++ b/src/specsolve/relational/engines/polars/coverage.py @@ -10,15 +10,15 @@ position the gap looks like asked ============================= =================================== ========================================== a divisor under a term a null coefficient in the share before the terminal aggregate reads it as 0 -a divisor under a constant a null value in the piece before :func:`~fragments.constant_scalar` sums it away +a divisor under a constant a null value in the piece before [`constant_scalar`][fragments.constant_scalar] sums it away a constant piece the row sees a null after the join onto the rows on the rows pass itself a constant piece summed away a coordinate the parameter lacks of the parameter, the piece no longer showing it ============================= =================================== ========================================== Which parameters stand as constant pieces, and under which region of a ``cases:`` block, is read off the fragments the compiler built -(:attr:`~fragments.TermFragment.parameters`, -:attr:`~fragments.TermFragment.region`), so the rule is decided once, where +([`parameters`][fragments.TermFragment.parameters], +[`region`][fragments.TermFragment.region]), so the rule is decided once, where the pieces are made. """ @@ -68,8 +68,8 @@ def refuse_null_coefficients(stacked: pl.DataFrame, subject: str, *expressions: def refuse_null_constants(pieces: Sequence[pl.LazyFrame], divisors: Collection[str], subject: str) -> None: """A null value in a constant *piece* means a divisor had no value where the model divided. - :func:`refuse_null_coefficients` one position over, and asked before - :func:`~fragments.constant_scalar` rather than after: a constant piece is + [`refuse_null_coefficients`][] one position over, and asked before + [`constant_scalar`][fragments.constant_scalar] rather than after: a constant piece is summed per coordinate on its way to the row, and polars reads a null as zero, so a gap left behind for this to find is filled in by the time the assembled constant is joined. *pieces* are narrowed by the caller to the @@ -88,7 +88,7 @@ def refuse_null_constants(pieces: Sequence[pl.LazyFrame], divisors: Collection[s def narrowed_to_rows(rows: pl.LazyFrame, consts: Sequence[TermFragment]) -> list[pl.LazyFrame]: - """Each constant piece cut to the rows built, for :func:`refuse_null_constants`. + """Each constant piece cut to the rows built, for [`refuse_null_constants`][]. A piece keeping the row's own dims is narrowed to the rows built, the semi-join standing in for the inner join that narrows a term; one that @@ -126,7 +126,7 @@ def constant_side( caught here and nowhere else. What it cannot answer for is a gap an aggregation summed away, which is - :func:`refuse_short_constants`' question. + [`refuse_short_constants`][]' question. Raises: DataError: A constant piece with no value at a row it is owed. @@ -173,7 +173,7 @@ def refuse_short_constants( ) -> None: """A parameter on a constant side must cover the coordinates the rows ask of it. - Asked of the *parameter* where :func:`constant_side` asks the assembled + Asked of the *parameter* where [`constant_side`][] asks the assembled piece, because the parameter is what still has the answer once an aggregation has stood between the two: a summed piece carries one row per coordinate it does cover, so the gap it left is not a null a join can find diff --git a/src/specsolve/relational/engines/polars/engine.py b/src/specsolve/relational/engines/polars/engine.py index e4f9cbec..bcfca402 100644 --- a/src/specsolve/relational/engines/polars/engine.py +++ b/src/specsolve/relational/engines/polars/engine.py @@ -1,10 +1,10 @@ """Polars engine: build the model frames, hand them to a sink, read the answer back. The engine owns the lifecycle — a build, its solver, the counters and clocks -:meth:`PolarsEngine.diagnostics` reports — and none of the three questions it -asks on the way: what the data is (:mod:`~specsolve.relational.engines.polars.attaching`), -what each declaration contributes (:mod:`~specsolve.relational.engines.polars.assembly`), -how a row or a solve reads back (:mod:`~specsolve.relational.engines.polars.readback`). +[`PolarsEngine.diagnostics`][] reports — and none of the three questions it +asks on the way: what the data is ([`attaching`][specsolve.relational.engines.polars.attaching]), +what each declaration contributes ([`assembly`][specsolve.relational.engines.polars.assembly]), +how a row or a solve reads back ([`readback`][specsolve.relational.engines.polars.readback]). The lane is described in docs/about/architecture.md. """ @@ -51,14 +51,14 @@ def _no_built_model(doing: str) -> str: class PolarsEngine: - """Build a :class:`Program` into polars frames, then sink it.""" + """Build a ``Program`` into polars frames, then sink it.""" def __init__(self) -> None: #: The build, or ``None`` where there is not one — closed, released by #: an update that raised, or never run. self._built: BuiltModel | None = None #: What the last build measured about itself. Outlives ``_built``, - #: since :meth:`diagnostics` answers after :meth:`close`. + #: since [`diagnostics`][] answers after [`close`][]. self._measured = Measured() #: The solver holding this model, kept between solves — the only thing #: a rebuild does *not* throw away. ``None`` until one has been solved. @@ -89,7 +89,7 @@ def build(self, program: program.Program, sources: Mapping[str, pl.LazyFrame]) - **A second call rebuilds over the same object**, which is what ``update`` is. The previous build is released *before* this one starts, and the held solver is asked for its - :meth:`~specsolve.relational.sinks.solvers.base.Solver.structure` first, + [`structure`][specsolve.relational.sinks.solvers.base.Solver.structure] first, reading it being what lets go of these frames. A build that raises leaves no model at all rather than half of one, and ``diagnostics()`` answers from what was measured by then. @@ -110,7 +110,7 @@ def build(self, program: program.Program, sources: Mapping[str, pl.LazyFrame]) - # ------------------------------------------------------------------ def row(self, name: str, coordinate: Mapping[str, object]) -> ConstraintRow: - """One built constraint row, spelled back out. See :meth:`~specsolve.api.Model.row`.""" + """One built constraint row, spelled back out. See [`row`][specsolve.api.Model.row].""" if self._built is None: raise SpecsolveError(_no_built_model(f"to read '{name}' out of")) return readback.row(self._built, name, coordinate) @@ -120,7 +120,7 @@ def write(self, path: str | Path) -> None: A construct the format has no section for is refused here, the way the solve path refuses one a solver cannot ingest - (:func:`~specsolve.relational.sinks.refusal`). + ([`refusal`][specsolve.relational.sinks.refusal]). Raises: ValueError: A suffix nothing writes. @@ -146,25 +146,25 @@ def solve( """Hand the built model to a solver and solve it. The solver stays loaded where it can, which is - :func:`~specsolve.relational.sinks.solvers.loaded`'s decision: an updated + [`loaded`][specsolve.relational.sinks.solvers.loaded]'s decision: an updated model has its new numbers pushed onto what the solver already holds, and one whose structure moved is loaded again. A construct the solver cannot ingest is refused before the load - (:func:`~specsolve.relational.sinks.refusal`). + ([`refusal`][specsolve.relational.sinks.refusal]). Args: - solver_name: One of :data:`~specsolve.relational.sinks.SOLVERS`. + solver_name: One of [`SOLVERS`][specsolve.relational.sinks.SOLVERS]. solver_options: Forwarded to the solver verbatim, in its own vocabulary (``{'time_limit': 60, 'mip_rel_gap': 0.01}``). keep: How much of the session this solve may keep — one of - :data:`~specsolve.relational.result.KEEPS`. A preference, not a + [`KEEPS`][specsolve.relational.result.KEEPS]. A preference, not a guarantee: a model whose structure moved is loaded again whatever was asked, and - :attr:`~specsolve.relational.result.Result.kept` reports what + [`kept`][specsolve.relational.result.Result.kept] reports what happened. ``nothing`` is held to structurally, the held solver being closed before the load decision. lower: How an expression the caller *writes* becomes a plan node, - for :meth:`~specsolve.relational.result.Result.evaluate`. Passed + for [`evaluate`][specsolve.relational.result.Result.evaluate]. Passed in because lowering reads the spec as written, which nothing under ``relational/`` sees (docs/about/architecture.md, hard rule 2). ``None`` for a build from an already-lowered @@ -176,7 +176,7 @@ def solve( Raises: SpecsolveError: A *keep* outside - :data:`~specsolve.relational.result.KEEPS`. + [`KEEPS`][specsolve.relational.result.KEEPS]. """ if keep not in KEEPS: raise SpecsolveError(unknown_keep_message(keep)) @@ -242,7 +242,7 @@ def contents(self) -> str: def diagnostics(self) -> Diagnostics: """What this build and its solves did that the answer does not show. - Answerable after :meth:`close`: every field is a count, a clock or a + Answerable after [`close`][]: every field is a count, a clock or a small frame this keeps, not a read of the model it releases. """ measured = self._measured @@ -274,14 +274,14 @@ def _read_back( activity: pl.Series | None, dual_ray: pl.Series | None, ) -> tuple[dict[str, pl.LazyFrame], ...]: - """One solve's answer as one frame per declaration — a :class:`Result`'s own. + """One solve's answer as one frame per declaration — a [`Result`][]'s own. References rather than copies: the frames point at this build's label - frames, and :meth:`build` replacing the registries takes nothing from + frames, and [`build`][] replacing the registries takes nothing from what an earlier result still holds. Lazy, so each declaration's plan is composed only when it is read. A vector that is ``None`` yields no frames at all rather than empty ones, which is the state - :class:`Result` reports through the status. + [`Result`][] reports through the status. """ model = self._model program = model.program @@ -316,7 +316,7 @@ def _readers( dict[str, Callable[[], pl.DataFrame]], Callable[[str | Mapping[str, object]], pl.DataFrame] | None, ]: - """What :meth:`~specsolve.relational.result.Result.evaluate` reads through: a reader per declared name, and the ad-hoc evaluator. + """What [`evaluate`][specsolve.relational.result.Result.evaluate] reads through: a reader per declared name, and the ad-hoc evaluator. Both close over the same snapshot the result *owns* — the program, the attached data, a copy of this build's variable-frame registry and the @@ -325,7 +325,7 @@ def _readers( compiled until a reader is called. The evaluator is served whenever *lower* is given: a loaded answer - rebuilds it (:meth:`reconstruct`), and a build off a lowered ``Program``, + rebuilds it ([`reconstruct`][]), and a build off a lowered ``Program``, which has no spec as written, does not. """ if primal is None: @@ -346,7 +346,7 @@ def reconstruct( The primal and dual are reconstructed from the frames a save wrote, this build supplying the labels that put the values back in vector order - (:func:`readback.reordered`); the evaluator is then the one :meth:`solve` + ([`readback.reordered`][]); the evaluator is then the one [`solve`][] hands a live result. It comes back where *lower* is given. Args: @@ -379,10 +379,10 @@ def _quadratic_constraints(self) -> list[str]: # ------------------------------------------------------------------ def close(self) -> None: - """Drop the built model. A :class:`Result` keeps its own frames. + """Drop the built model. A [`Result`][] keeps its own frames. A loaded solver goes first, being the one thing here that is not this - process's memory. :meth:`diagnostics` still answers afterwards. + process's memory. [`diagnostics`][] still answers afterwards. """ if self._solver is not None: self._solver.close() @@ -398,7 +398,7 @@ def __exit__(self, *exc: object) -> Literal[False]: def _per_name(kind: str, measured: Mapping[str, object], **columns: PolarsDataType) -> pl.DataFrame: - """One :class:`~specsolve.relational.result.Diagnostics` frame: a row per name in *measured*, in build order. + """One [`Diagnostics`][specsolve.relational.result.Diagnostics] frame: a row per name in *measured*, in build order. *kind* names the first column, and the remaining *columns* carry each value in order — a scalar for one column, a tuple for several. @@ -416,7 +416,7 @@ def expression_readers( sources: Mapping[str, pl.LazyFrame], lower: Callable[[str | Mapping[str, object]], program.Expression] | None, ) -> tuple[dict[str, Callable[[], pl.DataFrame]], Callable[[str | Mapping[str, object]], pl.DataFrame] | None]: - """Attach *sources* and defer the reads :func:`specsolve.evaluate` values one expression through. + """Attach *sources* and defer the reads [`specsolve.evaluate`][] values one expression through. A spec that declares no variables is a calculation rather than an optimisation, so every expression has a value with no solver and no chosen @@ -425,7 +425,7 @@ def expression_readers( Args: program: A lowered program with no variables — a calculation. - sources: Tidied sources, as :func:`~specsolve.sources.tidy_sources` produces. + sources: Tidied sources, as [`tidy_sources`][specsolve.sources.tidy_sources] produces. lower: How an ad-hoc expression becomes a plan node in the model's namespace, or ``None`` where ad-hoc evaluation is not offered. diff --git a/src/specsolve/relational/engines/polars/fragments.py b/src/specsolve/relational/engines/polars/fragments.py index 07438169..50e58e99 100644 --- a/src/specsolve/relational/engines/polars/fragments.py +++ b/src/specsolve/relational/engines/polars/fragments.py @@ -126,7 +126,7 @@ class TermFragment: kind: Kind presences: tuple[Presence, ...] = () - """Where the variables under this fragment exist — see :class:`Presence`. + """Where the variables under this fragment exist — see [`Presence`][]. Empty is nothing to report: a constant fragment has no variable, and a reduction clears it, ``sum`` skipping absent slots rather than propagating @@ -138,7 +138,7 @@ class TermFragment: """ region: program.Mask | None = None - """The region of a :class:`~mathspec.program.Cases` this piece was built under. + """The region of a `Cases` this piece was built under. ``None`` where the piece stands over the whole frame, which is everything outside a ``cases:`` block. Set, it says the piece covers that region *by @@ -149,7 +149,7 @@ class TermFragment: It travels through the arithmetic, and a product of two regions is the conjunction: ``ramp_limit * previous_status`` has a value exactly where ``previous_status`` does. A reduction that drops a dim the region reads - drops the region with it (:func:`region_over`): it can no longer say which + drops the region with it ([`region_over`][]): it can no longer say which of the rows summed into a coordinate it claimed. """ @@ -160,7 +160,7 @@ class TermFragment: in ``x + hi`` or in ``sum(x) + sum(hi)``. A parameter a variable stands with in a product is a coefficient instead, and a sparse coefficient is a zero the absence rules allow, so a term carries none and a product strips the - factor that stood beside a variable (:meth:`PolarsCompiler.expression`). + factor that stood beside a variable ([`PolarsCompiler.expression`][specsolve.relational.engines.polars.compiler.PolarsCompiler.expression]). A divisor is never owed here either: it has its own check. """ @@ -238,7 +238,7 @@ def absence_restrictions(fragments: Sequence[TermFragment]) -> list[Presence]: rules): ``x + y >= 10`` where ``y`` is masked is not ``x >= 10``, it is no constraint at all. Only *variable* absence counts — a sparse parameter's missing rows mean a zero coefficient — which is why the fragment carries - :attr:`TermFragment.presences` separately from its frame. + [`TermFragment.presences`][] separately from its frame. *Having* no dims is not *having nothing to restrict*: a masked scalar variable restricts every row of every constraint naming it, all or nothing. @@ -250,7 +250,7 @@ def absence_restrictions(fragments: Sequence[TermFragment]) -> list[Presence]: #: How a node's output rows relate to its input slots, answered by -#: :func:`fan_in` for every node. +#: [`fan_in`][] for every node. FanIn = Literal['one-to-one', 'many-to-one', 'one-to-many'] @@ -258,7 +258,7 @@ def fan_in(expression: program.Expression) -> FanIn: """How *expression*'s output rows relate to its input slots. Both classes other than ``'one-to-one'`` sum several input slots into an - output row, so :func:`propagate_absence` runs before them. + output row, so [`propagate_absence`][] runs before them. """ if isinstance(expression, program.Named): return fan_in(expression.body) @@ -301,7 +301,7 @@ def propagate_absence(compiled: CompiledExpression) -> CompiledExpression: Applied only where the key columns are dims the fragment carries: a restriction naming a dim a fragment lacks cannot speak about it. - **Which operators need it is decided by their fan-in** (:func:`fan_in`), + **Which operators need it is decided by their fan-in** ([`fan_in`][]), which the compiler reads. Many-to-one and one-to-many mix several input slots into an output row: the row-level intersection at assembly can say the *row* survives, never @@ -427,7 +427,7 @@ def join_mul(a: TermFragment, c: TermFragment, kind: Kind, divide: bool = False) def join_pow(a: TermFragment, b: TermFragment) -> TermFragment: """``a ** b``, both const fragments — one const fragment out. - :func:`join_mul`'s shape with ``pow`` in place of ``*``, and the same + [`join_mul`][]'s shape with ``pow`` in place of ``*``, and the same reason for renaming the right-hand value first: both sides carry ``cval``. An **inner** join, unlike divide's left: an exponent with no value at a coordinate is not a division by a hole, it is a factor the model never @@ -464,7 +464,7 @@ def join_quad(a: TermFragment, b: TermFragment) -> TermFragment: Nothing is canonicalised here: which of ``x * y`` and ``y * x`` a pair is depends on column labels, which fragments do not carry until the engine - places them (:meth:`Assembly._build_objective`). + places them ([`Assembly._build_objective`][specsolve.relational.engines.polars.assembly.Assembly._build_objective]). """ shared = [d for d in a.dims if d in b.dims] out_dims = a.dims + tuple(d for d in b.dims if d not in a.dims) diff --git a/src/specsolve/relational/engines/polars/labels.py b/src/specsolve/relational/engines/polars/labels.py index 9e4aee97..5ddf9c3f 100644 --- a/src/specsolve/relational/engines/polars/labels.py +++ b/src/specsolve/relational/engines/polars/labels.py @@ -6,14 +6,14 @@ (docs/about/architecture.md, "The relational lane"). Variables and constraint rows are the same operation over different frames, so -:func:`frame` is written once — one rule, sort the survivors into declaration +[`frame`][] is written once — one rule, sort the survivors into declaration order and number them from *start*. A mask, a restriction or neither produce the same shape down to the schema. The one split kept is *how much product is materialised*. A mask that cannot see the leading dims removes the same coordinates under every one of their values, so the survivors are a rectangle and only the masked suffix needs rows -(:func:`_factored`). +([`_factored`][]). """ from __future__ import annotations @@ -42,7 +42,7 @@ class Labelled: """One declaration's labelled frame, and the contiguous run of labels it owns. The frame and its run move together — a dropped row renumbers both. - :func:`frame` numbers a declaration's survivors contiguously from + [`frame`][] numbers a declaration's survivors contiguously from ``start``, which is what makes its share of a solver vector a slice. """ @@ -75,12 +75,12 @@ def frame( still occurs. Which rows they remove is unknown until data is read, so a restriction takes the counted path whatever the mask looks like. - No dims means the carrier is :data:`UNIT`, selected because selecting + No dims means the carrier is [`UNIT`][], selected because selecting nothing would drop the one row of the empty coordinate product. **Nothing sorts unless the data says it must.** The product is *produced* in declaration order, a filter keeps it and a semi-join usually does, so - :func:`in_position_order` verifies linearly and sorts only when the engine + [`in_position_order`][] verifies linearly and sorts only when the engine emitted another order. **Nothing renumbers unless a row was dropped**, either. With neither mask @@ -125,7 +125,7 @@ def frame( def declared_height(scope: Scope, dims: tuple[str, ...], where: program.Mask | None) -> int: """How many rows a declaration *asks* for: its coord product under its own mask. - The count :func:`frame` would return if no variable's absence restricted it, + The count [`frame`][] would return if no variable's absence restricted it, so the difference between the two is the rows a propagated absence removed — which nothing else records, a restricted row never existing to be counted. @@ -162,7 +162,7 @@ def _factored( **The survivors go on the left of the cross join**, so survivors turning over within each head coordinate is label order and - :func:`in_position_order` permutes nothing. Which side cycles is polars' + [`in_position_order`][] permutes nothing. Which side cycles is polars' own business, asserted nowhere: the verify is what makes it safe to exploit. """ head, kept = dims[:free], dims[free:] @@ -206,7 +206,7 @@ def _free_prefix(dims: tuple[str, ...], touched: frozenset[str]) -> int: def _row_major(scope: Scope, dims: tuple[str, ...]) -> pl.Expr: - """:meth:`Scope.row_major` over a product frame, which carries its ordinals.""" + """[`Scope.row_major`][] over a product frame, which carries its ordinals.""" return scope.row_major(dims, lambda d: pl.col(ordinal(d))) diff --git a/src/specsolve/relational/engines/polars/predicates.py b/src/specsolve/relational/engines/polars/predicates.py index 7f4252e7..a7b5b94b 100644 --- a/src/specsolve/relational/engines/polars/predicates.py +++ b/src/specsolve/relational/engines/polars/predicates.py @@ -7,11 +7,11 @@ A closed vocabulary of its own — comparisons against a parameter, a dimension label, a position along a dimension, a relation, and the three connectives. It -takes the :class:`~specsolve.relational.engines.polars.scope.Scope` as an -argument and holds nothing. :func:`masked` is the product a declaration is +takes the [`Scope`][specsolve.relational.engines.polars.scope.Scope] as an +argument and holds nothing. [`masked`][] is the product a declaration is instantiated over, cut by its mask: the one place the two meet. -:class:`Carrier` lives here too, and the bounds walk imports it: both walks +[`Carrier`][] lives here too, and the bounds walk imports it: both walks that read parameters build an expression over columns they are joining on as they go. """ @@ -41,8 +41,8 @@ class Carrier: """A frame a walk joins onto, each attachment made at most once. - Both walks that read parameters — the mask (:func:`compile_predicate`) - and the bounds (:meth:`~specsolve.relational.engines.polars.compiler.PolarsCompiler.bounds`) + Both walks that read parameters — the mask ([`compile_predicate`][]) + and the bounds ([`bounds`][specsolve.relational.engines.polars.compiler.PolarsCompiler.bounds]) — build an expression over columns they are joining on as they go, so the frame and the set of aliases already attached travel together. """ @@ -119,7 +119,7 @@ def compile_predicate( **A name the mask is certain of is joined rather than left-joined**, and a certain variable is semi-joined and never read - (:func:`_certain_names`). An atom over a missing value reads as false + ([`_certain_names`][]). An atom over a missing value reads as false either way, so the strategies differ only in *where* the row is dropped. ``VariableDefined`` is the one atom answered by a join rather than a @@ -127,7 +127,7 @@ def compile_predicate( dims the dim rule has already checked are inside this frame. No join here maintains order: consumers verify where they read - (:func:`labels.in_position_order`), so a shuffle costs a sort + ([`labels.in_position_order`][]), so a shuffle costs a sort downstream at worst, never a wrong label. """ certain = _certain_names(mask) @@ -297,7 +297,7 @@ def _values(scope: Scope, side: program.Expression) -> tuple[pl.LazyFrame, tuple translations and groupings included, and a second reading of it would drift from the one the rows are built with. Its pieces are added so that a null spreads, which a row's constant side reads as a zero instead; a - side with no value is the false :func:`falsy_if_null` reads out of it. + side with no value is the false [`falsy_if_null`][] reads out of it. """ # in-function: the compiler imports this module from specsolve.relational.engines.polars.compiler import PolarsCompiler @@ -369,7 +369,7 @@ def _certain_names(mask: program.Mask) -> frozenset[str]: def _refuse_short_groups(p: program.DimensionPosition, grouping: Grouping) -> None: """Refuse a position no coordinate of some group occupies. - The ungrouped counterpart is :func:`_position_ordinal`, and the reason is + The ungrouped counterpart is [`_position_ordinal`][], and the reason is the same one construct-wide: a boundary clause that silently seeds no row leaves that group's recurrence unanchored. Grouping only multiplies the chance — one short period is enough — so it is checked per group. diff --git a/src/specsolve/relational/engines/polars/readback.py b/src/specsolve/relational/engines/polars/readback.py index e8a36347..0287d2a3 100644 --- a/src/specsolve/relational/engines/polars/readback.py +++ b/src/specsolve/relational/engines/polars/readback.py @@ -28,7 +28,7 @@ def row(model: BuiltModel, name: str, coordinate: Mapping[str, object]) -> ConstraintRow: - """One built constraint row, spelled back out. See :meth:`~specsolve.api.Model.row`. + """One built constraint row, spelled back out. See [`row`][specsolve.api.Model.row]. Three positional takes against frames the build already keeps, and no scan of the matrix: the constraint's own coordinate frame carries the global row @@ -153,7 +153,7 @@ def laid_out( ) -> pl.LazyFrame: """One declaration's coordinates in label order, beside its share of *values*. - The order was never lost: :func:`labels.frame` hands back a + The order was never lost: [`labels.frame`][] hands back a label-ascending frame, and the solver's vector is positional in the same index. The share is attached as a column rather than concatenated as a frame, so a mismatched length raises instead of padding with nulls. @@ -177,10 +177,10 @@ def reordered( declared: Mapping[str, program.VariableDeclaration | program.ConstraintDeclaration], frames: Mapping[str, pl.DataFrame], ) -> pl.Series: - """A saved solution's value frames back as the positional vector — the inverse of :func:`laid_out`. + """A saved solution's value frames back as the positional vector — the inverse of [`laid_out`][]. Each declaration's ``(dims…, value)`` is aligned to its - :class:`~labels.Labelled` frame's label order and the values concatenated in + [`Labelled`][labels.Labelled] frame's label order and the values concatenated in ``start`` order, rebuilding the vector a solver returned. A rebuild of the model over the same spec and sources numbers the labels identically (docs/about/architecture.md, "The relational lane"). @@ -208,7 +208,7 @@ def _aligned( """One declaration's saved values in its label order — its slice of the vector. The values are joined onto the rebuilt label frame on the dims, the string - ones cast as :func:`laid_out` casts them. A declaration the rebuild masks + ones cast as [`laid_out`][] casts them. A declaration the rebuild masks away entirely holds no label, so its slice is empty and a missing *stored* is no error; a missing one the rebuild does build raises. """ @@ -241,7 +241,7 @@ def readers( named: Mapping[str, program.ExpressionDeclaration], lower: Callable[[str | Mapping[str, object]], program.Expression] | None, ) -> tuple[dict[str, Callable[[], pl.DataFrame]], Callable[[str | Mapping[str, object]], pl.DataFrame] | None]: - """The reads :meth:`~specsolve.relational.result.Result.evaluate` is built from, over one compiler. + """The reads [`evaluate`][specsolve.relational.result.Result.evaluate] is built from, over one compiler. Every producer of them — a live solve, a rebuilt archive, and the variable-free arithmetic path — comes through here, so the read a declared diff --git a/src/specsolve/relational/engines/polars/reindex.py b/src/specsolve/relational/engines/polars/reindex.py index f2ca264c..79bd8880 100644 --- a/src/specsolve/relational/engines/polars/reindex.py +++ b/src/specsolve/relational/engines/polars/reindex.py @@ -8,10 +8,10 @@ operator has to answer: what happens at the edge, where the walk runs out of dimension. -Both take the :class:`~specsolve.relational.engines.polars.scope.Scope` and +Both take the [`Scope`][specsolve.relational.engines.polars.scope.Scope] and hold nothing. They read three things off it — ``data``, ``program`` and ``widen`` — and everything else here is their own, built once per operator -as an :class:`_Order`. +as an [`_Order`][]. """ from __future__ import annotations @@ -53,7 +53,7 @@ class _Order: """ #: The groups the walk stays inside — the whole dimension as one group - #: where no ``by=`` was written (:meth:`Grouping.whole`). + #: where no ``by=`` was written ([`Grouping.whole`][]). grouping: Grouping incoming: pl.LazyFrame outgoing: pl.LazyFrame @@ -108,7 +108,7 @@ def translate_rows( ) -> pl.LazyFrame: """*frame*'s rows moved *offset* positions along *along*, the end the move vacates dropped. - The predicate form of :func:`translate_fragment`: no partition, no named + The predicate form of [`translate_fragment`][]: no partition, no named offset, no wrap and nothing to fill, since false is what a missing row already means in a mask. *carried* is the columns that travel with the coordinates. @@ -127,7 +127,7 @@ def window_fragment(scope: Scope, p: TermFragment, s: program.WindowSum, context The lag table is built to the widest window the data asks for; a named width then keeps only the lags that entity reaches. Every join is still on a dim-table key or the width's own dims, so the reach stays a relation - and the locality class is the one :meth:`translate_fragment` has. + and the locality class is the one [`translate_fragment`][] has. Unlike a shift this vacates nothing: the window at the first position is short rather than empty, since it always contains that position @@ -138,7 +138,7 @@ def window_fragment(scope: Scope, p: TermFragment, s: program.WindowSum, context Under ``by=`` the walk is inside the group: positions are the within-group rank rather than the ``ord`` along the whole dimension, and a wrap closes on the group's - own size, exactly as :func:`translate_fragment` walks a partitioned shift. + own size, exactly as [`translate_fragment`][] walks a partitioned shift. """ if s.along not in p.dims: refuse_a_fragment_without_the_dims(p, [s.along], context, f'sum_back(along={s.along!r})') @@ -185,7 +185,7 @@ def translate_fragment(scope: Scope, p: TermFragment, s: program.Translate, cont Both joins are on a dim-table key, so the row count is unchanged and an out-of-range ordinal does not join. No window function; bounded-halo - locality. The operand's *presences* are :func:`travelled_presences` below. + locality. The operand's *presences* are ``travelled_presences`` below. Every fill over a *constant* is written, ``0`` included: the arithmetic is unchanged, but the slot now has a value, so asking for @@ -225,7 +225,7 @@ def travelled_presences() -> tuple[Presence, ...]: An existing presence **travels**: the coordinate set goes through the same map the rows did, and the inner join drops whatever the edge vacated. Under a fill the vacated positions go back in - (:meth:`_vacated`) — a filled slot counts as present. A narrow + ([`_vacated`][]) — a filled slot counts as present. A narrow presence is widened first when the shift moves a dim it is silent about, since there is no column to remap otherwise. @@ -260,7 +260,7 @@ def travelled(presence: Presence) -> Presence: @dataclass(frozen=True) class _Edge: - """The edge of an acyclic shift along an :class:`_Order`: which coordinates it vacates, and what keys them. + """The edge of an acyclic shift along an [`_Order`][]: which coordinates it vacates, and what keys them. Keyed by the translated dimension, a named offset's own dims and the partition's joined dims, each once. How far back a row reaches decides @@ -292,7 +292,7 @@ def keys(self) -> tuple[str, ...]: return tuple(dict.fromkeys((self.order.grouping.dimension, *self.offset_dims, *self.order.grouping.joined))) def coordinates(self, *, vacated: bool) -> pl.LazyFrame: - """The coordinates the shift vacates, or keeps, under :attr:`keys`. + """The coordinates the shift vacates, or keeps, under [`keys`][]. Exact complements: a fill and the presence set it implies must not disagree about which coordinates the edge is. Under a partition the @@ -300,7 +300,7 @@ def coordinates(self, *, vacated: bool) -> pl.LazyFrame: the translation itself walks: a coordinate reaches outside its own group exactly where it would have reached outside the dimension. A coordinate in no group is neither — it is absent, the reading - :meth:`_Order.placed` gives it, so it is not in the table at all. A + [`Grouping.placed`][] gives it, so it is not in the table at all. A per-group offset reaches it by the group column rather than by a cross join, one lag standing for the whole group. """ diff --git a/src/specsolve/relational/engines/polars/relations.py b/src/specsolve/relational/engines/polars/relations.py index 8f06dd02..3d5b0844 100644 --- a/src/specsolve/relational/engines/polars/relations.py +++ b/src/specsolve/relational/engines/polars/relations.py @@ -1,15 +1,15 @@ """A relation's table as a call reads it — the one place a role becomes a column. -The plan's :class:`~mathspec.program.Direction` names *roles*: which columns of +The plan's `Direction` names *roles*: which columns of a relation an operator consumes, produces and joins on. The engine reads by *dimension*, since an operand carries its coordinates under the dimensions' names. Everything here is that translation, spelled once: - a group or a pullback trades the dimensions its direction consumes for the - ones it produces through :func:`walk_join`, against the :func:`mapping` + ones it produces through [`walk_join`][], against the [`mapping`][] table; -- a :class:`~mathspec.program.Partition` ranks the dimension it steps along - inside a :class:`Grouping`. +- a `Partition` ranks the dimension it steps along + inside a [`Grouping`][]. Nothing here reads data or holds state: every function takes the attached frames and returns a lazy query. @@ -29,7 +29,7 @@ from specsolve.relational.engines.polars.attaching import AttachedSources -#: What a :class:`Grouping` adds to a dimension table: a coordinate's rank +#: What a [`Grouping`][] adds to a dimension table: a coordinate's rank #: inside its group, and the group's size — the position and span a #: partitioned walk reads. GROUP_RANK = '__pos in group__' @@ -47,7 +47,7 @@ def landing(dim: str) -> str: def group_column(role: str) -> str: - """The column a :class:`Grouping` carries one group-making value column of the relation under. + """The column a [`Grouping`][] carries one group-making value column of the relation under. Named for the role rather than its dimension, since a group may hold two columns over one dimension, and kept apart from the dimension's own name, @@ -65,7 +65,7 @@ def mapping(relations: Mapping[str, pl.LazyFrame], direction: program.Direction) """The table a group or a pullback joins against — the relation, read as the direction names it. Consumed and joined columns arrive under their dimensions, produced ones - under :func:`landing`. A key the direction does not map has no row in the + under [`landing`][]. A key the direction does not map has no row in the relation and so none here, which is what "reaches no slot" means. """ table = relations[direction.name] @@ -100,7 +100,7 @@ def walk_join( Args: frame: The operand, carrying *have* and *columns*. - mapping: :func:`mapping` for the node's direction. + mapping: [`mapping`][] for the node's direction. node: The group or the pullback, whose direction says what is consumed, what is produced and what is joined on. have: The dimensions *frame* carries. @@ -129,7 +129,7 @@ class Grouping: A group is the partition's group columns at each coordinate of its joined dimensions — a season per generator, where the relation is keyed by both. - The inner join behind :attr:`table` is where "this coordinate is in no + The inner join behind [`table`][] is where "this coordinate is in no group" comes from: it has no row in the relation, so it has none here, and every rank, span and neighbour a partitioned call reads sees only coordinates that are in one. @@ -138,7 +138,7 @@ class Grouping: dimension: The dimension stepped along. joined: The partition's other key dimensions, which the operand carries. groups: The group-making columns, one per group column, under - :func:`group_column`. + [`group_column`][]. grouped: The dimension each group column is over, in the same order. table: ``(val, ord, joined…, groups…, GROUP_RANK, GROUP_SIZE)``, one row per coordinate the partition places in a group. @@ -201,7 +201,7 @@ def partial(self) -> bool: return bool(self.groups) def placed(self) -> pl.LazyFrame: - """The coordinates the partition places in some group, under :attr:`keys`. + """The coordinates the partition places in some group, under [`keys`][]. The rest belong to none, so a partitioned call reaches nothing for them and their rows are not built — the reading ``sum(by=)`` gives a diff --git a/src/specsolve/relational/engines/polars/scope.py b/src/specsolve/relational/engines/polars/scope.py index 9920b950..758d5d38 100644 --- a/src/specsolve/relational/engines/polars/scope.py +++ b/src/specsolve/relational/engines/polars/scope.py @@ -57,9 +57,9 @@ def product(self, dims: tuple[str, ...]) -> pl.LazyFrame: **Folded in reverse, then projected back.** polars' streaming engine walks a cross join right-major, so folding backwards makes the product arrive in declaration row-major order — label order. - :func:`labels.frame` verifies that rather than trusting it. + [`labels.frame`][] verifies that rather than trusting it. - The empty product is one *real* row carrying only :data:`UNIT`: a + The empty product is one *real* row carrying only [`UNIT`][]: a ``where`` on a scalar declaration filters this frame, and nothing survives a filter. """ @@ -89,7 +89,7 @@ def parameter_join( *how* is ``left`` for a bound, where a missing value is a fact to report rather than a row to drop. ``inner`` is the mask walk's story - (:func:`~specsolve.relational.engines.polars.predicates.compile_predicate`). + ([`compile_predicate`][specsolve.relational.engines.polars.predicates.compile_predicate]). *maintain_order* is asked for only by the bounds, which become ``cols`` and are read in order; every other consumer verifies order where it @@ -111,7 +111,7 @@ def row_major(self, dims: tuple[str, ...], ordinals: Callable[[str], pl.Expr]) - empty product's one row. *ordinals* says how the frame in hand carries a dim's ordinal — a product frame has the column beside the label, a built variable frame kept only the label and reads it through - :meth:`ordinal_of`. + [`ordinal_of`][]. """ position: pl.Expr = pl.lit(0, dtype=pl.Int64) for d in dims: diff --git a/src/specsolve/relational/parquet.py b/src/specsolve/relational/parquet.py index a50c0686..5ce5fd6a 100644 --- a/src/specsolve/relational/parquet.py +++ b/src/specsolve/relational/parquet.py @@ -4,7 +4,7 @@ answers with — the primals, the duals, the named expressions — so a constraint carrying a variable's name never collides with it. A result writes one file under each name; a sweep one per slice, and reads them back -as one. Beside them is the :class:`Record`, which says how the solve +as one. Beside them is the [`Record`][], which says how the solve terminated: a result writes one row, a sweep one per slice. A saved result holds two things a sweep does not: ``activity/`` for @@ -97,7 +97,7 @@ def digest_of(yaml: str) -> str: same document, byte for byte. Not the same *model* — that is the document with its data, and two scenarios of one spec share this and share nothing else. What an archive holds beside it says whether the data agreed too: - :func:`digest_of_file` over each member of ``sources/``. + [`digest_of_file`][] over each member of ``sources/``. """ return digest_of_bytes(yaml.encode()) @@ -115,14 +115,14 @@ class Record(NamedTuple): termination_condition: str #: What the solve reached, or ``None`` where it reached nothing. Null #: rather than ``nan``: nan is a *number* to every aggregate that meets it. - #: :attr:`Result.objective` is a float and reads it back as ``nan``, having + #: [`Result.objective`][] is a float and reads it back as ``nan``, having #: no null to return. objective: float | None #: Whether the solve produced values, which the condition alone does not #: say: a run stopped at a limit before any incumbent is ``ok`` with #: nothing to read. has_primal: bool - #: :func:`digest_of` the spec this answered, or ``None`` where the solve + #: A digest of the spec this answered, or ``None`` where the solve #: was run off a lowered program and there was no document to digest. Null #: on disk, never an empty string. spec_digest: str | None @@ -136,9 +136,8 @@ class Record(NamedTuple): #: halves of its name. Stamped when the archive is written and null until #: then. run: str | None = None - #: :attr:`~specsolve.relational.sinks.handoff.Handoff.contents` of the model this - #: answered — the spec *and* its data, where :attr:`spec_digest` is the - #: document alone. ``None`` for an answer written before this column, and + #: A digest of the model this answered — the spec *and* its data, where + #: [`spec_digest`][] is the document alone. ``None`` for an answer written before this column, and #: for one whose result was never asked for it. model_digest: str | None = None @@ -164,7 +163,7 @@ def of( values to read — ``nan`` is a *number* to every aggregate. has_primal: Whether there are values, which the condition alone does not say. - spec_digest: :func:`digest_of` the spec answered, or ``None``. + spec_digest: A digest of the spec answered, or ``None``. solved_at: When the solver returned, in UTC. ``None`` where the solve carried no clock. model_digest: The built model's digest, or ``None`` where this @@ -220,7 +219,7 @@ def _column_types(record: type[NamedTuple]) -> dict[str, pl.DataType | type[pl.D return written -#: :class:`Record`'s columns as they are written, so a row whose ``objective`` +#: [`Record`][]'s columns as they are written, so a row whose ``objective`` #: or ``spec_digest`` is absent writes that column's own type holding null #: rather than the ``Null`` one polars would infer from a single row. Passed #: as ``schema_overrides``, so a sweep's key column beside them keeps the type @@ -231,17 +230,17 @@ def _column_types(record: type[NamedTuple]) -> dict[str, pl.DataType | type[pl.D class Metrics(NamedTuple): """What a build and its solves took, as the row an archive records beside the answer. - :class:`Record`'s sibling — one says how the solve terminated, this is the + [`Record`][]'s sibling — one says how the solve terminated, this is the measure of what it took — and the same columns whoever writes them, so rows written by runs that never met concatenate into one table. - The scalars of :class:`~specsolve.relational.result.Diagnostics` and none of + The scalars of [`Diagnostics`][specsolve.relational.result.Diagnostics] and none of its frames: a coefficient range is a table per declaration, which does not fold into a row beside a count. **Cumulative over the model's life**, as every counter it is read off is. - :attr:`solves` says how many solves the clocks cover; it reads ``1`` for - the archive :func:`specsolve.solve` writes, that verb building the model it + [`solves`][] says how many solves the clocks cover; it reads ``1`` for + the archive [`specsolve.solve`][] writes, that verb building the model it solves. """ @@ -260,9 +259,9 @@ class Metrics(NamedTuple): #: built model streamed to an LP or MPS file. A phase that never ran writes #: zero rather than no column. #: - #: So :attr:`write_seconds` reads zero on an archive whose caller never + #: So [`write_seconds`][] reads zero on an archive whose caller never #: asked for a file, which is most of them: it is - #: :meth:`~specsolve.api.Model.write`'s clock rather than the archive's own. **What writing the archive cost is + #: [`write`][specsolve.api.Model.write]'s clock rather than the archive's own. **What writing the archive cost is #: not here and is not anywhere**: a caller who wants that number times the #: call. attach_seconds: float @@ -270,18 +269,18 @@ class Metrics(NamedTuple): handoff_seconds: float solve_seconds: float write_seconds: float - #: What the archive holding this row was called, as :attr:`Record.run` is + #: What the archive holding this row was called, as [`Record.run`][] is #: stamped onto the record beside it: the archive's file name without a #: ``.zip``. Null until one is written. run: str | None = None -#: :class:`Metrics`'s columns as they are written, as :data:`RECORD_SCHEMA`. +#: [`Metrics`][]'s columns as they are written, as [`RECORD_SCHEMA`][]. METRICS_SCHEMA = _column_types(Metrics) class SliceMetrics(NamedTuple): - """What one slice of a sweep took — :class:`Metrics` one dimension in. + """What one slice of a sweep took — [`Metrics`][] one dimension in. Not the same columns, and the fold is what separates them. A slice's clocks are its own share rather than a cumulative total; ``loaded`` says whether @@ -294,7 +293,7 @@ class SliceMetrics(NamedTuple): every slice concatenates the way a directory of archives does. """ - #: The shape this slice built, as :class:`Metrics` reports a whole model's. + #: The shape this slice built, as [`Metrics`][] reports a whole model's. columns: int rows: int nonzeros: int @@ -320,7 +319,7 @@ def row_of[R](row_type: Callable[..., R], columns: Mapping[str, object], found: it next. Args: - row_type: :class:`Record`, :class:`Metrics` or :class:`SliceMetrics`. + row_type: [`Record`][], [`Metrics`][] or [`SliceMetrics`][]. columns: The row as read, ``name: value``. found: What to name in the message — the file or directory it came from. @@ -339,7 +338,7 @@ def row_of[R](row_type: Callable[..., R], columns: Mapping[str, object], found: #: The three files that sit beside the frames — a result and a sweep both -#: write them, and :func:`specsolve.archive.load_archive` reads back whichever +#: write them, and [`specsolve.archive.load_archive`][] reads back whichever #: wrote: the record of how the solve terminated, what reaching it cost, and #: the reasons behind whatever is deliberately not there. RECORD_FILE = 'record.parquet' @@ -405,7 +404,7 @@ def write_reasons(directory: Path, no_duals: str | None, no_expressions: Mapping def read_reasons(directory: Path) -> tuple[str | None, dict[str, str]]: - """What :func:`write_reasons` wrote: the duals' reason, and one per named expression.""" + """What [`write_reasons`][] wrote: the duals' reason, and one per named expression.""" file = directory / REASONS_FILE rows: list[tuple[str, str, str]] = pl.read_parquet(file).rows() if file.is_file() else [] return ( @@ -415,7 +414,7 @@ def read_reasons(directory: Path) -> tuple[str | None, dict[str, str]]: def reader_kind(kind: str) -> str: - """*kind* as one of :data:`KINDS`, which every reader that takes a name takes beside it. + """*kind* as one of [`KINDS`][], which every reader that takes a name takes beside it. Raises: SpecsolveError: A *kind* that names no reader. diff --git a/src/specsolve/relational/result.py b/src/specsolve/relational/result.py index 5103033d..ed53a077 100644 --- a/src/specsolve/relational/result.py +++ b/src/specsolve/relational/result.py @@ -1,8 +1,8 @@ -"""What a caller reads back — a solve's :class:`Result`, a build's :class:`Diagnostics`. +"""What a caller reads back — a solve's [`Result`][], a build's [`Diagnostics`][]. The objects ``sps.solve`` and ``model.diagnostics()`` hand back, so they are the pieces of this subpackage a reader meets without going looking. A -:class:`Result` holds one finished frame per declaration, its values already +[`Result`][] holds one finished frame per declaration, its values already laid out over the build's coordinates, so no reader ever goes back to the engine. @@ -47,8 +47,8 @@ #: How much of the session a solve keeps, as a request to -#: :meth:`specsolve.api.Model.solve` and as the report in -#: :attr:`Result.kept`. The two things a session holds — the solver with the +#: [`specsolve.api.Model.solve`][] and as the report in +#: [`Result.kept`][]. The two things a session holds — the solver with the #: model on it, and the work that solver did — can only be dropped in that #: order: there is no carrying on from a solver that was closed, so the fourth #: combination does not exist. @@ -143,7 +143,7 @@ def _bracket(labels: str) -> str: @dataclass(frozen=True) class ConstraintRow: - """One built constraint row, spelled back out — what :meth:`~specsolve.api.Model.row` returns. + """One built constraint row, spelled back out — what [`row`][specsolve.api.Model.row] returns. The row a model actually built at one coordinate: every term with its coefficient, and the comparison and right-hand side it was built against. @@ -151,13 +151,13 @@ class ConstraintRow: row, after ``where`` masking, after any term whose variable was absent dropped out, and after a coefficient the data made exactly zero stopped being a term at all - (:func:`~specsolve.relational.engines.polars.assembly._without_zeros`). Those + ([`_without_zeros`][specsolve.relational.engines.polars.assembly._without_zeros]). Those three are why a row can be shorter than the file suggests, and why reading one is worth it when a model says something other than what its author wrote. Printing it gives the row as one line of math, which is what reading a row - usually means; :attr:`terms` is the same content as a frame, for the row + usually means; [`terms`][] is the same content as a frame, for the row too wide to read and for anything that filters or joins. Attributes: @@ -185,7 +185,7 @@ class ConstraintRow: def __str__(self) -> str: """The row as one line: ``balance[snapshot=1]: +1 p[…] +50 p[…] >= 60``. - linopy's shape for the same job. A row wider than :attr:`display_terms` + linopy's shape for the same job. A row wider than [`display_terms`][] summarises rather than truncating. """ return f'{self.name}{_bracket(self._where())}: {self._body()} {self.sense} {_number(self.rhs)}' @@ -281,7 +281,7 @@ class Diagnostics: #: The axis a solver reports and does not repair: HiGHS prints a ``Bound`` #: range beside its ``Matrix`` one, equilibrates the matrix automatically, #: and answers the bounds with ``Consider scaling the bounds by …`` — so a - #: model can be clean on :attr:`coefficient_range` and still be the one the + #: model can be clean on [`coefficient_range`][] and still be the one the #: solver is complaining about. Zero and infinity are excluded, an #: unbounded side and a ``lower: 0`` being nothing the solver represents. A #: large ``largest`` is usually a big number standing in for "uncapped", and @@ -305,7 +305,7 @@ class Diagnostics: #: an iterating driver is the difference between "specsolve is slow" and #: "this model masks on a parameter that varies", unless the driver asked #: for ``keep='nothing'``, which loads by construction. ``loads`` ticks on - #: exactly the solves that report :attr:`Result.kept` of ``nothing`` — + #: exactly the solves that report [`Result.kept`][] of ``nothing`` — #: the same event, counted here and named there. solves: int loads: int @@ -326,7 +326,7 @@ def metrics(self) -> Metrics: What ``archive=`` records beside the answer, and what a caller feeding its own store reads off a model it solved. Which fields reach it and what it means cumulatively are - :class:`~specsolve.relational.parquet.Metrics`'s to say; a phase this + [`Metrics`][specsolve.relational.parquet.Metrics]'s to say; a phase this build never entered reads zero there. ``run`` is null: the name is the publisher's, and nothing has published this yet. """ @@ -379,10 +379,10 @@ def evaluated( class Result: """What a solve returned — the outcome, and access to any values. - Returned whatever the solve concluded: test :attr:`has_primal` before - reading values, or catch :class:`~specsolve.errors.NoSolutionError`. The + Returned whatever the solve concluded: test [`has_primal`][] before + reading values, or catch [`NoSolutionError`][specsolve.errors.NoSolutionError]. The values are this result's own, so a later solve on the same model does not - rewrite them, and there is no lifetime to manage — :meth:`close` releases + rewrite them, and there is no lifetime to manage — [`close`][] releases what this result holds early, and nothing breaks without it. An update is no exception. A result owns everything it reads — one finished @@ -396,40 +396,40 @@ class Result: _status: SolveStatus _objective: float #: One ``(dims…, value)`` frame per declaration, lazy and in label order — - #: a read is a collect. ``None`` is what :meth:`close` leaves behind, and + #: a read is a collect. ``None`` is what [`close`][] leaves behind, and #: the primal's absence is what "closed" means: both go together, and an #: empty mapping is a solve that left nothing, which the status reports. _primals: Mapping[str, pl.LazyFrame] | None _duals: Mapping[str, pl.LazyFrame] | None #: The constraints' left-hand sides at the solution, laid out exactly as - #: :attr:`_duals` — same frames, same row order — and present whenever the + #: [`_duals`][] — same frames, same row order — and present whenever the #: primals are: unlike a dual, an activity exists at any incumbent. _activities: Mapping[str, pl.LazyFrame] | None #: How much of the session this solve kept, read off what actually ran — #: never off what was asked for. _kept: Keep #: One deferred reader per declared named expression, and the ad-hoc - #: evaluator — what :meth:`evaluate` reads through and what :meth:`save` + #: evaluator — what [`evaluate`][] reads through and what [`save`][] #: writes. Nothing is compiled until a reader is called; the pair is #: composed above the lane so the evaluator may read the spec as written #: (hard rule 2). ``_evaluate`` is ``None`` where there is no such spec — #: a build off an already-lowered ``Program``, or an answer read back off - #: disk. Released with the primals by :meth:`close`, since each holds this + #: disk. Released with the primals by [`close`][], since each holds this #: build's frames and values. _expressions: Mapping[str, Callable[[], pl.DataFrame]] | None = None _evaluate: Callable[[str | Mapping[str, object]], pl.DataFrame] | None = None #: Why there are no duals, when a solve that left values still has none. - #: ``None`` whenever :attr:`_duals` holds them. + #: ``None`` whenever [`_duals`][] holds them. _no_duals: str | None = None #: One ``(dims…, value)`` frame per constraint, laid out exactly as - #: :attr:`_duals`, carrying the certificate an infeasible solve left. + #: [`_duals`][], carrying the certificate an infeasible solve left. #: Empty on every solve that was not infeasible, and released by - #: :meth:`close` with the rest. + #: [`close`][] with the rest. _dual_rays: Mapping[str, pl.LazyFrame] | None = None #: Why there is no certificate — the status, or the solver setting that - #: would have produced one. ``None`` whenever :attr:`_dual_rays` holds it. + #: would have produced one. ``None`` whenever [`_dual_rays`][] holds it. _no_dual_ray: str | None = None - #: Which spec this answered, as :func:`~specsolve.relational.parquet.digest_of` + #: Which spec this answered, as [`digest_of`][specsolve.relational.parquet.digest_of] #: names it. Attached by the model that solved, so a solve run off a #: lowered program — which has no document — leaves it ``None``. _spec_digest: str | None = None @@ -437,9 +437,9 @@ class Result: _solved_at: datetime | None = None #: The built model's digest — the spec *and* its data — as the value an #: answer read off disk carries, or as the callable a live solve is given - #: so that nothing is hashed unless :meth:`model_digest` is asked. ``None`` + #: so that nothing is hashed unless [`model_digest`][] is asked. ``None`` #: where neither: an answer written before the column, or one built by hand. - #: Read through :meth:`model_digest`, never here. + #: Read through [`model_digest`][], never here. _model_digest: str | Callable[[], str] | None = None #: The archive this answer was read back out of, as its record names it. #: ``None`` for a live solve: the name is stamped when an archive is @@ -449,7 +449,7 @@ class Result: def model_digest(self) -> str | None: """Which model this answered — the document and the data it was attached to. - :attr:`spec_digest` names the document alone, so two scenarios of one + [`spec_digest`][] names the document alone, so two scenarios of one spec share that and differ here. Computed on the first ask and kept, which is what keeps a solve that never asks free of it. """ @@ -476,7 +476,7 @@ def is_ok(self) -> bool: def has_primal(self) -> bool: """Whether there are values to read — what the accessors gate on. - Narrower than :attr:`is_ok`: a run stopped at a time limit before any + Narrower than [`is_ok`][]: a run stopped at a time limit before any incumbent is ``ok`` with nothing to read. """ return self._status.is_readable @@ -508,12 +508,12 @@ def solved_at(self) -> datetime | None: @property def record(self) -> Record: - """How this solve terminated, as the one row :meth:`save` writes for it. + """How this solve terminated, as the one row [`save`][] writes for it. The fields above in one value, and the same row a sweep keeps per slice - in :attr:`~specsolve.strategy.Sweep.record`. ``objective`` is ``None`` + in [`record`][specsolve.strategy.Sweep.record]. ``objective`` is ``None`` rather than ``nan`` where there are no values. Asking computes - :meth:`model_digest` once, as a save does. + [`model_digest`][] once, as a save does. """ return Record.of( self.termination_condition, @@ -526,13 +526,13 @@ def record(self) -> Record: @property def kept(self) -> Keep: - """How much of the session this solve kept — one of :data:`KEEPS`. + """How much of the session this solve kept: ``solver``, ``progress`` or ``nothing``. What *happened*, not what was asked: ``keep=`` is a preference, and a first solve or a structure that moved keeps ``nothing`` whatever it requested, the solver having been loaded again. So a driver that asked to keep ``progress`` and reads ``nothing`` back is being told its - labels moved. Advisory, like :class:`Diagnostics`: no answer depends + labels moved. Advisory, like [`Diagnostics`][]: no answer depends on it. """ return self._kept @@ -541,7 +541,7 @@ def _unclosed(self, what: str) -> Mapping[str, pl.LazyFrame]: """The primals, or why nothing here can be read: this result was closed. The closed check is read off the primals whichever mapping the caller - wants: :meth:`close` releases them together. + wants: [`close`][] releases them together. """ if self._primals is None: raise SpecsolveError( @@ -584,7 +584,7 @@ def primal(self, name: str) -> pl.DataFrame: def dual(self, name: str) -> pl.DataFrame: """Shadow prices of constraint *name* — ``(dims…, value)``. - :meth:`primal`'s shape and order, over constraint rows. + [`primal`][]'s shape and order, over constraint rows. Raises: NoSolutionError: The solve left no values at all. @@ -601,7 +601,7 @@ def dual_ray(self, name: str) -> pl.DataFrame: """Constraint *name*'s share of the certificate that this model has no solution — ``(dims…, value)``. The one thing an infeasible solve has to say, and the only reader that - answers on one: :meth:`primal`, :meth:`dual` and :meth:`activity` all + answers on one: [`primal`][], [`dual`][] and [`activity`][] all raise there, because there is no solution behind them. Weight every row by its value here and add them together, and the combined row demands more than the columns can deliver inside their bounds — which @@ -610,7 +610,7 @@ def dual_ray(self, name: str) -> pl.DataFrame: longer needs a second model to ask *how far from feasible* a subproblem was. - :meth:`dual`'s shape and order. **The sign is the row's own**, one + [`dual`][]'s shape and order. **The sign is the row's own**, one convention across every sink, so a driver never asks who solved — a sink whose solver signs the other way negates what it reads. Where every column is held only by a lower bound of zero, as a dispatch @@ -642,10 +642,10 @@ def dual_ray(self, name: str) -> pl.DataFrame: def activity(self, name: str) -> pl.DataFrame: """The left-hand side of constraint *name* at the solution — ``(dims…, value)``. - :meth:`dual`'s shape and order, and the other half of a row's story: + [`dual`][]'s shape and order, and the other half of a row's story: how far each row's ``Σ aᵢxᵢ`` sits from its bound. The solver's own number, not a recomputation. Readable whenever there is a solution — - unlike :meth:`dual` it is well-defined on a mixed-integer model. On an + unlike [`dual`][] it is well-defined on a mixed-integer model. On an ``==`` row it equals the right-hand side up to solver tolerance by construction. @@ -665,7 +665,7 @@ def evaluate(self, expression: str | Mapping[str, object]) -> pl.DataFrame: with ``dims:`` and ``otherwise:``. It may use every name the model declares and only those. The value is aggregated to the expression's own dims, in declaration order, rows in label order over them — - :meth:`primal`'s shape and order. + [`primal`][]'s shape and order. A declared name is served by its own reader, compiled on this call and never lowered again, so a spec whose expressions go unread compiles @@ -708,7 +708,7 @@ def _names(self, kind: str) -> tuple[str, ...]: return tuple(self._expressions or {}) def to_pandas(self, name: str, kind: str = 'primal') -> pd.DataFrame: - """One name's values as a tidy :class:`pandas.DataFrame`. + """One name's values as a tidy `pandas.DataFrame`. Args: name: A variable, a constraint or a named expression, as *kind* @@ -719,14 +719,14 @@ def to_pandas(self, name: str, kind: str = 'primal') -> pd.DataFrame: return tidy_to_pandas(self._frame(name, kind)) def to_dataarray(self, name: str, kind: str = 'primal') -> xr.DataArray: - """One name's values as a labelled :class:`xarray.DataArray`, :meth:`to_pandas`'s arguments. + """One name's values as a labelled `xarray.DataArray`, [`to_pandas`][]'s arguments. Dense over the name's dims: a masked coordinate comes back NaN. """ return tidy_to_dataarray(self.to_pandas(name, kind), name) def to_dataset(self, *names: str, kind: str = 'primal') -> xr.Dataset: - """The named values of one *kind* as one :class:`xarray.Dataset`; all of that kind by default. + """The named values of one *kind* as one `xarray.Dataset`; all of that kind by default. One kind per call: a dual and a variable of the same name would collide, and mean something else per row. Each arrives dense over its @@ -742,7 +742,7 @@ def save(self, directory: str | Path) -> Path: """Every kind this solve answered with, one file per name, into *directory*. ``record.parquet`` holds the - :class:`~specsolve.relational.parquet.Record` — how the solve terminated + [`Record`][specsolve.relational.parquet.Record] — how the solve terminated and what it reached, in the columns a sweep keys and folds. A solve that reached no objective writes null there rather than ``nan``, so a directory per case is a table an aggregate reads. Then @@ -750,9 +750,9 @@ def save(self, directory: str | Path) -> Path: for every constraint where the duals are defined, and ``expression/.parquet`` for every named expression this data can evaluate — an integer variable leaves the duals out, and an - expression that fails on this data is left out, :meth:`evaluate` + expression that fails on this data is left out, [`evaluate`][] still saying why. The primals are streamed to disk in - :meth:`primal`'s order, so the same model and data write the same + [`primal`][]'s order, so the same model and data write the same bytes. ``activity/.parquet`` goes beside them for every constraint, @@ -764,8 +764,8 @@ def save(self, directory: str | Path) -> Path: expression that failed, and one with an empty *name* for the duals, whose absence is never per-constraint. Written because a directory that simply lacks a file cannot tell "there is none, and here is why" - from "no such name", which is the one thing :meth:`dual` and - :meth:`evaluate` do say. + from "no such name", which is the one thing [`dual`][] and + [`evaluate`][] do say. A solve that left no values writes the record and nothing else. A run that came back infeasible is an answer a set of saved cases needs on @@ -815,7 +815,7 @@ def close(self) -> None: Its frames, which carry both its own values and its hold on the label frames of the build it answered. Frames already read stay valid. Never the model or the solver, which are the - :class:`~specsolve.api.Model`'s to close. + [`Model`][specsolve.api.Model]'s to close. """ self._primals = self._duals = self._activities = self._expressions = None self._dual_rays = None diff --git a/src/specsolve/relational/sinks/capabilities.py b/src/specsolve/relational/sinks/capabilities.py index 04a05cf9..7bf108df 100644 --- a/src/specsolve/relational/sinks/capabilities.py +++ b/src/specsolve/relational/sinks/capabilities.py @@ -74,7 +74,7 @@ def support(self, capability: Capability) -> Support: def missing(self, required: Collection[Capability]) -> list[Capability]: """Those of *required* this sink cannot take at all. - In :data:`CAPABILITIES` order rather than the caller's. + In [`CAPABILITIES`][] order rather than the caller's. """ return [c for c in CAPABILITIES if c in required and self.support(c) == 'absent'] @@ -83,7 +83,7 @@ def excluded(self, required: Collection[Capability]) -> frozenset[Capability] | Returns: The excluded set, or ``None``. Each member is one the sink supports - on its own; one it simply lacks is :meth:`missing`'s answer. + on its own; one it simply lacks is [`missing`][]'s answer. """ for combination in self.excludes: if combination <= set(required): diff --git a/src/specsolve/relational/sinks/handoff.py b/src/specsolve/relational/sinks/handoff.py index c124b2a3..fc178834 100644 --- a/src/specsolve/relational/sinks/handoff.py +++ b/src/specsolve/relational/sinks/handoff.py @@ -77,7 +77,7 @@ def height(self) -> int: } #: The dtype the ``rows`` frame holds a comparison in. Built from -#: :data:`SENSE_CODES` so a category's index *is* its code. +#: [`SENSE_CODES`][] so a category's index *is* its code. SENSE = pl.Enum(list(SENSE_CODES)) @@ -103,7 +103,7 @@ class Handoff: **contiguous tail** of the label space — quadratic is a property of a declaration, so the engine builds those last — which lets a sink holding linear and quadratic rows in different objects read its answer back as two - runs rather than a scatter, beginning at :attr:`linear_row_count`. + runs rather than a scatter, beginning at [`linear_row_count`][]. ``sos`` is the fifth stream and the one that lands unevenly: ``(set, type, col, weight)`` in ``(set, weight)`` order, one row per member, empty for @@ -119,12 +119,12 @@ class Handoff: indices and no sink builds a mapping. **``cols`` carries no ``col`` and ``matrix`` no ``row``**: a ``cols`` row's position is its index and a matrix entry's row is where it sits between two starts, which is what a - solver's matrix API takes. :meth:`matrix_block` spells them back out for the + solver's matrix API takes. [`matrix_block`][] spells them back out for the one consumer that renders them. ``obj`` keeps its ``col``, being genuinely sparse, and **carries no order contract at all**: its rows arrive in whatever order collapsing them produced, which differs between two builds of one model. Every consumer reads it scattered over the column index - (:meth:`dense_columns`), so nothing downstream can tell — and anything new + ([`dense_columns`][]), so nothing downstream can tell — and anything new that reads it must scatter too rather than read it in place. """ @@ -202,8 +202,8 @@ def dense_columns(self, infinity: float) -> ColumnVectors: def dense_rows(self, infinity: float) -> RowVectors: """The row vectors over the solver's row index, ready to hand over. - The row half of :meth:`dense_columns`, so a chunk of rows is a slice - rather than a search. It stops at the sense, a :data:`SENSE_CODES` + The row half of [`dense_columns`][], so a chunk of rows is a slice + rather than a search. It stops at the sense, a [`SENSE_CODES`][] byte, because that is where the solvers part — HiGHS wants ``lower``/``upper``, the others a comparison and right-hand side. A row with no entry gets a comparison nothing can fail (``>=`` against @@ -277,7 +277,7 @@ def structure(self) -> bytes: def contents(self) -> str: """A digest of the built model **whole** — the numbers included. - :attr:`structure`'s counterpart, and the one question a saved answer + [`structure`][]'s counterpart, and the one question a saved answer asks of a model rebuilt later: is this the model I answered? So it covers what ``structure`` leaves out on purpose — the bounds, the costs and the right-hand sides a re-solve may push — because a pushed number @@ -319,7 +319,7 @@ def sets(self) -> Iterator[tuple[int, pl.Series, pl.Series]]: In declared ``(set, weight)`` order; the type is read off the first member, every member of a set carrying the same one. Nothing here is pushed on an update: a set is structure, so a model whose members moved - is one :attr:`structure` has already sent back to be loaded again. + is one [`structure`][] has already sent back to be loaded again. """ for members in self.sos.partition_by('set', maintain_order=True): yield members.item(0, 'type'), members.get_column('col'), members.get_column('weight') @@ -329,7 +329,7 @@ def quadratic_blocks(self) -> Iterator[tuple[int, pl.DataFrame]]: One row at a time, unlike the linear matrix: every API that takes a quadratic constraint takes one per call. They are the contiguous tail - beginning at :attr:`linear_row_count`, so they arrive ascending and a + beginning at [`linear_row_count`][], so they arrive ascending and a sink's read-back stays two runs. """ for (row,), entries in self.qmatrix.group_by('row', maintain_order=True): @@ -341,7 +341,7 @@ def row_blocks(self, budget: int | None) -> Iterator[MatrixBlock]: A chunk is a ``slice``: ``row_starts`` already says where every row's entries sit, so nothing is sorted and nothing is searched. A consumer that needs the ``row`` labels spelled back out asks - :meth:`matrix_block` with the chunk's own range, so its spans and + [`matrix_block`][] with the chunk's own range, so its spans and entries cannot disagree. """ for lo, hi in self._spans(budget): @@ -360,9 +360,9 @@ def matrix_block(self, lo: int, hi: int) -> pl.DataFrame: def spelled_senses(spelling: Mapping[str, str]) -> np.ndarray[tuple[int, ...], np.dtype[np.str_]]: - """:data:`SENSE_CODES` as one solver's spellings, indexed by code. + """[`SENSE_CODES`][] as one solver's spellings, indexed by code. - A sense added to :data:`SENSE_CODES` and not to *spelling* raises instead. + A sense added to [`SENSE_CODES`][] and not to *spelling* raises instead. """ import numpy as np diff --git a/src/specsolve/relational/sinks/solvers/__init__.py b/src/specsolve/relational/sinks/solvers/__init__.py index 8dab4b2c..db8ddaad 100644 --- a/src/specsolve/relational/sinks/solvers/__init__.py +++ b/src/specsolve/relational/sinks/solvers/__init__.py @@ -1,12 +1,12 @@ """The solver family: one class per solver, holding one model. See ../README.md. One module per solver, **named for the solver**. Each defines a -:class:`~specsolve.relational.sinks.solvers.base.Solver` subclass named for it, +[`Solver`][specsolve.relational.sinks.solvers.base.Solver] subclass named for it, plus ``build_``, the load-only seam `bench/` measures. ``tests/test_architecture.py`` checks all of that off the path. What a solver holds between solves, and the rule for keeping it, is -:mod:`~specsolve.relational.sinks.solvers.base` — the one module a member may read +[`base`][specsolve.relational.sinks.solvers.base] — the one module a member may read besides ``handoff.py``. """ @@ -70,7 +70,7 @@ def loaded( *held* is kept exactly when it is the named class holding a model that differs from this one in nothing but numbers — same - :attr:`~specsolve.relational.sinks.handoff.Handoff.structure`, same options — and + [`structure`][specsolve.relational.sinks.handoff.Handoff.structure], same options — and then the new numbers are pushed onto it. A solver being replaced is closed here. *name* is resolved first. diff --git a/src/specsolve/relational/sinks/solvers/base.py b/src/specsolve/relational/sinks/solvers/base.py index 2ceecb9d..18bbf0f8 100644 --- a/src/specsolve/relational/sinks/solvers/base.py +++ b/src/specsolve/relational/sinks/solvers/base.py @@ -1,7 +1,7 @@ """What every solver is: a loaded model, and the rule for keeping it. A solver sink holds the model it was given and outlives the solve it was loaded -for, so that an updated model (:meth:`~specsolve.api.Model.update`) has its new +for, so that an updated model ([`update`][specsolve.api.Model.update]) has its new numbers *pushed* onto what the solver already has and re-solves from the basis the last one ended on. @@ -32,7 +32,7 @@ class WarmStart: """What one solve leaves for a later session: a basis, or an incumbent. - Read with :meth:`Solver.warm_start`, applied with :meth:`Solver.warm`. + Read with [`Solver.warm_start`][], applied with [`Solver.warm`][]. Which fields are filled is the reading solver's decision: an LP leaves its simplex basis (both status vectors, paired), a mixed-integer solve leaves no valid basis anywhere and carries its incumbent instead. @@ -48,7 +48,7 @@ class WarmStart: #: left no valid basis. column_statuses: Any | None #: Basis status per row in label order; filled exactly when - #: :attr:`column_statuses` is. + #: [`column_statuses`][] is. row_statuses: Any | None #: Primal value per column in label order — a mixed-integer incumbent — #: or ``None`` where the basis carries the start instead. @@ -82,7 +82,7 @@ class SolveAnswer: dual: pl.Series | None activity: pl.Series | None #: A weight per row certifying that the constraints cannot all hold, in - #: the sign convention of :meth:`Solver.dual_ray`, or ``None`` where the + #: the sign convention of [`Solver.dual_ray`][], or ``None`` where the #: solve was not infeasible or the solver produced none. dual_ray: pl.Series | None = None @@ -98,7 +98,7 @@ def unreadable(cls, status: SolveStatus, dual_ray: pl.Series | None = None) -> S class Solver(ABC): """One solver, holding one model. Subclassed once per member of ``SOLVERS``. - A driver never constructs one directly: :func:`~specsolve.relational.sinks.solvers.loaded` + A driver never constructs one directly: [`loaded`][specsolve.relational.sinks.solvers.loaded] is the whole of "reuse or load again", and what it hands back is run and, eventually, closed:: @@ -120,13 +120,13 @@ def __init__( #: The options the loaded model was told, set at the load. self._options = dict(solver_options or {}) self._load(handoff, batch_rows) - #: The build's own frames, until :meth:`structure` reads their digest + #: The build's own frames, until [`structure`][] reads their digest #: and lets them go. self._handoff: Handoff | None = handoff #: The digest of everything a re-solve may not change, or ``None`` - #: before :meth:`structure` is first asked. Read through it, never here. + #: before [`structure`][] is first asked. Read through it, never here. self._structure: bytes | None = None - #: The loaded model's spans, read by :meth:`_takes` alone. + #: The loaded model's spans, read by [`_takes`][] alone. self._columns = handoff.column_count self._rows = handoff.row_count @@ -136,10 +136,10 @@ def __init__( #: What this member can ingest, and what it refuses in combination. A #: member states it; the family acts on it - #: (:func:`~specsolve.relational.sinks.refusal`). + #: ([`refusal`][specsolve.relational.sinks.refusal]). capabilities: ClassVar[Capabilities] - #: What to tell a caller when :meth:`is_available` says no — which package + #: What to tell a caller when [`is_available`][] says no — which package #: is missing, and whether it ships or needs an extra. unavailable_message: ClassVar[str] @@ -160,7 +160,7 @@ def keeps(self, handoff: Handoff, solver_options: Mapping[str, Any] | None) -> b @classmethod def imported(cls) -> Any: - """Every package in :attr:`requires`, imported — or :attr:`unavailable_message`. + """Every package in [`requires`][], imported — or [`unavailable_message`][]. Returns the first, the member's own library; the rest are imported only to fail here. @@ -209,7 +209,7 @@ def warm_start(self) -> WarmStart | None: """ def warm(self, ws: WarmStart) -> None: - """Start the next :meth:`run` from *ws* instead of from scratch. + """Start the next [`run`][] from *ws* instead of from scratch. The caller vouches that *ws* was read from a model with this one's label set; what is checked here is what can be — the sink it came @@ -258,9 +258,9 @@ def _takes(self, ws: WarmStart) -> None: def _warm(self, ws: WarmStart) -> None: """Apply *ws* onto the loaded model, its spans already checked. - Reached only through :meth:`warm`, so a member may assume the vectors + Reached only through [`warm`][], so a member may assume the vectors span the model it holds and that filled fields pair the way - :class:`WarmStart` says they do. + [`WarmStart`][] says they do. """ def run(self, handoff: Handoff) -> SolveAnswer: @@ -300,8 +300,8 @@ def _run(self, handoff: Handoff) -> SolveAnswer: *handoff* is asked only for what has no column and so was never loaded — the objective's constant. When either vector may be ``None`` is - :class:`SolveAnswer`'s docstring. An infeasible solve calls - :meth:`dual_ray` and returns what it gives. + [`SolveAnswer`][]'s docstring. An infeasible solve calls + [`dual_ray`][] and returns what it gives. """ def dual_ray(self) -> pl.Series | None: @@ -328,7 +328,7 @@ def dual_ray(self) -> pl.Series | None: def forget(self) -> None: """Discard the work the last solve did, keeping the model loaded. - The middle rung of :data:`~specsolve.relational.result.KEEPS`: the matrix + The middle rung of [`KEEPS`][specsolve.relational.result.KEEPS]: the matrix stays handed over, and the next run begins as if it had never been solved. A member with nothing to discard implements this as a no-op. """ @@ -340,7 +340,7 @@ def handle(self) -> Any: The library's own model — what ``build_`` gives a caller who stops at the hand-off, and what a test reads the load back through. - Owned by this holder: the caller does not release it, :meth:`close` + Owned by this holder: the caller does not release it, [`close`][] does. """ @@ -348,7 +348,7 @@ def handle(self) -> Any: def close(self) -> None: """Release the loaded model, and anything outside this process with it. - Idempotent. Afterwards :attr:`handle` is ``None``. + Idempotent. Afterwards [`handle`][] is ``None``. **The same release happens to a holder dropped without closing.** A member whose library releases its object on collection has that for diff --git a/src/specsolve/relational/sinks/solvers/gurobi.py b/src/specsolve/relational/sinks/solvers/gurobi.py index 96644e54..03fd45e8 100644 --- a/src/specsolve/relational/sinks/solvers/gurobi.py +++ b/src/specsolve/relational/sinks/solvers/gurobi.py @@ -1,6 +1,6 @@ """The ``gurobi`` solver: the model in two calls, straight into gurobipy. -The same hand-off as :mod:`~specsolve.relational.sinks.solvers.highs`, reading the +The same hand-off as [`highs`][specsolve.relational.sinks.solvers.highs], reading the same ``dense_columns``, ``dense_rows`` and ``row_blocks``, so the two cannot disagree about the model they load. Two things differ: @@ -10,7 +10,7 @@ ``[gurobi]`` extra carries scipy. - **Nothing is batched.** The columns cannot be, since ``addMConstr`` writes into one ``MVar`` spanning the model. See - :meth:`~specsolve.relational.sinks.handoff.Handoff.row_blocks`. + [`row_blocks`][specsolve.relational.sinks.handoff.Handoff.row_blocks]. ``gurobipy`` and ``scipy`` are imported inside the functions, so importing this module stays free for a caller who never solves with it. @@ -36,7 +36,7 @@ #: Gurobi status -> termination condition. Copied from linopy's own -#: ``Gurobi.CONDITION_MAP`` bar three entries (:data:`_LINOPY_DIVERGENCES`); +#: ``Gurobi.CONDITION_MAP`` bar three entries ([`_LINOPY_DIVERGENCES`][]); #: ``tests/test_solve_status.py`` asserts both halves. _CONDITION_OF_GUROBI_STATUS = { 1: 'unknown', @@ -72,15 +72,15 @@ def build_gurobi( batch_rows: int | None = None, solver_options: Mapping[str, Any] | None = None, ) -> Gurobi: - """Load the model into a :class:`gurobipy.Model` and stop there. + """Load the model into a `gurobipy.Model` and stop there. - :func:`~specsolve.relational.sinks.solvers.highs.build_highs`'s seam. + [`build_highs`][specsolve.relational.sinks.solvers.highs.build_highs]'s seam. ``batch_rows`` is a *nonzero* budget that splits the matrix across calls; it defaults to one call — see - :meth:`~specsolve.relational.sinks.handoff.Handoff.row_blocks`. + [`row_blocks`][specsolve.relational.sinks.handoff.Handoff.row_blocks]. Returns: - The :class:`Gurobi` holding the model, at ``.handle``. ``close``, or + The [`Gurobi`][] holding the model, at ``.handle``. ``close``, or leaving a ``with``, releases both the model and its environment in the order Gurobi wants. """ @@ -88,14 +88,14 @@ def build_gurobi( class Gurobi(Solver): - """Gurobi, holding one model — :class:`Solver`'s member for the opt-in sink. + """Gurobi, holding one model — [`Solver`][]'s member for the opt-in sink. - :class:`~specsolve.relational.sinks.solvers.highs.Highs`'s twin, and the same + [`Highs`][specsolve.relational.sinks.solvers.highs.Highs]'s twin, and the same lifecycle. Four things are gurobipy's shape: - **A push writes through the read-back handles.** The ``MVar`` and the constraint blocks are what carry the attributes, so this keeps what - :func:`_built` returns rather than the model alone. + [`_built`][] returns rather than the model alone. - **The release is one finalizer, however it is reached.** ``close`` runs it, and a holder dropped without closing runs it when the collector gets there; both dispose the model before its environment, the order @@ -103,7 +103,7 @@ class Gurobi(Solver): than the solver. - **Nothing pushes ``Sense``.** A row's comparison comes from the YAML and no data can move it, so a model whose senses differ is one - :attr:`~specsolve.relational.sinks.handoff.Handoff.structure` has already + [`structure`][specsolve.relational.sinks.handoff.Handoff.structure] has already sent back to be loaded again. gurobipy would refuse the array anyway. - **``update`` before ``optimize``**, gurobipy's changes being queued. """ @@ -113,7 +113,7 @@ class Gurobi(Solver): _m: Any _x: Any _blocks: list[Any] - #: :func:`_released` over the model and its environment, bound to this + #: [`_released`][] over the model and its environment, bound to this #: holder's lifetime. _release: weakref.finalize[[Any, Any], Gurobi] #: The quadratic constraints, in row order and **after** every linear one: @@ -204,7 +204,7 @@ def warm_start(self) -> WarmStart | None: mixed-integer solve, and before any — so the refusal itself routes to the incumbent, and to ``None`` where ``SolCount`` says there is not one of those either. Row statuses concatenate across the constraint - blocks the way :func:`_duals` reads prices. + blocks the way [`_duals`][] reads prices. """ import numpy as np @@ -344,7 +344,7 @@ def _built( def _filled(m: Any, handoff: Handoff, batch_rows: int | None, gurobipy: Any) -> tuple[Any, list[Any], list[Any]]: - """Everything :func:`_built` loads after the environment exists.""" + """Everything [`_built`][] loads after the environment exists.""" import numpy as np import scipy.sparse @@ -381,13 +381,13 @@ def _add_quadratic_rows(m: Any, x: Any, handoff: Handoff, rows: RowVectors, spel Each row is assembled from **both** matrices: its quadratic entries as :math:`Q` in :math:`x^ op Q x` (no halving, the convention - :func:`_set_quadratic` already takes) and its linear entries from the + [`_set_quadratic`][] already takes) and its linear entries from the ordinary matrix, where they sit at the same row label. A quadratic row keeps its place in the linear matrix, so the two halves are read from one label. They are the **tail** of the label space, so the handles returned here - concatenate onto the linear blocks (:func:`_duals`). + concatenate onto the linear blocks ([`_duals`][]). """ import numpy as np import scipy.sparse @@ -410,7 +410,7 @@ def _set_quadratic(m: Any, x: Any, handoff: Handoff, cost: Any) -> None: ``setMObjective`` takes :math:`Q` in :math:`x^\top Q x` — **no halving** — so the unordered-pair form the engine hands over - (:attr:`~specsolve.relational.sinks.handoff.Handoff.quad`) goes in as it + ([`quad`][specsolve.relational.sinks.handoff.Handoff.quad]) goes in as it stands, one entry per pair in the upper triangle. It sets the *whole* objective, so the cost vector already on the columns is @@ -457,7 +457,7 @@ def _spelled(gurobipy: Any) -> Any: def _gurobipy() -> Any: - """The optional dependency — scipy guarded with it — or :attr:`Gurobi.unavailable_message`.""" + """The optional dependency — scipy guarded with it — or [`Gurobi.unavailable_message`][].""" return Gurobi.imported() @@ -495,7 +495,7 @@ def _activity(blocks: list[Any], qrows: list[Any]) -> pl.Series: recovers the solver's number. ``Slack`` exists whenever a solution does, mixed-integer included, and a readable status guarantees one by the time this is asked. Blocks were added in ascending row ranges, the same fact - :func:`_duals` leans on. + [`_duals`][] leans on. **A quadratic row's activity is not** :math:`Ax`: ``QCSlack`` is measured against the whole left-hand side, :math:`x^\top Q x + a^\top x`, so the @@ -516,7 +516,7 @@ def _duals(blocks: list[Any], qrows: list[Any]) -> pl.Series | None: Blocks were added in ascending row ranges and the quadratic rows after them, so concatenating their slices reproduces the row index without a - sort — and :meth:`Solver.run` checks the vector spans the model. Gurobi + sort — and [`Solver.run`][] checks the vector spans the model. Gurobi refuses ``Pi`` on a mixed-integer model, and that refusal *is* the answer — no zero vector to test. diff --git a/src/specsolve/relational/sinks/solvers/highs.py b/src/specsolve/relational/sinks/solvers/highs.py index 88c3edc1..ea3a3a17 100644 --- a/src/specsolve/relational/sinks/solvers/highs.py +++ b/src/specsolve/relational/sinks/solvers/highs.py @@ -4,13 +4,13 @@ vector crosses as a numpy buffer, with no float→text→parse round trip. **Nothing textual crosses into numpy**: a row's ``'<='`` becomes a -:data:`~specsolve.relational.sinks.handoff.SENSE_CODES` byte before it is read +[`SENSE_CODES`][specsolve.relational.sinks.handoff.SENSE_CODES] byte before it is read here. ``highspy`` is imported inside the function, being optional: importing this module stays free for callers that only write LP files. -:class:`Highs` is the same hand-off held open — what a driver that re-solves +[`Highs`][] is the same hand-off held open — what a driver that re-solves one model with new numbers uses, and where the warm basis lives. """ @@ -62,19 +62,19 @@ def build_highs( handoff: Handoff, solver_options: Mapping[str, Any] | None = None, ) -> Highs: - """Load the model into a :class:`highspy.Highs` and stop there. + """Load the model into a `highspy.Highs` and stop there. The hand-off without the simplex. `bench/` ends here, as linopy's ``Model.to_highspy()`` does on that side. Returns: - The :class:`Highs` holding the model, at ``.handle``. + The [`Highs`][] holding the model, at ``.handle``. """ return Highs(handoff, None, solver_options) def _built(handoff: Handoff, solver_options: Mapping[str, Any] | None) -> Any: - """The populated :class:`highspy.Highs`. + """The populated `highspy.Highs`. One ``passModel`` loads the whole model at once — the scalars, the five dense vectors, and the matrix as row-wise CSR. Every array crosses as a @@ -159,7 +159,7 @@ def _pass_hessian(h: Any, handoff: Handoff) -> None: — so the stored value is :math:`q` itself. The whole part goes over at once — there is no incremental Hessian API — - but onto the model already loaded, which is what lets :meth:`Highs.push` + but onto the model already loaded, which is what lets [`Highs.push`][] replace it without a reload. """ import highspy @@ -190,13 +190,13 @@ def _pass_hessian(h: Any, handoff: Handoff) -> None: class Highs(Solver): - """HiGHS, holding one model — :class:`Solver`'s member for the default sink. + """HiGHS, holding one model — [`Solver`][]'s member for the default sink. The second solve of an updated model changes bounds, costs and right-hand sides on the model HiGHS already holds and starts from the basis the last solve ended on, unless the caller carries the basis across with - :meth:`warm_start` and - :meth:`~specsolve.relational.sinks.solvers.base.Solver.warm`. + [`warm_start`][] and + [`warm`][specsolve.relational.sinks.solvers.base.Solver.warm]. """ #: The loaded model. ``close`` drops it. @@ -269,7 +269,7 @@ def _warm(self, ws: WarmStart) -> None: """``setBasis`` for a basis, ``setSolution`` for an incumbent. Both report a refusal by return value, like every hand-off here, so - both go through :func:`_took`. + both go through [`_took`][]. """ import highspy diff --git a/src/specsolve/relational/sinks/solvers/xpress.py b/src/specsolve/relational/sinks/solvers/xpress.py index e1b94cd7..ae457c3b 100644 --- a/src/specsolve/relational/sinks/solvers/xpress.py +++ b/src/specsolve/relational/sinks/solvers/xpress.py @@ -1,6 +1,6 @@ """The ``xpress`` solver: the model in two calls, straight into the Optimizer. -The same hand-off as :mod:`~specsolve.relational.sinks.solvers.highs`, reading the +The same hand-off as [`highs`][specsolve.relational.sinks.solvers.highs], reading the same ``dense_columns``, ``dense_rows`` and ``row_blocks``, so no two sinks can disagree about the model they load. What differs: @@ -12,7 +12,7 @@ attribute for it. - **Forgetting is a control, not a call.** ``problem.reset()`` clears the whole problem here, so what discards the last solve's work is ``keepbasis``; see - :meth:`Xpress.forget`. + [`Xpress.forget`][]. ``xpress`` is imported inside the functions, so importing this module stays free for a caller who never solves with it. @@ -49,7 +49,7 @@ #: Which solution statuses carry values worth reading. ``FEASIBLE`` is an #: incumbent found before the run stopped, so it does; ``NOTFOUND`` is the -#: case :attr:`~specsolve.relational.status.SolveStatus.is_readable` exists for. +#: case [`is_readable`][specsolve.relational.status.SolveStatus.is_readable] exists for. _HAS_PRIMAL = frozenset({1, 2}) #: ``SolveStatus.FAILED`` and ``SolveStatus.UNSTARTED``, by value. The second @@ -65,22 +65,22 @@ def build_xpress( batch_rows: int | None = None, solver_options: Mapping[str, Any] | None = None, ) -> Xpress: - """Load the model into an :class:`xpress.problem` and stop there. + """Load the model into an `xpress.problem` and stop there. - :func:`~specsolve.relational.sinks.solvers.highs.build_highs`'s seam. + [`build_highs`][specsolve.relational.sinks.solvers.highs.build_highs]'s seam. Returns: - The :class:`Xpress` holding the problem, at ``.handle``. The problem + The [`Xpress`][] holding the problem, at ``.handle``. The problem owns its licence and releases it when it is collected. """ return Xpress(handoff, batch_rows, solver_options) class Xpress(Solver): - """FICO Xpress, holding one model — :class:`Solver`'s member for the second opt-in sink. + """FICO Xpress, holding one model — [`Solver`][]'s member for the second opt-in sink. - :class:`~specsolve.relational.sinks.solvers.highs.Highs`'s twin in how the - model is handed over and :class:`~specsolve.relational.sinks.solvers.gurobi.Gurobi`'s + [`Highs`][specsolve.relational.sinks.solvers.highs.Highs]'s twin in how the + model is handed over and [`Gurobi`][specsolve.relational.sinks.solvers.gurobi.Gurobi]'s in what it costs to hold. Three things are the Optimizer's shape: - **A push writes by index**, whole vectors through ``chgBounds`` / @@ -88,7 +88,7 @@ class Xpress(Solver): the read-back. - **Nothing pushes a row's comparison.** A sense comes from the YAML and no data can move it, so a model whose senses differ is one - :attr:`~specsolve.relational.sinks.handoff.Handoff.structure` has already + [`structure`][specsolve.relational.sinks.handoff.Handoff.structure] has already sent back to be loaded again. - **Duals are refused rather than zero-filled** on a model that has none, as on Gurobi, so the refusal is the answer. @@ -142,7 +142,7 @@ def warm_start(self) -> WarmStart | None: and is what is loaded a MIP. Xpress hands the basis back as ``(rows, columns)``, the opposite order - to :class:`WarmStart`'s fields. + to [`WarmStart`][]'s fields. """ import numpy as np @@ -164,7 +164,7 @@ def warm_start(self) -> WarmStart | None: def _warm(self, ws: WarmStart) -> None: """``loadBasis`` for a basis, ``addMipSol`` for an incumbent. - ``keepbasis`` goes back on with the basis; :meth:`forget` is what turns + ``keepbasis`` goes back on with the basis; [`forget`][] is what turns it off. """ if (basis := ws.basis()) is not None: @@ -213,7 +213,7 @@ def forget(self) -> None: ``problem.reset()`` on Xpress clears the whole problem, the model with it. The control is durable, so it tracks the caller's ``keep=`` across - re-solves; :meth:`_warm` turns it back on. + re-solves; [`_warm`][] turns it back on. """ self._p.controls.keepbasis = 0 @@ -236,7 +236,7 @@ def _built( Columns arrive with no entries — ``start`` is all zeros — because the matrix goes in row-wise afterwards, which is the form - :meth:`~specsolve.relational.sinks.handoff.Handoff.row_blocks` already + [`row_blocks`][specsolve.relational.sinks.handoff.Handoff.row_blocks] already hands over. ``chgColType`` is called only when some column is integral. @@ -299,7 +299,7 @@ def _add_sets(p: Any, handoff: Handoff, xpress: Any) -> None: def _xpress() -> Any: - """The optional dependency, or :attr:`Xpress.unavailable_message`.""" + """The optional dependency, or [`Xpress.unavailable_message`][].""" return Xpress.imported() diff --git a/src/specsolve/relational/sinks/writers/lp_file.py b/src/specsolve/relational/sinks/writers/lp_file.py index cf05abcf..37797679 100644 --- a/src/specsolve/relational/sinks/writers/lp_file.py +++ b/src/specsolve/relational/sinks/writers/lp_file.py @@ -28,7 +28,7 @@ #: A section is text, so this format excludes no combination and curvature #: costs it nothing — and every construct the language can reach is a section #: this writer emits, quadratic rows included. What a descriptor declares is -#: what :func:`write_lp_file` **emits**. +#: what [`write_lp_file`][] **emits**. #: #: What no descriptor promises is that the solver reading the file back parses #: what was written — that is a property of a *reader*, and HiGHS's refuses two @@ -44,7 +44,7 @@ ) -#: How the LP format spells each comparison, read off :data:`SENSE_CODES` so a +#: How the LP format spells each comparison, read off [`SENSE_CODES`][] so a #: sense added there reaches the file or raises here. The format differs on #: one word: it writes an equality as ``=``. _LP_SENSE = {sense: '=' if sense == '==' else sense for sense in SENSE_CODES} @@ -149,7 +149,7 @@ def _quadratic_terms(handoff: Handoff) -> pl.LazyFrame: and the off-diagonal does not. A pair arrives ordered, summed and deduplicated - (:meth:`~specsolve.relational.engines.polars.assembly.Assembly._objective_quadratic`), + ([`_objective_quadratic`][specsolve.relational.engines.polars.assembly.Assembly._objective_quadratic]), so nothing here sorts. """ return handoff.quad.lazy().select(_pair(pl.col('coeff') * 2)) @@ -211,7 +211,7 @@ def _constraint_lines(handoff: Handoff, lo: int, hi: int, entries: pl.DataFrame) One row per *output line*, interleaved by sorting, so nothing gathers a row's terms into a string list first. *entries* is the chunk's slice of the - matrix from :meth:`Handoff.matrix_block`, and the anti-join gives a termless + matrix from [`Handoff.matrix_block`][], and the anti-join gives a termless row the line a solver still needs to parse. **The order is one integer, and the only other column.** A row's lines diff --git a/src/specsolve/relational/sinks/writers/mps_file.py b/src/specsolve/relational/sinks/writers/mps_file.py index e054d9a1..56f99f22 100644 --- a/src/specsolve/relational/sinks/writers/mps_file.py +++ b/src/specsolve/relational/sinks/writers/mps_file.py @@ -1,7 +1,7 @@ """The ``mps_file`` sink: the model as MPS text. The format the other half of the world reads, and it differs from -:mod:`~specsolve.relational.sinks.writers.lp_file` in one way that shapes the +[`lp_file`][specsolve.relational.sinks.writers.lp_file] in one way that shapes the whole module: **MPS is column-major.** It hands a reader each column with its whole column of the matrix, where LP walks the matrix by row. So this is the one writer that sorts — CSR is row-major, and no engine frame holds a column diff --git a/src/specsolve/relational/status.py b/src/specsolve/relational/status.py index f5f5a130..d4adf0ce 100644 --- a/src/specsolve/relational/status.py +++ b/src/specsolve/relational/status.py @@ -40,7 +40,7 @@ class SolveStatus: #: Exactly what the solver called it, for a message a user can search for. solver_wording: str = '' #: Whether the solver reports an actual primal, which the termination - #: condition does not tell you — see :attr:`is_readable`. + #: condition does not tell you — see [`is_readable`][]. has_primal: bool = True @property @@ -52,7 +52,7 @@ def is_ok(self) -> bool: """The linopy rollup: the run is not an error, an abort or a refusal. Kept exactly as linopy defines it. It is *not* the question "can I read - values" — see :attr:`is_readable`. + values" — see [`is_readable`][]. """ return self.status == 'ok' diff --git a/src/specsolve/sources.py b/src/specsolve/sources.py index 6b548940..e82e4dc2 100644 --- a/src/specsolve/sources.py +++ b/src/specsolve/sources.py @@ -9,7 +9,7 @@ present and of the declared type — is asked here, once. The guard that needs the numbers rather than the shapes is -:mod:`specsolve.assumptions`, which :func:`tidy_sources` calls on the way through. +[`specsolve.assumptions`][], which [`tidy_sources`][] calls on the way through. """ from __future__ import annotations @@ -42,15 +42,15 @@ def attachable(program: Program) -> dict[str, ParameterDeclaration | DimensionDe def tidy_sources(program: Program, data: Mapping[str, Source]) -> dict[str, pl.LazyFrame]: """Read the caller's ``sources`` into the frames both lanes build against. - Every source comes back as an in-memory :class:`polars.LazyFrame`: a + Every source comes back as an in-memory `polars.LazyFrame`: a parameter as tidy ``(dims…, value)``, a dimension's index as the table it arrived as with the labels under the dimension's own name, a relation as the table it declares, one column per column under the column's own name and one row per row it holds. Dimensions are read first, because the - plain-Python parameter shapes :func:`_spread` accepts are spread over + plain-Python parameter shapes [`_spread`][] accepts are spread over their labels. What the model assumes of all of it is checked last, once every frame is there to check it against - (:func:`~specsolve.assumptions.validate_assumptions`). + ([`validate_assumptions`][specsolve.assumptions.validate_assumptions]). Args: program: The lowered spec. @@ -354,7 +354,7 @@ def _parameter_frame( """The caller's object for one parameter as a lazy frame, whatever shape it took. Raises: - DataError: A shape neither a table reader nor :func:`_spread` accepts. + DataError: A shape neither a table reader nor [`_spread`][] accepts. """ if is_dense_array(obj): raise DataError( @@ -377,14 +377,14 @@ def _parameter_frame( def least_value(name: str, p: ParameterDeclaration, obj: Source) -> float | None: """The least value one parameter's source holds, read without any dimension's labels. - Every shape :func:`tidy_sources` accepts has a least value that does not + Every shape [`tidy_sources`][] accepts has a least value that does not depend on where its numbers land, so a caller may ask how small a parameter goes before the indices it is over have been read — which is what lets a sweep resolve how far the model reaches along an axis it is - about to cut. Only the two shapes :func:`_spread` places *by position* are + about to cut. Only the two shapes [`_spread`][] places *by position* are read here — a number and a sequence, which it cannot spread without an index; a ``{label: value}`` map carries its own placement and goes through - :func:`_parameter_frame` with the rest. + [`_parameter_frame`][] with the rest. Returns: The least value, or ``None`` where the source holds no rows. diff --git a/src/specsolve/strategy.py b/src/specsolve/strategy.py index c1117347..19c5f789 100644 --- a/src/specsolve/strategy.py +++ b/src/specsolve/strategy.py @@ -1,14 +1,14 @@ """Solving strategies: one plan per slice, folded. A plan cannot contain a loop; a *process* may loop over plans -(mathspec's docs/about/limits.md). So a strategy is a driver above :mod:`specsolve.api`, +(mathspec's docs/about/limits.md). So a strategy is a driver above [`specsolve.api`][], built from the public verbs — never a language or engine feature. Every strategy is the same fold: **partition → attach → solve → carry → stitch**. Only how the sources are sliced and whether the slices couple differs. A serial fold builds -once and updates each slice (:func:`_serially`); under a process pool it builds -per slice (:func:`_pooled`), a built model being the one thing that cannot -cross. Both yield an :class:`_Answer`, and the fold that absorbs them is +once and updates each slice ([`_serially`][]); under a process pool it builds +per slice ([`_pooled`][]), a built model being the one thing that cannot +cross. Both yield an [`_Answer`][], and the fold that absorbs them is written once. scenario / sweep ``EachCoordinate('scenario')`` independent @@ -116,7 +116,7 @@ class _Slice(NamedTuple): def _slice_metrics(after: Diagnostics, before: Diagnostics | None) -> SliceMetrics: - """One slice's row of :attr:`Sweep.metrics`, off the model's cumulative counters. + """One slice's row of [`Sweep.metrics`][], off the model's cumulative counters. A serial fold reuses one model, whose clocks and ``loads`` keep summing across slices: *before* is what was measured as the previous slice @@ -154,7 +154,7 @@ def resolved(cls, program: Program, parameter: str, variable: str) -> _CarryRule carry collapses; everything else passes through, so a myopic pathway hands a whole capacity vector forward rather than one number at a time. Nothing here reads data. Whether the dropped dimension is one the axis - can answer for is :func:`_check_the_carry`'s. + can answer for is [`_check_the_carry`][]'s. """ if parameter not in program.parameters: raise SpecsolveError(f'carry writes parameter {parameter!r}, which the spec does not declare') @@ -212,14 +212,14 @@ def value_from( class _Answer: """One slice, solved and read out — what the fold absorbs. - What :func:`_serially` and :func:`_pooled` both produce. Plain data + What [`_serially`][] and [`_pooled`][] both produce. Plain data throughout — frames, strings and numbers, never a result or a model — so it can cross a process. """ meta: Record - #: This slice's row of :attr:`Sweep.metrics`, from - #: :func:`_slice_metrics`. + #: This slice's row of [`Sweep.metrics`][], from + #: [`_slice_metrics`][]. metrics: SliceMetrics primals: dict[str, pl.DataFrame] duals: dict[str, pl.DataFrame] @@ -239,7 +239,7 @@ class _OriginalIndex: *responsible* for — the coordinates its block names, the rest being lookahead the next window recomputes. One-way: the lookahead rows are not in it, so a sliced frame cannot be rebuilt from it — slicing stays - :meth:`EachWindow._slice`'s business. + [`EachWindow._slice`][]'s business. """ local: str @@ -320,7 +320,7 @@ class _Spill: slice done, so one interrupted part way is solved again rather than read back short. ``sweep.json`` names the key and the keys, so a directory answers for one sweep and another pointed at it is refused; it also - carries what :func:`load_sweep` cannot infer from the frames — whether the + carries what [`load_sweep`][] cannot infer from the frames — whether the axis was hand-built, and the dimension a window sliced, whose owned coordinates go beside it in ``owned.parquet``. """ @@ -439,7 +439,7 @@ def _listed(entries: Mapping[str, str]) -> str: def _least(program: Program, sources: Mapping[str, Source], name: str) -> int: """The least value of parameter *name*, which decides how far its rows read ahead; an empty one reads nowhere. - Read through :func:`~specsolve.sources.least_value`, which handles every shape + Read through [`least_value`][specsolve.sources.least_value], which handles every shape a source may arrive in — a parquet path, a table, a scalar, a ``{label: value}`` map, a sequence. @@ -501,7 +501,7 @@ def _check_the_program(self, program: Program, sources: Mapping[str, Source]) -> def _slice(self, sources: Mapping[str, Source], key_name: str) -> tuple[list[_Slice], _OriginalIndex | None]: """One slice per coordinate, keyed by it. Sources without *dim* pass through. - No :class:`_OriginalIndex`: nothing was re-indexed, so a slice's frames + No [`_OriginalIndex`][]: nothing was re-indexed, so a slice's frames already carry the coordinates they were solved over. """ del key_name @@ -529,7 +529,7 @@ class EachWindow: Whether the model *can* be sliced this way is asked before it is — the coupling, the reach and the lookahead they need are - :meth:`_check_the_program`. + `_check_the_program`. """ dim: str @@ -576,11 +576,11 @@ def _check_the_program(self, program: Program, sources: Mapping[str, Source]) -> """Refuse a window the program's rows cannot be whole inside, before one is taken. The program answers through - :attr:`~mathspec.program.Program.separability` and nothing here walks + `separability` and nothing here walks it: a window needs ``into`` *windowable*, and its lookahead to cover what the rows read ahead. Where a reach is an offset the data decides, the parameter's least value is read off the data and - :meth:`~mathspec.program.Separability.resolved` folds it in. + `resolved` folds it in. What the rows read *behind* is not refused: it is what a window's first rows meet the edge policy with, the rolling-horizon seed the @@ -637,8 +637,8 @@ def _slice(self, sources: Mapping[str, Source], key_name: str) -> tuple[list[_Sl Sources without *dim* pass through untouched. **A window owns the coordinates its block names**, and the - :class:`_OriginalIndex` records which — the rest is lookahead the next - window recomputes. :meth:`_blocks` trims the last block to what is left, + [`_OriginalIndex`][] records which — the rest is lookahead the next + window recomputes. [`_blocks`][] trims the last block to what is left, so the tail window owns all of itself and nothing falls off the end. """ carrying, coordinates = _coordinates(sources, self.dim, 'window') @@ -708,7 +708,7 @@ def _blocks(self, total: int) -> list[int]: class Sweep: """What a fold returned: frames keyed by slice, never a scalar. - :class:`~specsolve.relational.result.Result`'s readers one dimension wider — + [`Result`][specsolve.relational.result.Result]'s readers one dimension wider — same names, same shapes, the slice key prepended. Nothing is combined across slices: each row says which slice computed it. A windowed sweep reads over that key unless a reader asks ``original_index=True``, which @@ -724,8 +724,8 @@ class Sweep: #: holds null there rather than ``nan``, so the column aggregates over the #: slices that solved. record: pl.DataFrame - #: One :class:`~specsolve.relational.parquet.SliceMetrics` per slice, keyed - #: and in slice order — :meth:`~specsolve.api.Model.diagnostics` one dimension + #: One [`SliceMetrics`][specsolve.relational.parquet.SliceMetrics] per slice, keyed + #: and in slice order — [`diagnostics`][specsolve.api.Model.diagnostics] one dimension #: wider, its counts and clocks only. ``loaded`` says the solver took the #: model from scratch: under a serial fold the first slice does and the #: rest are pushed values, so a later ``True`` is a slice whose data moved @@ -743,11 +743,11 @@ class Sweep: #: Whether the axis was a hand-built list, which names no sliced dimension, #: so ``original_index`` is refused rather than answered with the keyed #: frame. Not the same fact as ``_original is None``, which - #: :class:`EachCoordinate` is too and where the keyed frame *is* the answer. + #: [`EachCoordinate`][] is too and where the keyed frame *is* the answer. _hand_built: bool = field(repr=False, default=False) #: Where the frames are instead, for a sweep solved with ``spill_to=``. _spill: _Spill | None = field(repr=False, default=None) - #: What :meth:`evaluate` lowers an undeclared expression through, wired by + #: What [`evaluate`][] lowers an undeclared expression through, wired by #: a sweep archive over the spec, sources, axis and carry it carries. #: ``None`` on a Sweep a live solve returned, which retains no model to #: lower an expression against. @@ -832,7 +832,7 @@ def _read( Raises: SpecsolveError: The sweep was spilled, so nothing is held: the - message names :meth:`scan`. + message names [`scan`][]. """ self._held_here() if name not in held: @@ -848,11 +848,11 @@ def _held_here(self) -> None: ) def scan(self, name: str, kind: str = 'primal', *, original_index: bool = False) -> pl.LazyFrame: - """One name's values across every slice as a :class:`polars.LazyFrame`, the slice key prepended. + """One name's values across every slice as a `polars.LazyFrame`, the slice key prepended. The reader for a sweep solved with ``spill_to=``, whose frames are on disk; - on one held in memory it is :meth:`primal`, :meth:`dual` or - :meth:`evaluate` made lazy, so the same line reads either. + on one held in memory it is [`primal`][], [`dual`][] or + [`evaluate`][] made lazy, so the same line reads either. Args: name: A variable, a constraint or a named expression the spec @@ -879,7 +879,7 @@ def primal(self, name: str, *, original_index: bool = False) -> pl.DataFrame: """One variable's values across every slice, the slice key prepended. A slice that reached no solution contributes no rows, so this can be - shorter than the sweep; :attr:`record` is one row per slice always. + shorter than the sweep; [`record`][] is one row per slice always. Args: name: A variable the sweep's spec declares. @@ -896,7 +896,7 @@ def primal(self, name: str, *, original_index: bool = False) -> pl.DataFrame: def dual(self, name: str, *, original_index: bool = False) -> pl.DataFrame: """One constraint's shadow prices across every slice, the key prepended. - :meth:`primal`'s shape and arguments. A slice whose model had an + [`primal`][]'s shape and arguments. A slice whose model had an integer variable contributes no duals; over the original index each coordinate carries the price of the window that owns it, never a blend of several. @@ -912,8 +912,8 @@ def dual(self, name: str, *, original_index: bool = False) -> pl.DataFrame: def evaluate(self, expression: str | Mapping[str, object], *, original_index: bool = False) -> pl.DataFrame: """The value of *expression* at every slice's solution, the slice key prepended. - :meth:`~specsolve.relational.result.Result.evaluate` one dimension wider, - and :meth:`primal`'s shape and arguments. *expression* is what one + [`evaluate`][specsolve.relational.result.Result.evaluate] one dimension wider, + and [`primal`][]'s shape and arguments. *expression* is what one ``expressions:`` entry takes: a name the file declares, an expression string, or the mapping carrying ``cases:`` with ``dims:`` and ``otherwise:``. @@ -924,7 +924,7 @@ def evaluate(self, expression: str | Mapping[str, object], *, original_index: bo solution with no re-solve: the slice's model is rebuilt from the archive's spec and that slice's cut of the sources, and its saved primal put back against it — so it is available on the sweep - :func:`~specsolve.archive.load_archive` hands back, which carries the + [`load_archive`][specsolve.archive.load_archive] hands back, which carries the spec, sources and axis, and a Sweep a live solve returned says it retains no model. It reads only what an archive can put back: an expression over a parameter the sweep **carried** is refused, that value being a @@ -944,7 +944,7 @@ def evaluate(self, expression: str | Mapping[str, object], *, original_index: bo Raises: SpecsolveError: No slice produced a declared *expression* — an evaluation that failed on every slice carries its own reason — - a spilled sweep, which :meth:`scan` reads instead; an undeclared + a spilled sweep, which [`scan`][] reads instead; an undeclared expression on a Sweep with no model behind it, or one that reads a parameter the sweep carried; or ``original_index`` on a hand-built axis or a quantity reduced over the sliced dimension. @@ -980,8 +980,8 @@ def _nothing_to_evaluate(self, expression: str | Mapping[str, object]) -> str: def _reindexed(self, frame: _Frame, *, original_index: bool) -> _Frame: """*frame* over the dimension the axis sliced, rather than over its slices. - Three answers, and the axis decides which. :class:`EachWindow` carries - the way back. :class:`EachCoordinate` re-indexed nothing and its key + Three answers, and the axis decides which. [`EachWindow`][] carries + the way back. [`EachCoordinate`][] re-indexed nothing and its key column already *is* a coordinate of the answer, so the frame comes back unchanged — a satisfied request rather than an ignored one. A hand-built list says neither, and there the keyed frame answers a different @@ -1005,12 +1005,12 @@ def _reindexed(self, frame: _Frame, *, original_index: bool) -> _Frame: return self._original.restore(frame, self.key_name) def _frame(self, name: str, kind: str, *, original_index: bool) -> pl.DataFrame: - """*name* through the reader *kind* names — the dispatch every bridge and :meth:`scan` share.""" + """*name* through the reader *kind* names — the dispatch every bridge and [`scan`][] share.""" reader = {'primal': self.primal, 'dual': self.dual, 'expression': self.evaluate}[reader_kind(kind)] return reader(name, original_index=original_index) def to_pandas(self, name: str, kind: str = 'primal', *, original_index: bool = False) -> pd.DataFrame: - """One name's values across every slice as a tidy :class:`pandas.DataFrame`. + """One name's values across every slice as a tidy `pandas.DataFrame`. The name is resolved before pandas is imported, so a sweep that never held *name* says so on any install. @@ -1026,7 +1026,7 @@ def to_pandas(self, name: str, kind: str = 'primal', *, original_index: bool = F return tidy_to_pandas(self._frame(name, kind, original_index=original_index)) def to_dataarray(self, name: str, kind: str = 'primal', *, original_index: bool = False) -> xr.DataArray: - """One name's values as a :class:`xarray.DataArray`, the slice key a dimension; :meth:`to_pandas`'s arguments. + """One name's values as a `xarray.DataArray`, the slice key a dimension; [`to_pandas`][]'s arguments. The extra dimension is named by the axis — a scenario sweep gives ``(scenario, …)`` and a window ``(_start, …)``. A slice that @@ -1038,13 +1038,13 @@ def to_dataarray(self, name: str, kind: str = 'primal', *, original_index: bool return tidy_to_dataarray(self.to_pandas(name, kind, original_index=original_index), name) def to_dataset(self, *names: str, kind: str = 'primal') -> xr.Dataset: - """The named values of one *kind* as one :class:`xarray.Dataset`; all of that kind by default. + """The named values of one *kind* as one `xarray.Dataset`; all of that kind by default. One kind per call: a dual and a variable of the same name would collide, and mean something else per row. Name the few you need, or - use :meth:`save`, which writes every kind. + use [`save`][], which writes every kind. - No ``original_index``: this and :meth:`save` export what the sweep + No ``original_index``: this and [`save`][] export what the sweep *holds*, lookahead rows included. Args: @@ -1064,7 +1064,7 @@ def save(self, directory: str | Path) -> Path: The same layout: ``//.parquet`` for every primal, dual and expression, the slice key a column of each, with ``record/``, ``metrics/`` and the manifest beside them. So the - directory is a spilled sweep: :meth:`scan` reads it, and the call + directory is a spilled sweep: [`scan`][] reads it, and the call that made this sweep, pointed at it with ``spill_to=``, reads it back without solving a slice. @@ -1155,15 +1155,15 @@ def _nothing_to_read(kind: str, name: str, held: Mapping[str, object], record: p def load_sweep(directory: str | Path) -> Sweep: - """Read back a sweep :meth:`Sweep.save` wrote, or one ``solve_over(spill_to=)`` spilled. + """Read back a sweep [`Sweep.save`][] wrote, or one ``solve_over(spill_to=)`` spilled. The sweep comes back **held**: every slice's frames are in memory when this returns, so it is the value a sweep solved without ``spill_to=`` is — - :meth:`Sweep.primal`, :meth:`Sweep.to_dataset` and :meth:`Sweep.save` all + [`Sweep.primal`][], [`Sweep.to_dataset`][] and [`Sweep.save`][] all answer, and it owes *directory* nothing afterwards. A sweep larger than - memory is :func:`scan_sweep` instead. + memory is [`scan_sweep`][] instead. - :attr:`Sweep.record` and :attr:`Sweep.metrics` are one row per slice + [`Sweep.record`][] and [`Sweep.metrics`][] are one row per slice either way, and ``original_index`` works on both, the manifest carrying the dimension a window sliced. @@ -1188,21 +1188,21 @@ def load_sweep(directory: str | Path) -> Sweep: def scan_sweep(directory: str | Path) -> Sweep: """The sweep under *directory*, its frames left where they lie. - :func:`load_sweep`'s other half, and the value a sweep solved with + [`load_sweep`][]'s other half, and the value a sweep solved with ``spill_to=`` already is: nothing but the record is read, and - :meth:`Sweep.scan` reads a name back as a :class:`polars.LazyFrame` when one + [`Sweep.scan`][] reads a name back as a `polars.LazyFrame` when one is asked for. That is the reader for a sweep too large to hold, and it - costs the frame readers: :meth:`Sweep.primal` and its siblings refuse, - naming :meth:`Sweep.scan`. + costs the frame readers: [`Sweep.primal`][] and its siblings refuse, + naming [`Sweep.scan`][]. *directory* has to outlive the sweep, the frames being read off it as they are asked for. Args: - directory: As :func:`load_sweep` takes it. + directory: As [`load_sweep`][] takes it. Raises: - LayoutError: As :func:`load_sweep` raises it. + LayoutError: As [`load_sweep`][] raises it. """ under = Path(directory) manifest = under / _MANIFEST_FILE @@ -1233,7 +1233,7 @@ def scan_sweep(directory: str | Path) -> Sweep: def axis_manifest(axis: EachCoordinate | EachWindow) -> dict[str, Any]: # pyrefly: ignore[explicit-any] — the archive's own JSON - """*axis* as the JSON an archive carries, read back by :func:`axis_from`.""" + """*axis* as the JSON an archive carries, read back by [`axis_from`][].""" if isinstance(axis, EachCoordinate): return {'each': 'coordinate', 'dim': axis.dim} steps = axis.steps if isinstance(axis.steps, int) else list(axis.steps) @@ -1241,7 +1241,7 @@ def axis_manifest(axis: EachCoordinate | EachWindow) -> dict[str, Any]: # pyref def axis_from(manifest: Mapping[str, Any]) -> EachCoordinate | EachWindow: # pyrefly: ignore[explicit-any] — the archive's own JSON - """The axis :func:`axis_manifest` wrote.""" + """The axis [`axis_manifest`][] wrote.""" if manifest['each'] == 'coordinate': return EachCoordinate(manifest['dim']) return EachWindow(manifest['dim'], steps=manifest['steps'], lookahead=manifest['lookahead'], into=manifest['into']) @@ -1289,12 +1289,12 @@ def solve_over( executor to choose — are [docs/reference/sweeps.md](../../docs/reference/sweeps.md). Args: - spec: As :func:`~specsolve.api.check` takes it. Parsed once, whichever + spec: As [`check`][specsolve.api.check] takes it. Parsed once, whichever executor runs the slices. - sources: As :func:`~specsolve.api.build` takes them, every shape + sources: As [`build`][specsolve.api.build] takes them, every shape included; the axis filters the tables that carry it and passes the rest through. - axis: :class:`EachCoordinate`, :class:`EachWindow`, or a list of + axis: [`EachCoordinate`][], [`EachWindow`][], or a list of ``(key, sources)`` written by hand. carry: ``{parameter: variable}`` — one slice's answer copied into the next slice's data. Where the two are over different dimensions the @@ -1303,20 +1303,20 @@ def solve_over( takes the parameter from *sources*, its seed. key_name: What to call the slice column; a class axis names its own, a hand-built list has to be told. - executor: Any :class:`concurrent.futures.Executor`; ``None`` runs the + executor: Any `concurrent.futures.Executor`; ``None`` runs the slices in order on one model. A process pool must be ``spawn`` or ``forkserver`` — a forked worker hangs. workers_share_fs: Whether the executor's workers can read this process's paths. Decided for the stdlib pools; anything else is assumed not to, and paths travel as bytes. - solver_options: As :meth:`~specsolve.api.Model.solve` takes them. - solver_name: As :meth:`~specsolve.api.Model.solve` takes it. - keep: As :meth:`~specsolve.api.Model.solve` takes it, reaching every + solver_options: As [`solve`][specsolve.api.Model.solve] takes them. + solver_name: As [`solve`][specsolve.api.Model.solve] takes it. + keep: As [`solve`][specsolve.api.Model.solve] takes it, reaching every slice. Under an executor every slice is a first solve and keeps nothing, whatever was asked. spill_to: A directory to write each slice's frames to as the fold goes, so the sweep's memory stays at one slice however many there - are. Read back through :meth:`Sweep.scan`. A directory holds + are. Read back through [`Sweep.scan`][]. A directory holds one sweep: run the same sweep at it again and the slices already there are not solved again, which is how an interrupted sweep resumes. @@ -1423,7 +1423,7 @@ def attach_sweep_readers( axis: EachCoordinate | EachWindow, carry: Mapping[str, str], ) -> Sweep: - """*sweep* with an undeclared expression readable through :meth:`Sweep.evaluate`, over a sweep archive's own inputs. + """*sweep* with an undeclared expression readable through [`Sweep.evaluate`][], over a sweep archive's own inputs. A sweep archive carries the spec, the uncut sources, the axis that cut them and the carry that chained them. The frames a save wrote supply each slice's @@ -1439,7 +1439,7 @@ def _per_slice( The one place a slice is put back together: the model is rebuilt from that slice's cut of the sources and its stored frames are put back against it - (:meth:`~specsolve.api.Model.evaluator`). A slice that reached no solution is + ([`evaluator`][specsolve.api.Model.evaluator]). A slice that reached no solution is skipped. """ primal, dual = _slice_index(sweep, 'primal'), _slice_index(sweep, 'dual') @@ -1511,7 +1511,7 @@ def _check_the_carry( A carry that collapses a dimension hands on the last coordinate the slice owns, so the dimension has to be the one the axis advances along — - :attr:`EachWindow.into`. + [`EachWindow.into`][]. """ for parameter, rule in plan.items(): if parameter not in first: @@ -1544,7 +1544,7 @@ def _serially( """Each slice's answer, off one model updated in place. Every slice of a sweep is the same math over different numbers, which is - what :meth:`~specsolve.api.Model.update` is for; a rebuild releases the + what [`update`][specsolve.api.Model.update] is for; a rebuild releases the previous model before it starts, so the fold holds one slice's model however many there are. @@ -1758,9 +1758,9 @@ def _key_column( Two rules: an axis that cannot name its own key has to be told, and no key may be a column the frames already carry — a dimension the spec declares, - or one of the fixed names every reader and :attr:`Sweep.record` use. What + or one of the fixed names every reader and [`Sweep.record`][] use. What a class axis calls its key when it is not told is - :meth:`EachCoordinate._key_name` and :meth:`EachWindow._key_name`. + [`EachCoordinate._key_name`][] and [`EachWindow._key_name`][]. Raises: SpecsolveError: A hand-built axis with no ``key_name``, a name the spec @@ -1829,7 +1829,7 @@ def _encode( as itself. *memo* keeps a source no slice rewrote from being encoded once per slice. - ``bytes`` is what :func:`_decode` reads back, and cannot be confused with a + ``bytes`` is what [`_decode`][] reads back, and cannot be confused with a path. """ out: dict[str, Any] = {} # pyrefly: ignore[explicit-any] — a frame crosses as parquet bytes @@ -1854,7 +1854,7 @@ def _encode( def _decode(encoded: Mapping[str, Any]) -> dict[str, Any]: # pyrefly: ignore[explicit-any] — a frame crosses as parquet bytes - """The inverse of :func:`_encode`, and a pass-through for what never crossed. + """The inverse of [`_encode`][], and a pass-through for what never crossed. Called on every returned frame rather than only the encoded ones: a frame that stayed in this process is not ``bytes`` and comes back untouched. diff --git a/tests/test_docs_site.py b/tests/test_docs_site.py index 1961dc0a..8ef7d673 100644 --- a/tests/test_docs_site.py +++ b/tests/test_docs_site.py @@ -299,3 +299,35 @@ def test_the_plan_table_names_every_expression_node(): shown = {name: cell.strip() for name, cell in rows.items()} declared = {name: fan_in(node) for name, node in nodes.items()} assert shown == declared, f'the table calls these {shown}, the compiler answers {declared}' + + +#: A ``:::`` entry, which mkdocstrings renders from the named object's docstring. +API_ENTRY = re.compile(r'^::: (\S+)$', re.MULTILINE) + + +def test_every_name_the_package_exports_has_an_entry_on_the_api_page(): + """The reference is the docstrings, so a name without an entry has no reference at all.""" + import specsolve + + rendered = set(API_ENTRY.findall((DOCS / 'reference' / 'api.md').read_text())) + missing = sorted(name for name in specsolve.__all__ if f'specsolve.{name}' not in rendered) + assert not missing, f'names in specsolve.__all__ with no ::: entry on reference/api.md: {missing}' + + +#: A Sphinx role, which mkdocstrings prints as it stands. +SPHINX_ROLE = re.compile(r':(?:func|class|meth|attr|mod|data|exc|obj):`') + + +def test_no_docstring_links_with_a_sphinx_role(): + """A ``:func:`build``` prints on the site as the literal text, so a docstring links as ``[`build`][]``. + + The strict build fails on a link of that form that resolves to nothing, but + it cannot tell a Sphinx role from prose. + """ + found = [ + f'{path.relative_to(REPO)}:{number}' + for path in sorted((REPO / 'src').rglob('*.py')) + for number, line in enumerate(path.read_text().splitlines(), start=1) + if SPHINX_ROLE.search(line) + ] + assert not found, f'Sphinx roles, which the site prints literally: {found}' diff --git a/tests/test_docstring_links.py b/tests/test_docstring_links.py new file mode 100644 index 00000000..e113e4f4 --- /dev/null +++ b/tests/test_docstring_links.py @@ -0,0 +1,79 @@ +"""Every link a docstring in `src/` writes lands on a specsolve object. + +The strict site build checks the links of the docstrings it renders, which is +the public surface. Most links sit in modules the site never renders, where a +target that was renamed or removed leaves a link to nothing and no build +notices. This walks every docstring the package holds, resolves each link the +way the site does — in the docstring's own scope, walking outward — and +refuses one that lands nowhere, or on another package, whose names are plain +code rather than links. + +griffe is the site's own reader, loaded with the extension the site uses to read +`#:` attribute comments, and ships with the docs toolchain: this runs under +`pixi run docs-test` and skips where the toolchain is absent. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +import pytest + +griffe = pytest.importorskip('griffe', reason='the docs toolchain reads the docstrings; `pixi run docs-test`') + +SRC = Path(__file__).resolve().parent.parent / 'src' + +#: A link as mkdocstrings writes it: ``[`name`][]`` or ``[`name`][dotted.path]``. +LINK = re.compile(r'\[`([^`]+)`\]\[([^\]]*)\]') + + +def _target(scope: griffe.Object, name: str) -> str | None: + """The full path *name* reaches from *scope*, walking outward the way a scoped link does.""" + if name.startswith('specsolve.'): + return name + first, _, rest = name.partition('.') + while scope is not None: + try: + found = scope.resolve(first) + except griffe.NameResolutionError: + scope = scope.parent + continue + return f'{found}.{rest}' if rest else found + return None + + +def _broken(package: griffe.Module) -> tuple[list[str], int]: + """The links that land nowhere or outside specsolve, and how many links were read.""" + broken, read = [], 0 + stack = [package] + while stack: + obj = stack.pop() + if obj.is_alias: + continue + stack.extend(obj.members.values()) + if obj.docstring is None: + continue + for display, written in LINK.findall(obj.docstring.value): + read += 1 + path = _target(obj, written or display) + try: + if path is None or not path.startswith('specsolve.'): + raise KeyError(path) + package[path.removeprefix('specsolve.')] + except (KeyError, griffe.AliasResolutionError): + broken.append(f'{obj.path}: [`{display}`][{written}] -> {path}') + return sorted(broken), read + + +def test_every_docstring_link_lands_on_a_specsolve_object(): + package = griffe.load( + 'specsolve', search_paths=[SRC], extensions=griffe.load_extensions('griffe_sphinx'), resolve_aliases=False + ) + broken, read = _broken(package) + assert read > 400, f'the walk read {read} links, so it no longer reaches the docstrings it is for' + assert not broken, ( + f'docstring links that land nowhere, or outside specsolve: {broken} — link a name as [`name`][] ' + f'where the module imports it, [`name`][dotted.path] where it does not, and write a name from ' + f'another package as plain code' + ) diff --git a/uv.lock b/uv.lock index f38c584b..fa1a9f90 100644 --- a/uv.lock +++ b/uv.lock @@ -284,6 +284,39 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fd/3c/6a2bf344106328fd04963664a60b9bb6496fc25df8e962fcdc1367285fb9/fsspec-2026.7.0-py3-none-any.whl", hash = "sha256:b57ddbafedfaef7018c1ecab32aa200a9d7ca26b77965f64e48b70061249d279", size = 206583, upload-time = "2026-07-28T16:34:49.538Z" }, ] +[[package]] +name = "ghp-import" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/29/d40217cbe2f6b1359e00c6c307bb3fc876ba74068cbab3dde77f03ca0dc4/ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343", size = 10943, upload-time = "2022-05-02T15:47:16.11Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034, upload-time = "2022-05-02T15:47:14.552Z" }, +] + +[[package]] +name = "griffe-sphinx" +version = "0.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "griffelib" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/0e/9503ddc07889905273223d9a1f1227459b0a62dc7c886443a265e3bf0f26/griffe_sphinx-0.3.0.tar.gz", hash = "sha256:8fec469cd17aa970bb8df8af1bd405be12423c03578932b22eb636fce23927c3", size = 31617, upload-time = "2026-09-02T17:18:19.81Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/8d/8d92bf303bfeb76c0ddb0db6613bf9bf2e647f77935a46e0f2eb6c1b7e77/griffe_sphinx-0.3.0-py3-none-any.whl", hash = "sha256:9a56d17e28b4affd9f1993d5411d3d5b143d5125a90e8bd0ffd51cb57f12057a", size = 9996, upload-time = "2026-09-02T17:18:18.716Z" }, +] + +[[package]] +name = "griffelib" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/27/af/018c10bc9edd42b6ef6db2e96b09542050d5253f9b195e74bc910b2d13ab/griffelib-2.3.0.tar.gz", hash = "sha256:7b0952caf5bca6afa4bb5ee8c6a2d183fe3f21b62efc5f6c7243cb2b26d2d115", size = 234534, upload-time = "2026-09-04T15:08:17.472Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/63/e876e789525063c840ccfa8857febdabd6523bcef9ce7eb979b9305ea895/griffelib-2.3.0-py3-none-any.whl", hash = "sha256:1b8f9cd525681c26b1d6d574faa1371651e8459ca51d209684f50b8096ae06e0", size = 169423, upload-time = "2026-09-04T15:08:12.956Z" }, +] + [[package]] name = "gurobipy" version = "13.0.3" @@ -605,6 +638,98 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/43/50/76598b0e0bf727f4bcdd1a65ac50d609d4952c7d736c27da6a4196468b61/memray-1.20.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:c19934e6b713dbbf35f15d3c84f289e048be613e85039913a500393f10272b9f", size = 12377327, upload-time = "2026-08-07T20:16:05.636Z" }, ] +[[package]] +name = "mergedeep" +version = "1.3.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3a/41/580bb4006e3ed0361b8151a01d324fb03f420815446c7def45d02f74c270/mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8", size = 4661, upload-time = "2021-02-05T18:55:30.623Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307", size = 6354, upload-time = "2021-02-05T18:55:29.583Z" }, +] + +[[package]] +name = "mkdocs" +version = "1.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "ghp-import" }, + { name = "jinja2" }, + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mergedeep" }, + { name = "mkdocs-get-deps" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "pyyaml" }, + { name = "pyyaml-env-tag" }, + { name = "watchdog" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bc/c6/bbd4f061bd16b378247f12953ffcb04786a618ce5e904b8c5a01a0309061/mkdocs-1.6.1.tar.gz", hash = "sha256:7b432f01d928c084353ab39c57282f29f92136665bdd6abf7c1ec8d822ef86f2", size = 3889159, upload-time = "2024-08-30T12:24:06.899Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/5b/dbc6a8cddc9cfa9c4971d59fb12bb8d42e161b7e7f8cc89e49137c5b279c/mkdocs-1.6.1-py3-none-any.whl", hash = "sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e", size = 3864451, upload-time = "2024-08-30T12:24:05.054Z" }, +] + +[[package]] +name = "mkdocs-autorefs" +version = "1.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mkdocs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/c0/f641843de3f612a6b48253f39244165acff36657a91cc903633d456ae1ac/mkdocs_autorefs-1.4.4.tar.gz", hash = "sha256:d54a284f27a7346b9c38f1f852177940c222da508e66edc816a0fa55fc6da197", size = 56588, upload-time = "2026-02-10T15:23:55.105Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/28/de/a3e710469772c6a89595fc52816da05c1e164b4c866a89e3cb82fb1b67c5/mkdocs_autorefs-1.4.4-py3-none-any.whl", hash = "sha256:834ef5408d827071ad1bc69e0f39704fa34c7fc05bc8e1c72b227dfdc5c76089", size = 25530, upload-time = "2026-02-10T15:23:53.817Z" }, +] + +[[package]] +name = "mkdocs-get-deps" +version = "0.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mergedeep" }, + { name = "platformdirs" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ce/25/b3cccb187655b9393572bde9b09261d267c3bf2f2cdabe347673be5976a6/mkdocs_get_deps-0.2.2.tar.gz", hash = "sha256:8ee8d5f316cdbbb2834bc1df6e69c08fe769a83e040060de26d3c19fad3599a1", size = 11047, upload-time = "2026-03-10T02:46:33.632Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/29/744136411e785c4b0b744d5413e56555265939ab3a104c6a4b719dad33fd/mkdocs_get_deps-0.2.2-py3-none-any.whl", hash = "sha256:e7878cbeac04860b8b5e0ca31d3abad3df9411a75a32cde82f8e44b6c16ff650", size = 9555, upload-time = "2026-03-10T02:46:32.256Z" }, +] + +[[package]] +name = "mkdocstrings" +version = "1.0.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jinja2" }, + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mkdocs" }, + { name = "mkdocs-autorefs" }, + { name = "pymdown-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/71/f85bdf13355073ae15a7375f09879375a830553552e58c1c4b7e0bbc5c8b/mkdocstrings-1.0.6.tar.gz", hash = "sha256:a0b8c2bdd29a6416c80d717aa369bbf7831946bd9f23c2a66db1b1dbe7693dbd", size = 100649, upload-time = "2026-07-11T19:38:05.732Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/5b/4c1902e8bdd5c4db63284e9d101dece4038d4025d6d88850ffe0a1578980/mkdocstrings-1.0.6-py3-none-any.whl", hash = "sha256:2703708697487d1b6d6d7b412e176fa436edf120c1bf81dc9e126b12d00893c7", size = 35787, upload-time = "2026-07-11T19:38:04.417Z" }, +] + +[[package]] +name = "mkdocstrings-python" +version = "2.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "griffelib" }, + { name = "mkdocs-autorefs" }, + { name = "mkdocstrings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/11/640067d1ad713ea99c233e53e57a4c300d9885f904a61371d7ef90dea549/mkdocstrings_python-2.0.9.tar.gz", hash = "sha256:ae945637dc0618c6beedbee169f10c0234b77f322c53db3fc9fb4c29b3013038", size = 203006, upload-time = "2026-09-22T13:43:27.712Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/0f/f7f926cfa06317bbdbd3cf0ff82f9e3a91767352eb01519c81499fddf575/mkdocstrings_python-2.0.9-py3-none-any.whl", hash = "sha256:c0233eff3f84d78110df50541918e7ec2bcff8e1614faddea34290a90321a416", size = 105698, upload-time = "2026-09-22T13:43:26.345Z" }, +] + [[package]] name = "nodeenv" version = "1.10.0" @@ -802,6 +927,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/71/e7/40fb618334dcdf7c5a316c0e7343c5cd82d3d866edc100d98e29bc945ecd/partd-1.4.2-py3-none-any.whl", hash = "sha256:978e4ac767ec4ba5b86c6eaa52e5a2a3bc748a2ca839e8cc798f1cc6ce6efb0f", size = 18905, upload-time = "2024-05-06T19:51:39.271Z" }, ] +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + [[package]] name = "platformdirs" version = "4.11.5" @@ -1247,6 +1381,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] +[[package]] +name = "pyyaml-env-tag" +version = "1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/2e/79c822141bfd05a853236b504869ebc6b70159afc570e1d5a20641782eaa/pyyaml_env_tag-1.1.tar.gz", hash = "sha256:2eb38b75a2d21ee0475d6d97ec19c63287a7e140231e4214969d0eac923cd7ff", size = 5737, upload-time = "2025-05-13T15:24:01.64Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/11/432f32f8097b03e3cd5fe57e88efb685d964e2e5178a48ed61e841f7fdce/pyyaml_env_tag-1.1-py3-none-any.whl", hash = "sha256:17109e1a528561e32f026364712fee1264bc2ea6715120891174ed1b980d2e04", size = 4722, upload-time = "2025-05-13T15:23:59.629Z" }, +] + [[package]] name = "rich" version = "15.0.0" @@ -1417,7 +1563,10 @@ dev = [ { name = "xarray" }, ] docs = [ + { name = "griffe-sphinx" }, { name = "markdown-exec" }, + { name = "mkdocstrings" }, + { name = "mkdocstrings-python" }, { name = "pygments" }, { name = "pymdown-extensions" }, { name = "pytest" }, @@ -1458,7 +1607,10 @@ dev = [ { name = "xarray", specifier = ">=2024.2.0" }, ] docs = [ + { name = "griffe-sphinx", specifier = "==0.3.0" }, { name = "markdown-exec", specifier = "==1.12.3" }, + { name = "mkdocstrings", specifier = "==1.0.6" }, + { name = "mkdocstrings-python", specifier = "==2.0.9" }, { name = "pygments", specifier = "==2.20.0" }, { name = "pymdown-extensions", specifier = "==11.0.1" }, { name = "pytest", specifier = "==9.1.1" }, @@ -1633,6 +1785,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/95/f8/dcec8dc767b812193316c7debed1e2b53e586e59c267bfe517688ff90275/virtualenv-21.7.7-py3-none-any.whl", hash = "sha256:67a6a68fef3ad8ca16b8b89f33fd8f97996cc0bf0db31629d07ecf8dec539a2c", size = 5324620, upload-time = "2026-08-28T18:59:48.056Z" }, ] +[[package]] +name = "watchdog" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471, upload-time = "2024-11-01T14:06:37.745Z" }, + { url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449, upload-time = "2024-11-01T14:06:39.748Z" }, + { url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054, upload-time = "2024-11-01T14:06:41.009Z" }, + { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload-time = "2024-11-01T14:06:42.952Z" }, + { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload-time = "2024-11-01T14:06:45.084Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload-time = "2024-11-01T14:06:47.324Z" }, + { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" }, + { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" }, + { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" }, + { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" }, + { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" }, + { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, +] + [[package]] name = "xarray" version = "2026.7.0"