diff --git a/CHANGELOG.md b/CHANGELOG.md index 089b89f..a8cf44a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,38 +1,48 @@ # Changelog -Notable changes to QProgram, newest first. Each entry starts life as a news -fragment under `changelog/`, and `towncrier build` assembles the fragments -into a release section here. See -[Contributing](https://qilimanjaro-tech.github.io/qprogram/developer/contributing.html) -for how to add one. +Notable changes to QProgram, newest first. Each entry starts life as a news fragment under `changelog/`, and `towncrier build` assembles the fragments into a release section here. See [Contributing](https://qilimanjaro-tech.github.io/qprogram/developer/contributing.html) for how to add one. +## 0.2.0 (2026-09-08) + +This release moves the `.qp` and `.wfl` format version onto the library version, so a file written by 0.1.0 no longer loads: its `#!QProgram 1.0` header reads as a later version than the `0.2` this release writes, and nothing runs backwards. Files written from here on are covered, since a release that changes a syntax now registers a migration that carries an older file up to the running version on load. + +### Added + +- A file written against an earlier format version now loads, in both text formats. A release that changes a syntax registers one migration under its own version with `qp.register_migration`, and reading applies every migration between the version in the file's header and the running one, oldest first, to the lines in memory — the file on disk is never rewritten, and writing it back out writes today's version. A release that breaks nothing registers nothing, so the chain carries an entry per breaking change rather than per release, and an older file with nothing behind it loads as it is. A migration rewrites lines one for one and may not add or drop any, which is what keeps a `ParseError`'s line number and every `source_map` entry naming a line of the file its author opened; one that breaks that count raises `ValueError` and names itself. `.qp` and `.wfl` share the version scale, since each header carries the library version cut to `major.minor`, but not their rewrites: `file_format="qp"` or `"wfl"` picks the table, and a change to vocabulary the two files share is one rewrite registered twice. `qp.serialization.known_migrations` lists what is registered for a format. A vendor extension gets the same mechanism for the wire form of its own operations through `qp.register_vendor_migration`, keyed to the extension's version: the `require ` line says which release wrote the body, and the extension's rewrites carry it up to the installed one, so renaming an operation no longer orphans the files a user already has. ([PR #43](https://github.com/qilimanjaro-tech/qprogram/pull/43)) +- `QProgramResult.plot` draws a measurement. The array is looked up the way `get` looks it up, and its shape chooses the figure: one dimension besides `IQ` gives a line per quadrature, two give a heatmap, and `kind="scatter"` puts I against Q. `channels=` says what to make of the quadratures, `x=` and `y=` which coordinate goes on which axis, and a swept variable's `label` and `units` reach the axis on their own. A dimension a parallel composition built carries one coordinate per composed variable, and both readings are drawn rather than one being chosen: the first runs along the axis and the second along a twin scale opposite it, in the order the dimension name gives them, so `"freq|time"` reads frequency across the bottom and time across the top. `coords=` and `value=` restate a quantity for the figure: a `qp.plotting.Quantity` carries the arithmetic and the words it produces in one object, so `{"freq": Quantity(units="GHz", transform=lambda v: v / 1e9)}` draws the axis in gigahertz and labels it so, and rescaling values without saying what unit they are now in raises rather than printing a label that contradicts its own numbers. The drawing sits behind a renderer registered by name, so matplotlib is one implementation rather than the only one: `qp.plotting.build_figure` describes a figure using numpy and xarray alone, and `qp.plotting.Style` and `Theme` are frozen dataclasses a light or dark palette of your own replaces. ([PR #35](https://github.com/qilimanjaro-tech/qprogram/pull/35)) +- A swept variable's `label` and `units` now reach the result. The executor writes them onto that variable's coordinate as the `long_name` and `units` attributes, which is what xarray's own plotting reads, so `result.get(m0).sel(IQ="I").plot()` labels its x axis instead of falling back to the variable id. ([PR #34](https://github.com/qilimanjaro-tech/qprogram/pull/34)) +- Eight worked example pages: qubit spectroscopy, T1 and Ramsey, active reset, resonator spectroscopy, CPMG on two qubits, multiplexed readout, single-shot readout, and checking a program before it runs. Between them they give a complete program to the parts of the language the earlier examples never reached: `set_frequency` and `qp.Linspace`, arithmetic expressions over a swept variable, `wait` as a time axis, `reset_phase` and `set_phase`, `MeasurementField.STATE` read back as a population and at single-shot resolution, the `if_` / `elif_` / `else_` chain conditioned on a measurement's classified state, `set_parameter` with the `forced-host` warning and the `qp.optimize` rewrite that clears it, `@qp.fragment` with `expand()`, `qp.WaveformLibrary` and the `.wfl` format, `QProgram.rebind`, a hand-written `qp.MeasurementModel`, and a `qp.PlatformCapabilities` built by hand to show what `qp.validate` refuses. ([PR #28](https://github.com/qilimanjaro-tech/qprogram/pull/28)) + +### Changed + +- The `.qp` format version now follows the library version, truncated to `major.minor`, so this release writes `#!QProgram 0.2` instead of the fixed `1.0`. `FORMAT_VERSION` is now derived rather than a literal: `library_major_minor` in the new `qprogram/_version.py` reads the installed distribution's version through `importlib.metadata`, and imports nothing else from the package, so `qprogram/serialization/_format.py` stays the leaf module the writer and the parser both import. The compatibility check is unchanged — the parser accepts any minor within its own major and rejects a different one — so a file written by an earlier release no longer loads: `#!QProgram 1.0` now fails with `Line 1: Unsupported format version 1.0`. The `.wfl` waveform library format follows the library version through the same helper, so a `WaveformLibrary` now writes `#!WaveformLibrary 0.2` and refuses a `1.0` file with `Line 1: Unsupported WaveformLibrary format version 1.0`. ([PR #42](https://github.com/qilimanjaro-tech/qprogram/pull/42)) +- Both readers now take a header version to be exactly `major.minor`, and refuse any version above the running one. A file from a later release used to load when its major matched, on the grounds that a minor only ever adds; it is refused now, because a reader has no way to know what a later release changed and no migration runs backwards. A header carrying a patch (`#!QProgram 0.2.3`) or a bare major (`#!QProgram 0`) is refused as well: a patch release changes code and never the format, so a file has no patch to declare. The `.qp` grammar's header terminal already said as much, and the hand-written parser now agrees with it. + + A `require ` line is read by that same rule against the extension it names, which changes two things and merges the two failures into one message. A line carrying a patch (`require myvendor 0.1.9`) is refused rather than rounded down, and a line naming an earlier major is migrated rather than refused, so `major versions must match` and `minor version too old` are replaced by `file requires myvendor 0.7, newer than the installed myvendor 0.1.0 — install myvendor 0.7 or newer`. What an extension registers with `register_vendor_version` is unaffected: that is a package version, and its patch component is still read and ignored. ([PR #43](https://github.com/qilimanjaro-tech/qprogram/pull/43)) +- `Sweep` takes a `SweepSource` and nothing else. A sequence of points in the source position is now a `ValidationError` naming the two spellings that pick explicit values, `sweep(variable, qp.Values([...]))` and `sweep(variable).from_values([...])`, so the block always holds something that can answer its own length and kind and write itself back out to `.qp`. The combinators are unaffected, and so is the format's bracket literal, which still reads back as `Values`. ([PR #41](https://github.com/qilimanjaro-tech/qprogram/pull/41)) +- `MeasurementSample.raw` now defaults to an empty `(0, 2)` array, so a measurement model with no ADC to simulate no longer has to invent a filler trace. A measurement that requests `MeasurementField.RAW` checks the trace's shape against the model's `raw_samples` and raises a `ValueError` naming the measurement, the shape received, and the shape expected. Previously a mismatched trace either died with a bare numpy broadcast error that named nothing, or — for a `(2,)` or `(1, 2)` trace — was silently broadcast across every time sample and returned a wrong result with no error at all. The check goes through `numpy.shape`, so a nested list or tuple remains a valid trace. ([PR #28](https://github.com/qilimanjaro-tech/qprogram/pull/28)) +- `Waveform.plot` and `IQWaveform.plot` draw through the palette and the renderer registry `QProgramResult.plot` draws through, so a pulse and the sweep it produced no longer look like they came from two libraries. All three now take the same `style`, `renderer` and `target`: the envelope is described as a `qp.plotting.Figure` and handed to a renderer resolved by name, the style defaults to the same `qp.plotting.Style()`, and `target` replaces `ax` and `axes`. `Style.size` defaults to `None`, meaning the size that suits what is being drawn: `qp.plotting.DEFAULT_SIZE` for a measurement and `ENVELOPE_SIZE` or `IQ_ENVELOPE_SIZE` for a waveform, which are the figure sizes the two plotting methods always had, and `Style.sized` is how a caller fills one in. A `Figure` also carries `series`, the palette slot its first mark takes, which is what draws the two panels of an IQ envelope in the theme's first two colours. Those panels are the one thing a renderer does not decide, since two axes sharing a scale is a matplotlib layout, so any other renderer has to be given the `(I, Q)` surfaces to draw on. `_repr_html_` returns a `` holding the envelope drawn once for a light surface and once for a dark one, chosen by `prefers-color-scheme`, so a waveform in a dark notebook is no longer a white rectangle; it also takes the figure off the axes `plot` returned rather than off pyplot's current figure, which was an ordering contract that held only by convention. ([PR #36](https://github.com/qilimanjaro-tech/qprogram/pull/36)) +- Every documentation page is rewritten against the source it describes, with the `.wfl` waveform-library format and `QProgram.rebind` documented for the first time. Code examples now import the package once as `import qprogram as qp` and reach everything else through `qp.`, including `qp.waveforms.Square` and `qp.operations.Play`. ([PR #25](https://github.com/qilimanjaro-tech/qprogram/pull/25)) +- The example pages now carry the figures their programs produce. Each one is rendered by building the page's own program, running it on the reference platform, and writing the result to `docs/assets/plots/`, so a plot cannot drift from the code printed above it. Every figure is written once per site theme and the page picks the one built for the surface being read on, so the dark variant is its own render on the dark surface rather than a light figure behind a filter. ([PR #29](https://github.com/qilimanjaro-tech/qprogram/pull/29)) + +### Fixed + +- `loads` no longer accepts a non-breaking space as the separator in a `fragment` header. Only ASCII whitespace separates a header's tokens, which is what the reference `qp.lark` grammar has always required, so the two now agree on the same set of documents. ([PR #26](https://github.com/qilimanjaro-tech/qprogram/pull/26)) +- `loads` and `WaveformLibrary.loads` no longer split a document at a Unicode line separator. Both read their lines with `str.splitlines`, which breaks on eight characters (`\v`, `\f`, `\x1c`, `\x1d`, `\x1e`, `\x85`, `\u2028` and `\u2029`) that the format's own line terminator does not, so a label, units, description, or waveform name holding one of them was written correctly but split across two lines on reload. Lines are now split on `\r?\n`, which is what the reference `qp.lark` grammar has always meant by a line. ([PR #27](https://github.com/qilimanjaro-tech/qprogram/pull/27)) +- `register_profile` no longer rejects a rebuilt-but-identical `Profile`. The guard compared object identity, so an import-time side effect that ran twice, a reloaded module, or a re-executed notebook cell was told its own bundle was "already registered with different content" while the two profiles compared equal. Registration is now idempotent for an equal profile and raises only when the content actually differs. Of an equal pair the registry keeps the first object, so the profile just passed in is not necessarily the one `resolve_profile` returns. ([PR #28](https://github.com/qilimanjaro-tech/qprogram/pull/28)) +- `dumps` no longer writes a fragment parameter in a `sync` bus list as its `repr`. `sync` was the one bus argument the format spells as a list, and it rendered each target through the writer's `serialize_bus`, which knows `BusRef` and quotes everything else; a parameter went out as the quoted string `"Parameter('drive')"`. The text still round-tripped byte-for-byte, but the reloaded fragment synchronized two buses of that literal name instead of the ones bound at the call site, and the program's bus set silently gained them. Targets now go through `serialize_value`, which is what every other operation's bus already used, so a parameter emits as the bare identifier the grammar's `value` rule has always accepted. ([PR #28](https://github.com/qilimanjaro-tech/qprogram/pull/28)) + + ## 0.1.0 (2026-08-25) ### Added -- First release. QProgram is a hardware-agnostic Python DSL for pulse-level - quantum experiments: a fluent builder that assembles a typed AST of - operations, symbolic expressions, waveforms, and control flow. The core knows - nothing about any particular instrument, and its runtime dependencies are - numpy and xarray. -- `BusSchema` and the `BusRef` values it produces, for addressing drives, - readouts, fluxes, and couplers without committing to a naming convention. -- A capability protocol platforms validate programs against. Capabilities are - declared per slot, where a slot is a `(bus, domain)` pair over the real-time - (`rt`) and host-side (`host`) domains. Validation reports `Diagnostic`s and an - `ExecutionPlan`, and `qp.explain` shows which part of a program a backend - cannot run and why. -- A reference software executor behind `qp.simulate`, which is the executable - definition of the language's semantics. Results come back as labeled `xarray` - arrays whose axes are named by the sweeps that produced them. -- The `.qp` text file format, with `src/qprogram/grammar/qp.lark` as its - normative grammar and explicit `require` lines recording what a file needs - from whatever reads it. -- Three hooks for vendor extensions: a runtime namespace, a typed mixin for - autocompletion, and a serialization registry entry. Extensions are discovered - through the `qprogram.vendors` entry-point group, so a `.qp` file that names a - vendor resolves it without the caller importing that package first. -- Two optional extras: `qprogram[viz]` adds `Waveform.plot()`, and - `qprogram[lsp]` adds the language server behind editor diagnostics. +- First release. QProgram is a hardware-agnostic Python DSL for pulse-level quantum experiments: a fluent builder that assembles a typed AST of operations, symbolic expressions, waveforms, and control flow. The core knows nothing about any particular instrument, and its runtime dependencies are numpy and xarray. +- `BusSchema` and the `BusRef` values it produces, for addressing drives, readouts, fluxes, and couplers without committing to a naming convention. +- A capability protocol platforms validate programs against. Capabilities are declared per slot, where a slot is a `(bus, domain)` pair over the real-time (`rt`) and host-side (`host`) domains. Validation reports `Diagnostic`s and an `ExecutionPlan`, and `qp.explain` shows which part of a program a backend cannot run and why. +- A reference software executor behind `qp.simulate`, which is the executable definition of the language's semantics. Results come back as labeled `xarray` arrays whose axes are named by the sweeps that produced them. +- The `.qp` text file format, with `src/qprogram/grammar/qp.lark` as its normative grammar and explicit `require` lines recording what a file needs from whatever reads it. +- Three hooks for vendor extensions: a runtime namespace, a typed mixin for autocompletion, and a serialization registry entry. Extensions are discovered through the `qprogram.vendors` entry-point group, so a `.qp` file that names a vendor resolves it without the caller importing that package first. +- Two optional extras: `qprogram[viz]` adds `Waveform.plot()`, and `qprogram[lsp]` adds the language server behind editor diagnostics. diff --git a/README.md b/README.md index f415ec2..53cd550 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,10 @@ # QProgram -[![Tests](https://github.com/qilimanjaro-tech/qprogram/actions/workflows/tests.yml/badge.svg)](https://github.com/qilimanjaro-tech/qprogram/actions/workflows/tests.yml) -[![Code Quality](https://github.com/qilimanjaro-tech/qprogram/actions/workflows/code_quality.yml/badge.svg)](https://github.com/qilimanjaro-tech/qprogram/actions/workflows/code_quality.yml) -[![Python](https://img.shields.io/badge/python-3.11%20%7C%203.12%20%7C%203.13%20%7C%203.14-blue)](https://www.python.org/) -[![License](https://img.shields.io/badge/license-Apache%202.0-blue)](LICENSE) +[![Tests](https://github.com/qilimanjaro-tech/qprogram/actions/workflows/tests.yml/badge.svg)](https://github.com/qilimanjaro-tech/qprogram/actions/workflows/tests.yml) [![Code Quality](https://github.com/qilimanjaro-tech/qprogram/actions/workflows/code_quality.yml/badge.svg)](https://github.com/qilimanjaro-tech/qprogram/actions/workflows/code_quality.yml) [![Python](https://img.shields.io/badge/python-3.11%20%7C%203.12%20%7C%203.13%20%7C%203.14-blue)](https://www.python.org/) [![License](https://img.shields.io/badge/license-Apache%202.0-blue)](LICENSE) -QProgram is a Python DSL for describing pulse-level quantum experiments. You -write what you want the chip to do; the platform decides how to run it. +QProgram is a Python DSL for describing pulse-level quantum experiments. You write what you want the chip to do; the platform decides how to run it. -The core package knows nothing about any particular instrument. It defines the -language, the AST, a text file format, a capability protocol platforms validate -programs against, and the extension hooks vendor packages plug into. Its only -runtime dependencies are `numpy` and `xarray`. +The core package knows nothing about any particular instrument. It defines the language, the AST, a text file format, a capability protocol platforms validate programs against, and the extension hooks vendor packages plug into. Its only runtime dependencies are `numpy` and `xarray`. ## Installation @@ -19,8 +12,7 @@ runtime dependencies are `numpy` and `xarray`. pip install qprogram ``` -Optional extras: `qprogram[viz]` adds `Waveform.plot()`, and `qprogram[lsp]` -adds the language server used by editor integrations. +Optional extras: `qprogram[viz]` adds `Waveform.plot()`, and `qprogram[lsp]` adds the language server used by editor integrations. ## A first program @@ -55,28 +47,15 @@ result = qp.simulate(resolved) data = result.get(m0) # xarray.DataArray with named dimensions ``` -`data` comes back with dimensions `("gain", "IQ")` and shape `(101, 2)`: -dimensions are named after the enclosing loops, outermost first, and the 1000 -shots of the `average` block are reduced rather than kept. The reference -executor is the executable definition of the language's semantics. +`data` comes back with dimensions `("gain", "IQ")` and shape `(101, 2)`: dimensions are named after the enclosing loops, outermost first, and the 1000 shots of the `average` block are reduced rather than kept. The reference executor is the executable definition of the language's semantics. ## What the package does -Nothing in the package reaches an instrument. A program is a description: it is -built, checked, and handed over. Turning it into instrument code, placing it on -a timeline, and calibrating its pulses all happen behind `qp.PlatformProtocol`, -which is why the same program runs wherever that protocol is implemented. The -core stays small for that reason: it holds only what any platform could be -asked to do. Instrument-specific work (markers, active reset, triggers, -slow-control parameters) comes from optional vendor packages that register -themselves on import, and a `.qp` file that uses one records the dependency as -a `require` line and refuses to load without it. +Nothing in the package reaches an instrument. A program is a description: it is built, checked, and handed over. Turning it into instrument code, placing it on a timeline, and calibrating its pulses all happen behind `qp.PlatformProtocol`, which is why the same program runs wherever that protocol is implemented. The core stays small for that reason: it holds only what any platform could be asked to do. Instrument-specific work (markers, active reset, triggers, slow-control parameters) comes from optional vendor packages that register themselves on import, and a `.qp` file that uses one records the dependency as a `require` line and refuses to load without it. ## Documentation -Full documentation, including the user guide, the `.qp` format reference, and -the generated API reference, lives at -. +Full documentation, including the user guide, the `.qp` format reference, and the generated API reference, lives at . ## Development @@ -93,9 +72,7 @@ uv run --group docs zensical serve # preview the documentation ## Reference -The design is described in *"QProgram: A Hardware-Agnostic DSL for Portable -Pulse-Level Quantum Programming"* by Vyron Vasileiadis, Flavie Le Bars, and -David Arcos (Qilimanjaro Quantum Tech, Barcelona, Spain). +The design is described in *"QProgram: A Hardware-Agnostic DSL for Portable Pulse-Level Quantum Programming"* by Vyron Vasileiadis, Flavie Le Bars, and David Arcos (Qilimanjaro Quantum Tech, Barcelona, Spain). ## License diff --git a/changelog/25.changed.md b/changelog/25.changed.md deleted file mode 100644 index 8b1fd81..0000000 --- a/changelog/25.changed.md +++ /dev/null @@ -1 +0,0 @@ -Every documentation page is rewritten against the source it describes, with the `.wfl` waveform-library format and `QProgram.rebind` documented for the first time. Code examples now import the package once as `import qprogram as qp` and reach everything else through `qp.`, including `qp.waveforms.Square` and `qp.operations.Play`. diff --git a/changelog/26.fixed.md b/changelog/26.fixed.md deleted file mode 100644 index 15bee37..0000000 --- a/changelog/26.fixed.md +++ /dev/null @@ -1 +0,0 @@ -`loads` no longer accepts a non-breaking space as the separator in a `fragment` header. Only ASCII whitespace separates a header's tokens, which is what the reference `qp.lark` grammar has always required, so the two now agree on the same set of documents. diff --git a/changelog/27.fixed.md b/changelog/27.fixed.md deleted file mode 100644 index 2289b1b..0000000 --- a/changelog/27.fixed.md +++ /dev/null @@ -1 +0,0 @@ -`loads` and `WaveformLibrary.loads` no longer split a document at a Unicode line separator. Both read their lines with `str.splitlines`, which breaks on eight characters (`\v`, `\f`, `\x1c`, `\x1d`, `\x1e`, `\x85`, `\u2028` and `\u2029`) that the format's own line terminator does not, so a label, units, description, or waveform name holding one of them was written correctly but split across two lines on reload. Lines are now split on `\r?\n`, which is what the reference `qp.lark` grammar has always meant by a line. diff --git a/changelog/28.added.md b/changelog/28.added.md deleted file mode 100644 index e02b1be..0000000 --- a/changelog/28.added.md +++ /dev/null @@ -1 +0,0 @@ -Eight worked example pages: qubit spectroscopy, T1 and Ramsey, active reset, resonator spectroscopy, CPMG on two qubits, multiplexed readout, single-shot readout, and checking a program before it runs. Between them they give a complete program to the parts of the language the earlier examples never reached: `set_frequency` and `qp.Linspace`, arithmetic expressions over a swept variable, `wait` as a time axis, `reset_phase` and `set_phase`, `MeasurementField.STATE` read back as a population and at single-shot resolution, the `if_` / `elif_` / `else_` chain conditioned on a measurement's classified state, `set_parameter` with the `forced-host` warning and the `qp.optimize` rewrite that clears it, `@qp.fragment` with `expand()`, `qp.WaveformLibrary` and the `.wfl` format, `QProgram.rebind`, a hand-written `qp.MeasurementModel`, and a `qp.PlatformCapabilities` built by hand to show what `qp.validate` refuses. diff --git a/changelog/28.changed.md b/changelog/28.changed.md deleted file mode 100644 index eb6f504..0000000 --- a/changelog/28.changed.md +++ /dev/null @@ -1 +0,0 @@ -`MeasurementSample.raw` now defaults to an empty `(0, 2)` array, so a measurement model with no ADC to simulate no longer has to invent a filler trace. A measurement that requests `MeasurementField.RAW` checks the trace's shape against the model's `raw_samples` and raises a `ValueError` naming the measurement, the shape received, and the shape expected. Previously a mismatched trace either died with a bare numpy broadcast error that named nothing, or — for a `(2,)` or `(1, 2)` trace — was silently broadcast across every time sample and returned a wrong result with no error at all. The check goes through `numpy.shape`, so a nested list or tuple remains a valid trace. diff --git a/changelog/28.fixed.1.md b/changelog/28.fixed.1.md deleted file mode 100644 index ed2f079..0000000 --- a/changelog/28.fixed.1.md +++ /dev/null @@ -1 +0,0 @@ -`dumps` no longer writes a fragment parameter in a `sync` bus list as its `repr`. `sync` was the one bus argument the format spells as a list, and it rendered each target through the writer's `serialize_bus`, which knows `BusRef` and quotes everything else; a parameter went out as the quoted string `"Parameter('drive')"`. The text still round-tripped byte-for-byte, but the reloaded fragment synchronized two buses of that literal name instead of the ones bound at the call site, and the program's bus set silently gained them. Targets now go through `serialize_value`, which is what every other operation's bus already used, so a parameter emits as the bare identifier the grammar's `value` rule has always accepted. diff --git a/changelog/28.fixed.2.md b/changelog/28.fixed.2.md deleted file mode 100644 index 92ad784..0000000 --- a/changelog/28.fixed.2.md +++ /dev/null @@ -1 +0,0 @@ -`register_profile` no longer rejects a rebuilt-but-identical `Profile`. The guard compared object identity, so an import-time side effect that ran twice, a reloaded module, or a re-executed notebook cell was told its own bundle was "already registered with different content" while the two profiles compared equal. Registration is now idempotent for an equal profile and raises only when the content actually differs. Of an equal pair the registry keeps the first object, so the profile just passed in is not necessarily the one `resolve_profile` returns. diff --git a/changelog/29.changed.md b/changelog/29.changed.md deleted file mode 100644 index bec4e14..0000000 --- a/changelog/29.changed.md +++ /dev/null @@ -1 +0,0 @@ -The example pages now carry the figures their programs produce. Each one is rendered by building the page's own program, running it on the reference platform, and writing the result to `docs/assets/plots/`, so a plot cannot drift from the code printed above it. Every figure is written once per site theme and the page picks the one built for the surface being read on, so the dark variant is its own render on the dark surface rather than a light figure behind a filter. diff --git a/changelog/34.added.md b/changelog/34.added.md deleted file mode 100644 index d4145da..0000000 --- a/changelog/34.added.md +++ /dev/null @@ -1 +0,0 @@ -A swept variable's `label` and `units` now reach the result. The executor writes them onto that variable's coordinate as the `long_name` and `units` attributes, which is what xarray's own plotting reads, so `result.get(m0).sel(IQ="I").plot()` labels its x axis instead of falling back to the variable id. diff --git a/changelog/35.added.md b/changelog/35.added.md deleted file mode 100644 index 56a656a..0000000 --- a/changelog/35.added.md +++ /dev/null @@ -1 +0,0 @@ -`QProgramResult.plot` draws a measurement. The array is looked up the way `get` looks it up, and its shape chooses the figure: one dimension besides `IQ` gives a line per quadrature, two give a heatmap, and `kind="scatter"` puts I against Q. `channels=` says what to make of the quadratures, `x=` and `y=` which coordinate goes on which axis, and a swept variable's `label` and `units` reach the axis on their own. A dimension a parallel composition built carries one coordinate per composed variable, and both readings are drawn rather than one being chosen: the first runs along the axis and the second along a twin scale opposite it, in the order the dimension name gives them, so `"freq|time"` reads frequency across the bottom and time across the top. `coords=` and `value=` restate a quantity for the figure: a `qp.plotting.Quantity` carries the arithmetic and the words it produces in one object, so `{"freq": Quantity(units="GHz", transform=lambda v: v / 1e9)}` draws the axis in gigahertz and labels it so, and rescaling values without saying what unit they are now in raises rather than printing a label that contradicts its own numbers. The drawing sits behind a renderer registered by name, so matplotlib is one implementation rather than the only one: `qp.plotting.build_figure` describes a figure using numpy and xarray alone, and `qp.plotting.Style` and `Theme` are frozen dataclasses a light or dark palette of your own replaces. diff --git a/changelog/36.changed.md b/changelog/36.changed.md deleted file mode 100644 index f958ec5..0000000 --- a/changelog/36.changed.md +++ /dev/null @@ -1 +0,0 @@ -`Waveform.plot` and `IQWaveform.plot` draw through the palette and the renderer registry `QProgramResult.plot` draws through, so a pulse and the sweep it produced no longer look like they came from two libraries. All three now take the same `style`, `renderer` and `target`: the envelope is described as a `qp.plotting.Figure` and handed to a renderer resolved by name, the style defaults to the same `qp.plotting.Style()`, and `target` replaces `ax` and `axes`. `Style.size` defaults to `None`, meaning the size that suits what is being drawn: `qp.plotting.DEFAULT_SIZE` for a measurement and `ENVELOPE_SIZE` or `IQ_ENVELOPE_SIZE` for a waveform, which are the figure sizes the two plotting methods always had, and `Style.sized` is how a caller fills one in. A `Figure` also carries `series`, the palette slot its first mark takes, which is what draws the two panels of an IQ envelope in the theme's first two colours. Those panels are the one thing a renderer does not decide, since two axes sharing a scale is a matplotlib layout, so any other renderer has to be given the `(I, Q)` surfaces to draw on. `_repr_html_` returns a `` holding the envelope drawn once for a light surface and once for a dark one, chosen by `prefers-color-scheme`, so a waveform in a dark notebook is no longer a white rectangle; it also takes the figure off the axes `plot` returned rather than off pyplot's current figure, which was an ordering contract that held only by convention. diff --git a/changelog/41.changed.md b/changelog/41.changed.md deleted file mode 100644 index e1fc1b7..0000000 --- a/changelog/41.changed.md +++ /dev/null @@ -1 +0,0 @@ -`Sweep` takes a `SweepSource` and nothing else. A sequence of points in the source position is now a `ValidationError` naming the two spellings that pick explicit values, `sweep(variable, qp.Values([...]))` and `sweep(variable).from_values([...])`, so the block always holds something that can answer its own length and kind and write itself back out to `.qp`. The combinators are unaffected, and so is the format's bracket literal, which still reads back as `Values`. diff --git a/changelog/42.changed.md b/changelog/42.changed.md deleted file mode 100644 index 587a870..0000000 --- a/changelog/42.changed.md +++ /dev/null @@ -1 +0,0 @@ -The `.qp` format version now follows the library version, truncated to `major.minor`, so this release writes `#!QProgram 0.2` instead of the fixed `1.0`. `FORMAT_VERSION` is now derived rather than a literal: `library_major_minor` in the new `qprogram/_version.py` reads the installed distribution's version through `importlib.metadata`, and imports nothing else from the package, so `qprogram/serialization/_format.py` stays the leaf module the writer and the parser both import. The compatibility check is unchanged — the parser accepts any minor within its own major and rejects a different one — so a file written by an earlier release no longer loads: `#!QProgram 1.0` now fails with `Line 1: Unsupported format version 1.0`. The `.wfl` waveform library format follows the library version through the same helper, so a `WaveformLibrary` now writes `#!WaveformLibrary 0.2` and refuses a `1.0` file with `Line 1: Unsupported WaveformLibrary format version 1.0`. diff --git a/changelog/43.added.md b/changelog/43.added.md deleted file mode 100644 index 1aef7b1..0000000 --- a/changelog/43.added.md +++ /dev/null @@ -1 +0,0 @@ -A file written against an earlier format version now loads, in both text formats. A release that changes a syntax registers one migration under its own version with `qp.register_migration`, and reading applies every migration between the version in the file's header and the running one, oldest first, to the lines in memory — the file on disk is never rewritten, and writing it back out writes today's version. A release that breaks nothing registers nothing, so the chain carries an entry per breaking change rather than per release, and an older file with nothing behind it loads as it is. A migration rewrites lines one for one and may not add or drop any, which is what keeps a `ParseError`'s line number and every `source_map` entry naming a line of the file its author opened; one that breaks that count raises `ValueError` and names itself. `.qp` and `.wfl` share the version scale, since each header carries the library version cut to `major.minor`, but not their rewrites: `file_format="qp"` or `"wfl"` picks the table, and a change to vocabulary the two files share is one rewrite registered twice. `qp.serialization.known_migrations` lists what is registered for a format. A vendor extension gets the same mechanism for the wire form of its own operations through `qp.register_vendor_migration`, keyed to the extension's version: the `require ` line says which release wrote the body, and the extension's rewrites carry it up to the installed one, so renaming an operation no longer orphans the files a user already has. diff --git a/changelog/43.changed.md b/changelog/43.changed.md deleted file mode 100644 index f026639..0000000 --- a/changelog/43.changed.md +++ /dev/null @@ -1,3 +0,0 @@ -Both readers now take a header version to be exactly `major.minor`, and refuse any version above the running one. A file from a later release used to load when its major matched, on the grounds that a minor only ever adds; it is refused now, because a reader has no way to know what a later release changed and no migration runs backwards. A header carrying a patch (`#!QProgram 0.2.3`) or a bare major (`#!QProgram 0`) is refused as well: a patch release changes code and never the format, so a file has no patch to declare. The `.qp` grammar's header terminal already said as much, and the hand-written parser now agrees with it. - -A `require ` line is read by that same rule against the extension it names, which changes two things and merges the two failures into one message. A line carrying a patch (`require myvendor 0.1.9`) is refused rather than rounded down, and a line naming an earlier major is migrated rather than refused, so `major versions must match` and `minor version too old` are replaced by `file requires myvendor 0.7, newer than the installed myvendor 0.1.0 — install myvendor 0.7 or newer`. What an extension registers with `register_vendor_version` is unaffected: that is a package version, and its patch component is still read and ignored. diff --git a/docs/developer/adding-operations.md b/docs/developer/adding-operations.md index 3b076ac..0bbd619 100644 --- a/docs/developer/adding-operations.md +++ b/docs/developer/adding-operations.md @@ -1,47 +1,24 @@ # Adding a core operation -This page adds an operation to the core `qprogram` package. The example is -`SetPower(bus, power)`, a hypothetical real-time power setter. Every core -operation follows the same path, so the steps double as a description of how -`set_phase` and `play` got where they are. - -Snippets whose first line is a `# src/qprogram/...` path comment are package -source, and they keep their intra-package imports (`from -qprogram.operations.operation import Operation`): inside the package, `import -qprogram` would close an import cycle. The snippets without that comment are -user code, and follow the convention the rest of the documentation uses, a -single `import qprogram as qp` with everything else reached through `qp.`. The -snippets in [step 6](#step-6-write-tests) are a third case: they are bodies -lifted from files under `tests/`, which import the names they use directly -(`from qprogram import Variable`), so nothing in them is prefixed either. - -A vendor-specific operation is a different job, with no core change at all. -[Building a vendor extension](vendor-extensions.md) covers that end to end. +This page adds an operation to the core `qprogram` package. The example is `SetPower(bus, power)`, a hypothetical real-time power setter. Every core operation follows the same path, so the steps double as a description of how `set_phase` and `play` got where they are. + +Snippets whose first line is a `# src/qprogram/...` path comment are package source, and they keep their intra-package imports (`from qprogram.operations.operation import Operation`): inside the package, `import qprogram` would close an import cycle. The snippets without that comment are user code, and follow the convention the rest of the documentation uses, a single `import qprogram as qp` with everything else reached through `qp.`. The snippets in [step 6](#step-6-write-tests) are a third case: they are bodies lifted from files under `tests/`, which import the names they use directly (`from qprogram import Variable`), so nothing in them is prefixed either. + +A vendor-specific operation is a different job, with no core change at all. [Building a vendor extension](vendor-extensions.md) covers that end to end. ## What a new operation touches Seven edits, in dependency order: -1. `src/qprogram/operations/set_power.py`: the class, subclassing `Operation` - and overriding `required_capabilities()`. -2. `src/qprogram/operations/__init__.py`: the import and the `__all__` entry - that make the class `qp.operations.SetPower`. +1. `src/qprogram/operations/set_power.py`: the class, subclassing `Operation` and overriding `required_capabilities()`. +2. `src/qprogram/operations/__init__.py`: the import and the `__all__` entry that make the class `qp.operations.SetPower`. 3. `src/qprogram/qprogram.py`: the `QProgram.set_power` builder method. -4. `src/qprogram/serialization/_specs.py`: one `register_operation` line in - `_register_core_specs()`. +4. `src/qprogram/serialization/_specs.py`: one `register_operation` line in `_register_core_specs()`. 5. `src/qprogram/protocol.py`: the `op.set_power` token in `_BASE_TOKENS`. -6. The test files that cover the operation, listed in - [step 6](#step-6-write-tests). -7. The four documentation pages that enumerate the operations, listed in - [step 7](#step-7-document-it). - -Three places that look like they need an edit do not. The canonical grammar in -`src/qprogram/grammar/qp.lark` accepts any identifier as a statement keyword and -leaves the decision to the registries, so a new keyword parses under it -unchanged. The writer and the parser dispatch through the operation registry -rather than through a list of keywords. The editor integration in -`src/qprogram/lsp.py` parses and validates instead of carrying its own keyword -table. +6. The test files that cover the operation, listed in [step 6](#step-6-write-tests). +7. The four documentation pages that enumerate the operations, listed in [step 7](#step-7-document-it). + +Three places that look like they need an edit do not. The canonical grammar in `src/qprogram/grammar/qp.lark` accepts any identifier as a statement keyword and leaves the decision to the registries, so a new keyword parses under it unchanged. The writer and the parser dispatch through the operation registry rather than through a list of keywords. The editor integration in `src/qprogram/lsp.py` parses and validates instead of carrying its own keyword table. ## Step 1: Create the operation class @@ -77,15 +54,9 @@ class SetPower(Operation): return {"op.set_power"} | expression_tokens(self.power) ``` -An operation stores its constructor arguments on `self` under the parameter -names, and stores nothing else in public attributes. Both halves of -serialization read `inspect.signature(cls.__init__)`, so a public attribute that -is not a constructor parameter is never written to the file and does not survive -a reload. Give anything computed a leading underscore, or make it a property. +An operation stores its constructor arguments on `self` under the parameter names, and stores nothing else in public attributes. Both halves of serialization read `inspect.signature(cls.__init__)`, so a public attribute that is not a constructor parameter is never written to the file and does not survive a reload. Give anything computed a leading underscore, or make it a property. -Four class attributes tune the introspection the base class performs. Each has a -default that suits an op with one bus attribute and no waveform, which is why -`SetPower` declares none of them. +Four class attributes tune the introspection the base class performs. Each has a default that suits an op with one bus attribute and no waveform, which is why `SetPower` declares none of them. | Class attribute | Default | Set by | |---|---|---| @@ -94,32 +65,11 @@ default that suits an op with one bus attribute and no waveform, which is why | `BROADCASTS_WHEN_NO_BUS` | `False` | `Sync`, whose empty target list means every bus in the program | | `AFFECTS_AVERAGING` | `False` | `MeasurementOperation`, so `measure` and a vendor `acquire` opt in automatically | -From those lists the base class derives the four introspection methods, and -overriding them is rare. `buses()` reads `BUS_ATTRS` and collects plain strings, -`BusRef`s, and lists of either. `waveforms()` reads `WAVEFORM_ATTRS` and skips -`None`, which is what makes an optional waveform parameter work. `variables()` -ignores the attribute lists and walks every public attribute instead, descending -into `Expression` trees, waveform parameters, and nested lists and tuples, so a -symbolic parameter is reported wherever it is stored. `walk()` yields `self`, -since an operation is a leaf. - -Equality and hashing are structural, over `vars(self)` through `ast_eq` and -`ast_hash`, which is what lets two independently built `SetPower` instances -compare equal and lets an operation be a dictionary key. The cost is that an -instance must not be mutated after it has been hashed. `QProgram.rebind` -rewrites operations on a fresh `deepcopy` for that reason. - -`required_capabilities()` returns the tokens this instance needs, and only -those. The method is non-recursive: the validator walks the tree and unions the -per-node sets, so a node that recursed into its children would double-count. -An operation's own token is its identity (`op.set_power`), and everything else -is a refinement computed from instance state: `expression_tokens(value)` for a -numeric parameter, the channel kind and per-class token for a waveform -parameter, one `measure.fields.` per requested field for a measurement. -`expression_tokens` is imported inside the method rather than at module level, -which is what every core op does, so `qprogram.operations` carries no -import-time dependency on `qprogram.protocol`. -[Capability protocol internals](capability-protocol.md) has the full mechanics. +From those lists the base class derives the four introspection methods, and overriding them is rare. `buses()` reads `BUS_ATTRS` and collects plain strings, `BusRef`s, and lists of either. `waveforms()` reads `WAVEFORM_ATTRS` and skips `None`, which is what makes an optional waveform parameter work. `variables()` ignores the attribute lists and walks every public attribute instead, descending into `Expression` trees, waveform parameters, and nested lists and tuples, so a symbolic parameter is reported wherever it is stored. `walk()` yields `self`, since an operation is a leaf. + +Equality and hashing are structural, over `vars(self)` through `ast_eq` and `ast_hash`, which is what lets two independently built `SetPower` instances compare equal and lets an operation be a dictionary key. The cost is that an instance must not be mutated after it has been hashed. `QProgram.rebind` rewrites operations on a fresh `deepcopy` for that reason. + +`required_capabilities()` returns the tokens this instance needs, and only those. The method is non-recursive: the validator walks the tree and unions the per-node sets, so a node that recursed into its children would double-count. An operation's own token is its identity (`op.set_power`), and everything else is a refinement computed from instance state: `expression_tokens(value)` for a numeric parameter, the channel kind and per-class token for a waveform parameter, one `measure.fields.` per requested field for a measurement. `expression_tokens` is imported inside the method rather than at module level, which is what every core op does, so `qprogram.operations` carries no import-time dependency on `qprogram.protocol`. [Capability protocol internals](capability-protocol.md) has the full mechanics. ## Step 2: Export from `operations/__init__.py` @@ -133,11 +83,7 @@ __all__ = [ ] ``` -Both the import block and `__all__` are alphabetical. This is the list -`qp.operations.SetPower` resolves through, and it is not everything the -subpackage defines: `MeasurementOperation` stays out, and the API reference -documents it under its full path, -`qprogram.operations.operation.MeasurementOperation`. +Both the import block and `__all__` are alphabetical. This is the list `qp.operations.SetPower` resolves through, and it is not everything the subpackage defines: `MeasurementOperation` stays out, and the API reference documents it under its full path, `qprogram.operations.operation.MeasurementOperation`. ## Step 3: Add a method on `QProgram` @@ -162,30 +108,17 @@ class QProgram: self._append_to_active(SetPower(bus=bus, power=power)) ``` -Put the method in the `--- Core operations ---` section of the file, next to the -other bus-scoped setters; the API reference lists members in source order. +Put the method in the `--- Core operations ---` section of the file, next to the other bus-scoped setters; the API reference lists members in source order. -`_validate_bus` rejects a `BusRef` produced by a different `BusSchema` than the -one attached to the program, and adopts the ref's schema when the program has -none yet. Plain strings and refs without schema metadata pass through. Call it -once per bus attribute, the way `sync` does for each entry in its target list. -An operation that takes a waveform also calls the module-level -`_validate_waveform_channel(bus, waveform)`, which raises `ValidationError` when -a single-channel waveform lands on an IQ bus or the reverse. +`_validate_bus` rejects a `BusRef` produced by a different `BusSchema` than the one attached to the program, and adopts the ref's schema when the program has none yet. Plain strings and refs without schema metadata pass through. Call it once per bus attribute, the way `sync` does for each entry in its target list. An operation that takes a waveform also calls the module-level `_validate_waveform_channel(bus, waveform)`, which raises `ValidationError` when a single-channel waveform lands on an IQ bus or the reverse. -`_append_to_active` appends to the innermost block still open on the block -stack. It also closes a pending `if_` chain when the append lands at the chain's -own level, because anything other than `elif_` or `else_` there makes the chain -ambiguous; appends inside an arm body sit at a deeper level and leave the chain -open. +`_append_to_active` appends to the innermost block still open on the block stack. It also closes a pending `if_` chain when the append lands at the chain's own level, because anything other than `elif_` or `else_` there makes the chain ambiguous; appends inside an arm body sit at a deeper level and leave the chain open. -The method name is not what names the operation on the wire. The registration in -step 4 does that, and matching the two is a convention every core op follows. +The method name is not what names the operation on the wire. The registration in step 4 does that, and matching the two is a convention every core op follows. ## Step 4: Register it with the serializer -Most operations need no writer or parser code. Add one line to -`_register_core_specs()`: +Most operations need no writer or parser code. Add one line to `_register_core_specs()`: ```python # src/qprogram/serialization/_specs.py @@ -196,27 +129,11 @@ def _register_core_specs() -> None: register_operation("set_power", SetPower) ``` -The first argument is the keyword as it appears in a `.qp` file. Re-registering -the same class under the same name refreshes its callbacks and is allowed, since -import-time registration modules can run twice; registering a *different* class -under a taken name raises `ValueError` rather than changing how every existing -file parses that keyword. - -`default_serialize_operation` walks the constructor parameters after `self`. -Parameters with no default are emitted positionally in declaration order, and -parameters with a default are emitted as `name=value` only when the stored value -differs from that default. A parameter with no matching attribute is skipped, so -`__init__` may accept a keyword it does not store. The result is -`set_power "drive_q0" 5.0`, and `set_power "drive_q0" pw` when the power is a -swept variable. - -`default_parse_operation` inverts it. A token counts as a keyword argument when -it contains an `=` that is not inside leading quotes and has no `(` before it, -which is what keeps `Gaussian(amplitude=0.5)` and `"key=value"` positional. The -remaining tokens bind by index to the constructor parameters, and the operation -is then constructed entirely from keywords, so positional order cannot drift. -Two failures get their own messages, each prefixed by the parser with -`Line :`: +The first argument is the keyword as it appears in a `.qp` file. Re-registering the same class under the same name refreshes its callbacks and is allowed, since import-time registration modules can run twice; registering a *different* class under a taken name raises `ValueError` rather than changing how every existing file parses that keyword. + +`default_serialize_operation` walks the constructor parameters after `self`. Parameters with no default are emitted positionally in declaration order, and parameters with a default are emitted as `name=value` only when the stored value differs from that default. A parameter with no matching attribute is skipped, so `__init__` may accept a keyword it does not store. The result is `set_power "drive_q0" 5.0`, and `set_power "drive_q0" pw` when the power is a swept variable. + +`default_parse_operation` inverts it. A token counts as a keyword argument when it contains an `=` that is not inside leading quotes and has no `(` before it, which is what keeps `Gaussian(amplitude=0.5)` and `"key=value"` positional. The remaining tokens bind by index to the constructor parameters, and the operation is then constructed entirely from keywords, so positional order cannot drift. Two failures get their own messages, each prefixed by the parser with `Line :`: ``` set_power "drive_q0" 5.0 7.0 @@ -229,41 +146,22 @@ set_power "drive_q0" bogus=1 # SetPower.__init__() got an unexpected keyword argument 'bogus' ``` -Excess positional tokens are an error rather than a truncation, because dropping -them would load a different program than the file describes. A `ValidationError` -raised by the constructor itself is passed through under the same line tag, -which is how an unknown measurement field in a `fields=[...]` list reports its -own message with a line number attached. +Excess positional tokens are an error rather than a truncation, because dropping them would load a different program than the file describes. A `ValidationError` raised by the constructor itself is passed through under the same line tag, which is how an unknown measurement field in a `fields=[...]` list reports its own message with a line number attached. ### When a custom callback is needed -Three core operations do not fit "keyword, then positional arguments, then -keyword arguments", and each shows what a callback is for. All three live in -`_specs.py`: - -- `sync` has a variadic bus list rather than a fixed parameter list, so - `sync_serialize` writes `sync` or `sync ...` and `sync_parse` reads - every token as a bus. -- `get_parameter` writes its result variable after a `->` arrow, so - `get_parameter_serialize` places the identifier itself and - `get_parameter_parse` splits the token list on the arrow. -- `measure` carries a `MeasurementHandle` that the file names rather than - spells, so `measurement_op_serialize` skips the `handle` parameter and emits - `name="..."`, and `make_measurement_op_parse(cls)` resolves that name back to - the canonical handle instance through `ctx.get_or_create_handle`, which is - what makes every reference to one measurement the same Python object after a - load. +Three core operations do not fit "keyword, then positional arguments, then keyword arguments", and each shows what a callback is for. All three live in `_specs.py`: + +- `sync` has a variadic bus list rather than a fixed parameter list, so `sync_serialize` writes `sync` or `sync ...` and `sync_parse` reads every token as a bus. +- `get_parameter` writes its result variable after a `->` arrow, so `get_parameter_serialize` places the identifier itself and `get_parameter_parse` splits the token list on the arrow. +- `measure` carries a `MeasurementHandle` that the file names rather than spells, so `measurement_op_serialize` skips the `handle` parameter and emits `name="..."`, and `make_measurement_op_parse(cls)` resolves that name back to the canonical handle instance through `ctx.get_or_create_handle`, which is what makes every reference to one measurement the same Python object after a load. ```python # src/qprogram/serialization/_specs.py register_operation("sync", Sync, serialize=sync_serialize, parse=sync_parse) ``` -One core operation is registered nowhere. `call` is written as -`()` rather than as a keyword-led statement, so the writer -and the parser handle it directly. An operation whose statement shape differs -that much from the others needs writer and parser code of its own rather than a -spec callback. +One core operation is registered nowhere. `call` is written as `()` rather than as a keyword-led statement, so the writer and the parser handle it directly. An operation whose statement shape differs that much from the others needs writer and parser code of its own rather than a spec callback. ## Step 5: Register the capability token @@ -276,34 +174,15 @@ _BASE_TOKENS: frozenset[str] = frozenset( ) ``` -`CAPABILITY_REGISTRY` is seeded from `_BASE_TOKENS` at import time, and -`Profile.__post_init__` validates every token a profile lists against it. A -token that is not registered therefore fails at profile construction, which for -a vendor package means at import: +`CAPABILITY_REGISTRY` is seeded from `_BASE_TOKENS` at import time, and `Profile.__post_init__` validates every token a profile lists against it. A token that is not registered therefore fails at profile construction, which for a vendor package means at import: ``` ValueError: Unknown capability token(s): ['op.set_power']. Register via qprogram.protocol.register_capability_tokens before use. ``` -Registering the token makes it spellable. Advertising it is a separate act, and -where it belongs follows from how the validator routes the node. An op whose -`BUS_ATTRS` resolve to one or more bus names is checked against -`caps.for_bus(bus)` for each of them, and the results are intersected; an op with -`BUS_ATTRS = ()` is checked against the platform slot; a broadcast op whose bus -list comes out empty is checked against every bus in the program. `SetPower` -holds one bus, so `op.set_power` belongs in a bus profile, alongside the other -`op.*` tokens. `QPROGRAM_BASE_V1`, the platform-level profile core ships, carries -only block, expression, and sweep tokens for that reason. -`tests/_dummy_vendor.py` shows the other side: its `_CORE_OPS` frozenset is the -set of core operations the dummy backend advertises per bus, unioned into the -`dummy-default-v1` profile. - -`reference_capabilities()` grants every token in the live `CAPABILITY_REGISTRY`, -with `set_parameter` and `get_parameter` present only in each bus slot's `host` -half, so a new core operation runs on `qp.ReferencePlatform` as soon as its -token is registered, without touching the executor. A platform that has not -advertised it rejects the program with a `missing-capability` diagnostic naming -the profile and the domains it checked: +Registering the token makes it spellable. Advertising it is a separate act, and where it belongs follows from how the validator routes the node. An op whose `BUS_ATTRS` resolve to one or more bus names is checked against `caps.for_bus(bus)` for each of them, and the results are intersected; an op with `BUS_ATTRS = ()` is checked against the platform slot; a broadcast op whose bus list comes out empty is checked against every bus in the program. `SetPower` holds one bus, so `op.set_power` belongs in a bus profile, alongside the other `op.*` tokens. `QPROGRAM_BASE_V1`, the platform-level profile core ships, carries only block, expression, and sweep tokens for that reason. `tests/_dummy_vendor.py` shows the other side: its `_CORE_OPS` frozenset is the set of core operations the dummy backend advertises per bus, unioned into the `dummy-default-v1` profile. + +`reference_capabilities()` grants every token in the live `CAPABILITY_REGISTRY`, with `set_parameter` and `get_parameter` present only in each bus slot's `host` half, so a new core operation runs on `qp.ReferencePlatform` as soon as its token is registered, without touching the executor. A platform that has not advertised it rejects the program with a `missing-capability` diagnostic naming the profile and the domains it checked: ``` [error] missing-capability: 'SetPower' requires capability 'op.set_power' which is not supported by 'dummy-default-v1' (rt) / 'dummy-default-v1' (host) (at body[0]) @@ -311,11 +190,9 @@ the profile and the domains it checked: ## Step 6: Write tests -Tests go next to the ones for the operation the new one most resembles. For -`SetPower`, that is `set_phase`, and these are the files its tests live in. +Tests go next to the ones for the operation the new one most resembles. For `SetPower`, that is `set_phase`, and these are the files its tests live in. -`tests/test_operations.py` covers the class in isolation, in the shape of -`test_set_phase_construction` and `test_set_phase_variables`: +`tests/test_operations.py` covers the class in isolation, in the shape of `test_set_phase_construction` and `test_set_phase_variables`: ```python def test_set_power_construction(): @@ -330,9 +207,7 @@ def test_set_power_variables(): assert SetPower("bus", v).variables() == {v} ``` -`tests/test_required_capabilities.py` pins the token set, in the shape of -`test_set_phase_picks_up_expr_tokens`. Test both the constant and the symbolic -argument: the refinement tokens are the part that is easy to get wrong. +`tests/test_required_capabilities.py` pins the token set, in the shape of `test_set_phase_picks_up_expr_tokens`. Test both the constant and the symbolic argument: the refinement tokens are the part that is easy to get wrong. ```python def test_set_power_token(): @@ -347,8 +222,7 @@ def test_set_power_picks_up_expr_tokens(): } ``` -`tests/test_qprogram.py` covers the builder method, using the `empty_program` -fixture from `tests/conftest.py`, in the shape of `test_set_phase_appends`: +`tests/test_qprogram.py` covers the builder method, using the `empty_program` fixture from `tests/conftest.py`, in the shape of `test_set_phase_appends`: ```python def test_set_power_appends(empty_program): @@ -356,60 +230,29 @@ def test_set_power_appends(empty_program): assert isinstance(empty_program.body.elements[0], SetPower) ``` -`tests/test_round_trip.py` covers serialization. The existing -`test_round_trip_all_core_operations` builds one program holding every core -operation and calls the module's `_assert_byte_stable` helper, which asserts -that `dumps` after `loads` after `dumps` is identical text; adding one line to -it is usually enough. `tests/test_writer.py` is where a test goes when the -emitted text itself is the point, as `test_dumps_set_phase_int` asserts that an -integer argument is not promoted to a float. - -`tests/test_specs.py` covers the signature-driven callbacks rather than any one -operation, so it needs a new test only for an operation with an unusual -signature. `test_default_parse_operation_positional`, -`test_default_parse_operation_kwarg`, -`test_default_parse_operation_extra_positional_raises`, and -`test_default_parse_operation_unknown_kwarg_raises` already cover the four -paths through the defaults. - -`tests/test_round_trip_property.py` builds random programs with hypothesis and -asserts byte stability. Its `emit_ops` helper draws from a `sampled_from` list of -operation names and dispatches on the result, so an operation joins the property -tests by adding its name to that list and a branch that calls the builder. -Worth doing for anything with more than one interesting argument shape. See -[Testing](testing.md) for how the suite is organized. +`tests/test_round_trip.py` covers serialization. The existing `test_round_trip_all_core_operations` builds one program holding every core operation and calls the module's `_assert_byte_stable` helper, which asserts that `dumps` after `loads` after `dumps` is identical text; adding one line to it is usually enough. `tests/test_writer.py` is where a test goes when the emitted text itself is the point, as `test_dumps_set_phase_int` asserts that an integer argument is not promoted to a float. + +`tests/test_specs.py` covers the signature-driven callbacks rather than any one operation, so it needs a new test only for an operation with an unusual signature. `test_default_parse_operation_positional`, `test_default_parse_operation_kwarg`, `test_default_parse_operation_extra_positional_raises`, and `test_default_parse_operation_unknown_kwarg_raises` already cover the four paths through the defaults. + +`tests/test_round_trip_property.py` builds random programs with hypothesis and asserts byte stability. Its `emit_ops` helper draws from a `sampled_from` list of operation names and dispatches on the result, so an operation joins the property tests by adding its name to that list and a branch that calls the builder. Worth doing for anything with more than one interesting argument shape. See [Testing](testing.md) for how the suite is organized. ## Step 7: Document it Four pages enumerate the operations, and all four go stale otherwise. -[`docs/guide/operations.md`](../guide/operations.md) has the "Every core -operation" table, which gives the builder call, the `.qp` statement, and the -capability tokens, plus a short subsection per operation with an example. +[`docs/guide/operations.md`](../guide/operations.md) has the "Every core operation" table, which gives the builder call, the `.qp` statement, and the capability tokens, plus a short subsection per operation with an example. -[`docs/reference/qp-format.md`](../reference/qp-format.md) has the wire syntax -under "Operations". The prose there counts the core keywords, so the count moves -with the table. +[`docs/reference/qp-format.md`](../reference/qp-format.md) has the wire syntax under "Operations". The prose there counts the core keywords, so the count moves with the table. -[`docs/reference/api-qprogram.md`](../reference/api-qprogram.md) is generated -from docstrings, but its member lists are explicit: add the builder method to -the `members:` list under `::: qprogram.QProgram` and the class to the one under -`::: qprogram.operations`. A symbol absent from those lists does not appear on -the page at all. +[`docs/reference/api-qprogram.md`](../reference/api-qprogram.md) is generated from docstrings, but its member lists are explicit: add the builder method to the `members:` list under `::: qprogram.QProgram` and the class to the one under `::: qprogram.operations`. A symbol absent from those lists does not appear on the page at all. -[`docs/guide/capabilities.md`](../guide/capabilities.md) lists the `op.*` tokens -in its token-prefix table. +[`docs/guide/capabilities.md`](../guide/capabilities.md) lists the `op.*` tokens in its token-prefix table. -If the operation interacts with control flow, measurements, or sweeps in a way -that is not obvious from its signature, the matching guide page needs a -paragraph too. `grep -rn set_phase docs/` finds every page that enumerates the -operations. +If the operation interacts with control flow, measurements, or sweeps in a way that is not obvious from its signature, the matching guide page needs a paragraph too. `grep -rn set_phase docs/` finds every page that enumerates the operations. ## Where `set_phase` appears -The closest thing to a checklist is an existing operation. `set_phase` is one of -the plainest, and every row below is a place an operation shaped like it needs an -entry. +The closest thing to a checklist is an existing operation. `set_phase` is one of the plainest, and every row below is a place an operation shaped like it needs an entry. | File | What it holds | |---|---| diff --git a/docs/developer/adding-waveforms.md b/docs/developer/adding-waveforms.md index 45e4c0c..077e51f 100644 --- a/docs/developer/adding-waveforms.md +++ b/docs/developer/adding-waveforms.md @@ -1,27 +1,12 @@ # Adding a waveform -There are two scenarios, and they share everything except where the registration -call lives. A built-in waveform is a module inside `src/qprogram/waveforms/`, -listed in the package's own registries, and can be advertised by a capability -token that core ships. A user waveform is a class in your own code or a -downstream package, registered at import time through the public API. Both -subclass the same two bases and are written to `.qp` files by the same code. - -Snippets whose first line is a `# src/qprogram/...` path comment are package -source, and they keep their intra-package imports (`from -qprogram.waveforms.waveform import Waveform`): inside the package, `import -qprogram` would close an import cycle. The snippets without that comment are -user code, and follow the convention the rest of the documentation uses, a -single `import qprogram as qp` with everything else reached through `qp.`. The -snippets in [Testing a new waveform](#testing-a-new-waveform) are a third case: -they are bodies lifted from files under `tests/`, which import the names they -use directly (`from qprogram import QProgram`), so nothing in them is prefixed -either. +There are two scenarios, and they share everything except where the registration call lives. A built-in waveform is a module inside `src/qprogram/waveforms/`, listed in the package's own registries, and can be advertised by a capability token that core ships. A user waveform is a class in your own code or a downstream package, registered at import time through the public API. Both subclass the same two bases and are written to `.qp` files by the same code. + +Snippets whose first line is a `# src/qprogram/...` path comment are package source, and they keep their intra-package imports (`from qprogram.waveforms.waveform import Waveform`): inside the package, `import qprogram` would close an import cycle. The snippets without that comment are user code, and follow the convention the rest of the documentation uses, a single `import qprogram as qp` with everything else reached through `qp.`. The snippets in [Testing a new waveform](#testing-a-new-waveform) are a third case: they are bodies lifted from files under `tests/`, which import the names they use directly (`from qprogram import QProgram`), so nothing in them is prefixed either. ## What a waveform must provide -A single-channel shape subclasses `Waveform` and implements two abstract -methods: +A single-channel shape subclasses `Waveform` and implements two abstract methods: ```python # src/qprogram/waveforms/half_sine.py @@ -66,36 +51,13 @@ class HalfSine(Waveform): return self.duration ``` -`envelope(resolution)` returns `duration / resolution` samples, with -`resolution` in nanoseconds and `1` meaning one sample per nanosecond. Shape -parameters that carry a time (a `sigma`, a rise time) are converted to samples -inside `envelope`, so the shape stays the same at every resolution; `Gaussian` -divides `sigma` by `resolution` for exactly that reason. The array dtype follows -the parameters, so an integer amplitude produces an integer array, which -`Square` documents on `envelope`. `Arbitrary` is the one built-in that ignores -`resolution`: its samples are the envelope already, one per nanosecond. - -`Waveform` implements the rest of its surface in terms of those two, so a -subclass inherits all of it and writes none of it. `peak_amplitude()` is -`max(|envelope|)`, `rms_amplitude()` the root mean square of the samples, -`area()` the trapezoidal integral in nanosecond-amplitude units -(`np.trapezoid(env, dx=resolution)`), and `spectrum()` a one-sided `np.fft.rfft` -paired with frequencies in Hz. `plot()` describes the envelope as a -`qp.plotting.Figure` and hands it to a renderer, and the Jupyter `_repr_html_` -draws it once per surface; both reach matplotlib by default, which ships in the -`viz` extra and is imported the first time something draws with it, so the -package stays importable without it. - -Nothing in core calls `envelope()`. Validation and serialization work on the -constructor arguments alone, so samples are rendered only when someone asks for -them: the analysis helpers above, a plot, or a platform compiler lowering the -program. A shape whose `envelope` is expensive costs nothing until then. - -Equality and hashing come from `_StructuralEqMixin`, which compares `vars(self)` -through `ast_eq`, so `HalfSine(0.5, 100) == HalfSine(0.5, 100)` holds and a -waveform can be a dictionary key. The mixin exists because symbolic parameters -and numpy arrays do not compose under Python's default identity equality. A -waveform is a value: once it has been hashed, do not mutate its attributes. +`envelope(resolution)` returns `duration / resolution` samples, with `resolution` in nanoseconds and `1` meaning one sample per nanosecond. Shape parameters that carry a time (a `sigma`, a rise time) are converted to samples inside `envelope`, so the shape stays the same at every resolution; `Gaussian` divides `sigma` by `resolution` for exactly that reason. The array dtype follows the parameters, so an integer amplitude produces an integer array, which `Square` documents on `envelope`. `Arbitrary` is the one built-in that ignores `resolution`: its samples are the envelope already, one per nanosecond. + +`Waveform` implements the rest of its surface in terms of those two, so a subclass inherits all of it and writes none of it. `peak_amplitude()` is `max(|envelope|)`, `rms_amplitude()` the root mean square of the samples, `area()` the trapezoidal integral in nanosecond-amplitude units (`np.trapezoid(env, dx=resolution)`), and `spectrum()` a one-sided `np.fft.rfft` paired with frequencies in Hz. `plot()` describes the envelope as a `qp.plotting.Figure` and hands it to a renderer, and the Jupyter `_repr_html_` draws it once per surface; both reach matplotlib by default, which ships in the `viz` extra and is imported the first time something draws with it, so the package stays importable without it. + +Nothing in core calls `envelope()`. Validation and serialization work on the constructor arguments alone, so samples are rendered only when someone asks for them: the analysis helpers above, a plot, or a platform compiler lowering the program. A shape whose `envelope` is expensive costs nothing until then. + +Equality and hashing come from `_StructuralEqMixin`, which compares `vars(self)` through `ast_eq`, so `HalfSine(0.5, 100) == HalfSine(0.5, 100)` holds and a waveform can be a dictionary key. The mixin exists because symbolic parameters and numpy arrays do not compose under Python's default identity equality. A waveform is a value: once it has been hashed, do not mutate its attributes. An IQ shape subclasses `IQWaveform` and supplies its two channels: @@ -133,42 +95,22 @@ class HalfSineIQ(IQWaveform): return self.duration ``` -The analysis helpers on `IQWaveform` work on the complex envelope `I + jQ`, and -its `spectrum()` is a two-sided `np.fft.fft` with the zero frequency shifted to -the middle, because a complex envelope carries information at negative -frequencies that a one-sided transform would fold away. - -Equal channel durations are an invariant each class enforces for itself, not a -base-class check. `IQPair`, which takes its two channels as arguments, compares -them in `__init__` and raises `ValidationError: IQPair channels must have equal -durations; got I=10 ns, Q=20 ns`, deferring the check when a duration is still -symbolic. A shape that builds its own channels, as `HalfSineIQ` does, has no -such check to make. Get it wrong and the failure arrives from numpy the first -time the complex envelope is assembled: `ValueError: operands could not be -broadcast together with shapes (10,) (20,)`. - -Choose the base class deliberately, because two behaviors follow from it and -neither is configurable. `Play.required_capabilities()` reads -`isinstance(waveform, IQWaveform)` to decide between the `waveform.iq` and -`waveform.single` channel-kind tokens, and `QProgram.play` and `QProgram.measure` -check the same thing against the bus's declared channel: +The analysis helpers on `IQWaveform` work on the complex envelope `I + jQ`, and its `spectrum()` is a two-sided `np.fft.fft` with the zero frequency shifted to the middle, because a complex envelope carries information at negative frequencies that a one-sided transform would fold away. + +Equal channel durations are an invariant each class enforces for itself, not a base-class check. `IQPair`, which takes its two channels as arguments, compares them in `__init__` and raises `ValidationError: IQPair channels must have equal durations; got I=10 ns, Q=20 ns`, deferring the check when a duration is still symbolic. A shape that builds its own channels, as `HalfSineIQ` does, has no such check to make. Get it wrong and the failure arrives from numpy the first time the complex envelope is assembled: `ValueError: operands could not be broadcast together with shapes (10,) (20,)`. + +Choose the base class deliberately, because two behaviors follow from it and neither is configurable. `Play.required_capabilities()` reads `isinstance(waveform, IQWaveform)` to decide between the `waveform.iq` and `waveform.single` channel-kind tokens, and `QProgram.play` and `QProgram.measure` check the same thing against the bus's declared channel: ``` ValidationError: Bus 'q0/drive' is an IQ channel but received a single-channel Waveform (Square). Use an IQWaveform (e.g. IQPair, IQDrag) instead. ``` -`register_waveform` does not enforce the hierarchy, so a class outside it -registers and serializes. What it loses is both of the checks above plus -variable collection: `Operation.variables()` descends into an attribute only -when it is an `Expression`, a `Waveform`, an `IQWaveform`, or a list of those, so -a swept parameter held by a class outside the hierarchy is invisible to the -program that contains it. +`register_waveform` does not enforce the hierarchy, so a class outside it registers and serializes. What it loses is both of the checks above plus variable collection: `Operation.variables()` descends into an attribute only when it is an `Expression`, a `Waveform`, an `IQWaveform`, or a list of those, so a swept parameter held by a class outside the hierarchy is invisible to the program that contains it. ## Variable-aware parameters -A parameter meant to be swept is annotated `float | Expression` (or -`int | Expression`) and resolved with `evaluate_or_raise()` at the point of use: +A parameter meant to be swept is annotated `float | Expression` (or `int | Expression`) and resolved with `evaluate_or_raise()` at the point of use: ```python # src/qprogram/waveforms/half_sine.py @@ -197,26 +139,13 @@ class HalfSine(Waveform): return int(duration) ``` -Every built-in is written this way. The explicit `isinstance` guard keeps the -boundary between symbolic and concrete visible at each use, and -`evaluate_or_raise()` turns an unbound variable into a message that names it, -`UnassignedVariableError: Cannot evaluate expression Variable('amp'): -unassigned variable(s) {Variable('amp')}`, rather than a numpy failure further -down. `get_duration()` has to do the same resolution, because it is called -independently of `envelope()`: `IQPair` uses it to compare channels, and -`Chained` sums it across children. +Every built-in is written this way. The explicit `isinstance` guard keeps the boundary between symbolic and concrete visible at each use, and `evaluate_or_raise()` turns an unbound variable into a message that names it, `UnassignedVariableError: Cannot evaluate expression Variable('amp'): unassigned variable(s) {Variable('amp')}`, rather than a numpy failure further down. `get_duration()` has to do the same resolution, because it is called independently of `envelope()`: `IQPair` uses it to compare channels, and `Chained` sums it across children. -Only parameters annotated with `Expression` accept one. A parameter that must be -a plain number, such as `Arbitrary.samples`, keeps its concrete annotation, and -`qprogram.waveforms`'s module docstring states that rule for readers of the API -reference. +Only parameters annotated with `Expression` accept one. A parameter that must be a plain number, such as `Arbitrary.samples`, keeps its concrete annotation, and `qprogram.waveforms`'s module docstring states that rule for readers of the API reference. ## Registering a built-in -A built-in shape is named in three registries inside the package, exported from -the waveform subpackage, and listed in the API reference. Start with the export, -in `src/qprogram/waveforms/__init__.py`, whose import block and `__all__` are -both alphabetical: +A built-in shape is named in three registries inside the package, exported from the waveform subpackage, and listed in the API reference. Start with the export, in `src/qprogram/waveforms/__init__.py`, whose import block and `__all__` are both alphabetical: ```python # src/qprogram/waveforms/__init__.py @@ -225,10 +154,7 @@ from qprogram.waveforms.half_sine import HalfSine __all__ = [..., "HalfSine", ...] ``` -Then the serialization registry, in `_register_builtin_waveforms()` in -`src/qprogram/serialization/registry.py`. The import sits inside the function to -break the cycle with `qprogram.waveforms`, and the loop keys each class by its -own `__name__`: +Then the serialization registry, in `_register_builtin_waveforms()` in `src/qprogram/serialization/registry.py`. The import sits inside the function to break the cycle with `qprogram.waveforms`, and the loop keys each class by its own `__name__`: ```python # src/qprogram/serialization/registry.py @@ -239,14 +165,9 @@ def _register_builtin_waveforms() -> None: _waveform_registry[cls.__name__] = cls ``` -Keying by `__name__` makes the class name the constructor name on the wire, so -renaming the class is a format change. The capability token is separate and need -not match: `SuddenNetZero` is spelled that way in a `.qp` file and carries the -token `waveform.snz`. +Keying by `__name__` makes the class name the constructor name on the wire, so renaming the class is a format change. The capability token is separate and need not match: `SuddenNetZero` is spelled that way in a `.qp` file and carries the token `waveform.snz`. -Then the capability side, in `src/qprogram/protocol.py`: the token string joins -the other `waveform.*` entries in `_BASE_TOKENS`, and the class-to-token mapping -joins `_register_builtin_waveform_tokens()`. +Then the capability side, in `src/qprogram/protocol.py`: the token string joins the other `waveform.*` entries in `_BASE_TOKENS`, and the class-to-token mapping joins `_register_builtin_waveform_tokens()`. ```python # src/qprogram/protocol.py @@ -257,16 +178,11 @@ WAVEFORM_TOKEN.update( ) ``` -`WAVEFORM_TOKEN` is populated lazily, on the first call to `waveform_token()`, -because `qprogram.protocol` cannot import `qprogram.waveforms` at module level. +`WAVEFORM_TOKEN` is populated lazily, on the first call to `waveform_token()`, because `qprogram.protocol` cannot import `qprogram.waveforms` at module level. -Last, add the class to the `members:` list under `::: qprogram.waveforms` in -[`docs/reference/api-qprogram.md`](../reference/api-qprogram.md). That list is -explicit, so a class missing from it does not appear in the API reference at all, -however complete its docstring. +Last, add the class to the `members:` list under `::: qprogram.waveforms` in [`docs/reference/api-qprogram.md`](../reference/api-qprogram.md). That list is explicit, so a class missing from it does not appear in the API reference at all, however complete its docstring. -After those edits, `HalfSine(0.5, 100)` writes as -`HalfSine(amplitude=0.5, duration=100)` and reads back to an equal instance. +After those edits, `HalfSine(0.5, 100)` writes as `HalfSine(amplitude=0.5, duration=100)` and reads back to an equal instance. ## Registering a user waveform @@ -295,29 +211,15 @@ class MyPulse(qp.waveforms.Waveform): qp.register_waveform_token(MyPulse, "waveform.my_pulse") ``` -`register_waveform` returns the class, which is what lets it be used as a -decorator, and registers it under `cls.__name__`. After the module is imported, -`MyPulse(0.5, 100, 3.14)` in a `.qp` file rebuilds an instance from the -constructor signature. Registering the same class again is a no-op; registering -a *different* class under a taken name raises `ValueError: waveform name -'MyPulse' is already registered to ...`, because it would change how every -existing file parses that constructor. - -`register_waveform_token(cls, token)` writes the class-to-token mapping and adds -the token to `CAPABILITY_REGISTRY` in one call, so a profile can list it without -a separate `register_capability_tokens`. Whether to pair the two calls is a real -choice. Without a token, the shape contributes only its channel kind, so every -profile that accepts `waveform.single` accepts it, including platforms that have -never heard of it. With a token, a platform that has not advertised the shape -refuses the program up front: +`register_waveform` returns the class, which is what lets it be used as a decorator, and registers it under `cls.__name__`. After the module is imported, `MyPulse(0.5, 100, 3.14)` in a `.qp` file rebuilds an instance from the constructor signature. Registering the same class again is a no-op; registering a *different* class under a taken name raises `ValueError: waveform name 'MyPulse' is already registered to ...`, because it would change how every existing file parses that constructor. + +`register_waveform_token(cls, token)` writes the class-to-token mapping and adds the token to `CAPABILITY_REGISTRY` in one call, so a profile can list it without a separate `register_capability_tokens`. Whether to pair the two calls is a real choice. Without a token, the shape contributes only its channel kind, so every profile that accepts `waveform.single` accepts it, including platforms that have never heard of it. With a token, a platform that has not advertised the shape refuses the program up front: ``` [error] missing-capability: 'Play' requires capability 'waveform.my_pulse' which is not supported by 'dummy-default-v1' (rt) / 'dummy-default-v1' (host) (at body[0]) ``` -A vendor extension puts both calls in the module its entry point loads, so -installing the package is what makes the shape parseable and advertisable. See -[Building a vendor extension](vendor-extensions.md). +A vendor extension puts both calls in the module its entry point loads, so installing the package is what makes the shape parseable and advertisable. See [Building a vendor extension](vendor-extensions.md). ## How the writer emits a waveform @@ -325,27 +227,16 @@ installing the package is what makes the shape parseable and advertisable. See Gaussian(amplitude=0.5, duration=40, sigma=8) ``` -`_Writer.serialize_waveform` takes the class name verbatim, since it is the -registry key, then walks `vars(wf)` in assignment order, skips any name starting -with `_`, and emits every remaining attribute as `key=value`, recursing through -`serialize_value` for expression nodes and nested waveforms. Sample arrays are -written in full: truncating an `Arbitrary` would leave the parser with no way to -recover the dropped samples. +`_Writer.serialize_waveform` takes the class name verbatim, since it is the registry key, then walks `vars(wf)` in assignment order, skips any name starting with `_`, and emits every remaining attribute as `key=value`, recursing through `serialize_value` for expression nodes and nested waveforms. Sample arrays are written in full: truncating an `Arbitrary` would leave the parser with no way to recover the dropped samples. -Three consequences follow. Constructor parameters are recovered from attributes, -so each one has to be stored on `self` under the same name. Defaults are written -explicitly rather than omitted, which keeps a file's meaning stable if a default -later changes. And a public attribute that is *not* a constructor parameter is -emitted too, which makes the file unloadable: +Three consequences follow. Constructor parameters are recovered from attributes, so each one has to be stored on `self` under the same name. Defaults are written explicitly rather than omitted, which keeps a file's meaning stable if a default later changes. And a public attribute that is *not* a constructor parameter is emitted too, which makes the file unloadable: ``` play "d" Leaky(amplitude=0.5, duration=20, n_samples=20) # TypeError: Leaky.__init__() got an unexpected keyword argument 'n_samples' ``` -Prefix computed attributes with `_` to keep them out of the file. Operations -behave differently here: they serialize from the constructor signature, so a -leftover public attribute is dropped silently rather than written out. +Prefix computed attributes with `_` to keep them out of the file. Operations behave differently here: they serialize from the constructor signature, so a leftover public attribute is dropped silently rather than written out. ## How the parser reads a waveform @@ -353,45 +244,21 @@ leftover public attribute is dropped silently rather than written out. play "drive_q0" Gaussian(amplitude=0.5, duration=40, sigma=8) ``` -Reading that line, the parser hands the constructor call to -`_parse_waveform_expr`. The class name is split off and looked up in the -waveform registry, falling back to the sweep-source registry, which shares the -`Name(args)` shape and the same key-by-class-name design. The argument list is -then split on top-level commas, -respecting quotes and every bracket kind, and each argument is decoded to a -number, a quoted string, a `Variable` reference (when the identifier is -declared), an `Expression` subtree, or a nested waveform. Finally the class is -called: `cls(**kwargs)` when any argument was named, `cls(*args)` otherwise. - -Positional and keyword arguments are never combined. The writer spells every -waveform argument as a keyword, so a written file always takes the keyword path; -a hand-written call that mixes the two loses its positional values. - -Only the lookup failure is wrapped. A name registered as neither a waveform nor -a sweep source raises `ParseError: Unknown waveform or sweep source type: -`. The construction itself is a bare call, so an argument list the -constructor rejects surfaces as the constructor's own `TypeError`, unwrapped and -with no line number. Loading +Reading that line, the parser hands the constructor call to `_parse_waveform_expr`. The class name is split off and looked up in the waveform registry, falling back to the sweep-source registry, which shares the `Name(args)` shape and the same key-by-class-name design. The argument list is then split on top-level commas, respecting quotes and every bracket kind, and each argument is decoded to a number, a quoted string, a `Variable` reference (when the identifier is declared), an `Expression` subtree, or a nested waveform. Finally the class is called: `cls(**kwargs)` when any argument was named, `cls(*args)` otherwise. + +Positional and keyword arguments are never combined. The writer spells every waveform argument as a keyword, so a written file always takes the keyword path; a hand-written call that mixes the two loses its positional values. + +Only the lookup failure is wrapped. A name registered as neither a waveform nor a sweep source raises `ParseError: Unknown waveform or sweep source type: `. The construction itself is a bare call, so an argument list the constructor rejects surfaces as the constructor's own `TypeError`, unwrapped and with no line number. Loading ``` play "drive_q0" Gaussian(amplitude=0.5, duration=40, bogus=8) ``` -raises `TypeError: Gaussian.__init__() got an unexpected keyword argument -'bogus'` rather than a `ParseError`. Read that as a signature mismatch and check -the constructor; the usual cure is accepting both literal numbers and -`Expression`s where the parameter is meant to be swept. +raises `TypeError: Gaussian.__init__() got an unexpected keyword argument 'bogus'` rather than a `ParseError`. Read that as a signature mismatch and check the constructor; the usual cure is accepting both literal numbers and `Expression`s where the parameter is meant to be swept. ## Testing a new waveform -`tests/test_waveforms.py` is organized by shape, with a comment banner per -class. The shapes that are worth pinning are the ones the existing tests pin: -the sample count and the values at the interesting points -(`test_square_envelope`, `test_gaussian_peak_at_center`), the duration -(`test_square_get_duration`), the behavior at a resolution other than 1 -(`test_square_resolution_changes_envelope_length`), a symbolic parameter with a -value bound (`test_square_with_expression_amplitude`), and the same parameter -left unbound (`test_square_with_unassigned_expression_raises`). +`tests/test_waveforms.py` is organized by shape, with a comment banner per class. The shapes that are worth pinning are the ones the existing tests pin: the sample count and the values at the interesting points (`test_square_envelope`, `test_gaussian_peak_at_center`), the duration (`test_square_get_duration`), the behavior at a resolution other than 1 (`test_square_resolution_changes_envelope_length`), a symbolic parameter with a value bound (`test_square_with_expression_amplitude`), and the same parameter left unbound (`test_square_with_unassigned_expression_raises`). ```python def test_half_sine_envelope(): @@ -402,17 +269,9 @@ def test_half_sine_envelope(): assert env[50] == pytest.approx(1.0, abs=0.05) ``` -`tests/test_registry.py` covers registration: `test_waveform_builtins_registered` -asserts that `get_waveform_class("Square")` resolves, and -`test_register_waveform_decorator` and -`test_register_waveform_rejects_different_class_under_taken_name` cover the -user-facing decorator. `tests/test_protocol.py` covers the token side, through -`test_waveform_token_returns_canonical_token_for_known_classes` and -`test_register_waveform_token_extends_registry_and_dispatch`. +`tests/test_registry.py` covers registration: `test_waveform_builtins_registered` asserts that `get_waveform_class("Square")` resolves, and `test_register_waveform_decorator` and `test_register_waveform_rejects_different_class_under_taken_name` cover the user-facing decorator. `tests/test_protocol.py` covers the token side, through `test_waveform_token_returns_canonical_token_for_known_classes` and `test_register_waveform_token_extends_registry_and_dispatch`. -`tests/test_round_trip.py` covers serialization with the module's -`_assert_byte_stable` helper, which asserts that `dumps` after `loads` after -`dumps` is identical text: +`tests/test_round_trip.py` covers serialization with the module's `_assert_byte_stable` helper, which asserts that `dumps` after `loads` after `dumps` is identical text: ```python def test_round_trip_half_sine(): @@ -421,35 +280,16 @@ def test_round_trip_half_sine(): _assert_byte_stable(p) ``` -`tests/test_round_trip_property.py` generates random programs with hypothesis. -Its `single_waveforms` strategy draws a shape name from a `sampled_from` list -and dispatches on it, and `iq_waveforms` picks between `IQPair` and `IQDrag` on a -boolean draw, so a new shape joins the property tests by adding a branch that -builds it from adversarial parameters. A shape with a stored array belongs there -in particular, since `single_waveforms` draws `Arbitrary` arrays -past any plausible truncation cutoff. `tests/conftest.py` holds the stock -waveform fixtures (`square_pulse`, `gaussian_pulse`, `iq_pulse`, -`iq_pair_pulse`) that the rest of the suite builds programs from. See -[Testing](testing.md). +`tests/test_round_trip_property.py` generates random programs with hypothesis. Its `single_waveforms` strategy draws a shape name from a `sampled_from` list and dispatches on it, and `iq_waveforms` picks between `IQPair` and `IQDrag` on a boolean draw, so a new shape joins the property tests by adding a branch that builds it from adversarial parameters. A shape with a stored array belongs there in particular, since `single_waveforms` draws `Arbitrary` arrays past any plausible truncation cutoff. `tests/conftest.py` holds the stock waveform fixtures (`square_pulse`, `gaussian_pulse`, `iq_pulse`, `iq_pair_pulse`) that the rest of the suite builds programs from. See [Testing](testing.md). ## Documenting a new waveform -The class docstring carries the description of the shape and an `Args:` entry -per parameter, and it is what the API reference renders, so that is where the -detail belongs. Four pages need an edit as well. +The class docstring carries the description of the shape and an `Args:` entry per parameter, and it is what the API reference renders, so that is where the detail belongs. Four pages need an edit as well. -[`docs/guide/waveforms.md`](../guide/waveforms.md) has a table of the -single-channel built-ins and one of the IQ built-ins, a section explaining what -the shape parameters mean, and a "Picking the right shape" table whose third -column names the property that decides between two candidates. +[`docs/guide/waveforms.md`](../guide/waveforms.md) has a table of the single-channel built-ins and one of the IQ built-ins, a section explaining what the shape parameters mean, and a "Picking the right shape" table whose third column names the property that decides between two candidates. -[`docs/reference/qp-format.md`](../reference/qp-format.md) has the constructor -table under "Inline waveform constructors", listing each class with its -parameters and their defaults. +[`docs/reference/qp-format.md`](../reference/qp-format.md) has the constructor table under "Inline waveform constructors", listing each class with its parameters and their defaults. -[`docs/reference/api-qprogram.md`](../reference/api-qprogram.md) needs the -`members:` entry described under -[registering a built-in](#registering-a-built-in). +[`docs/reference/api-qprogram.md`](../reference/api-qprogram.md) needs the `members:` entry described under [registering a built-in](#registering-a-built-in). -[`docs/guide/capabilities.md`](../guide/capabilities.md) lists the -`waveform.` tokens in its token-prefix table. +[`docs/guide/capabilities.md`](../guide/capabilities.md) lists the `waveform.` tokens in its token-prefix table. diff --git a/docs/developer/architecture.md b/docs/developer/architecture.md index 1969f28..18ee875 100644 --- a/docs/developer/architecture.md +++ b/docs/developer/architecture.md @@ -1,14 +1,6 @@ # Architecture -This page is for people changing QProgram rather than using it: what lives -where under `src/qprogram/`, which direction the imports run between those -modules, and the patterns that recur across the package. Most of the code below -is package source, each snippet headed by the file it comes from and keeping -that file's intra-package imports. One snippet is source from a vendor package, -headed by a `# qprogram-/...` path comment: that is external code, so it -reaches QProgram symbols through `qp.` and uses its own dotted paths for its own -modules. The rest is ordinary code written against the installed package, which -reaches everything through `qp.`. +This page is for people changing QProgram rather than using it: what lives where under `src/qprogram/`, which direction the imports run between those modules, and the patterns that recur across the package. Most of the code below is package source, each snippet headed by the file it comes from and keeping that file's intra-package imports. One snippet is source from a vendor package, headed by a `# qprogram-/...` path comment: that is external code, so it reaches QProgram symbols through `qp.` and uses its own dotted paths for its own modules. The rest is ordinary code written against the installed package, which reaches everything through `qp.`. ## Repository layout @@ -65,114 +57,29 @@ qprogram/ └── _format.py # the .qp format version constant ``` -`qprogram` is the whole language: the AST, the `.qp` format, the capability -protocol, the validator, and the reference executor. It depends on `numpy>=2.1` -and `xarray>=2026.4.0` at runtime and nothing else; `matplotlib` (the `viz` -extra) and `pygls` (the `lsp` extra) are optional, and `lark` is a development -dependency used only to cross-check the grammar. A vendor extension is a -separate package in its own repository that depends on `qprogram` and registers -itself on import. See [Building a vendor extension](vendor-extensions.md). - -`tests/_dummy_vendor.py` is a complete vendor extension living in the test -suite. Its `activate()` runs the same registration calls an installed extension -runs on import, and `deactivate()` pops the same entries back out, so a fixture -can install and remove the vendor at well-defined points. It ships no -`pyproject.toml`, so entry-point discovery is covered separately, with a stub -entry point, in `tests/test_vendor_discovery.py`. +`qprogram` is the whole language: the AST, the `.qp` format, the capability protocol, the validator, and the reference executor. It depends on `numpy` and `xarray` at runtime and nothing else; `matplotlib` (the `viz` extra) and `pygls` (the `lsp` extra) are optional, and `lark` is a development dependency used only to cross-check the grammar. A vendor extension is a separate package in its own repository that depends on `qprogram` and registers itself on import. See [Building a vendor extension](vendor-extensions.md). + +`tests/_dummy_vendor.py` is a complete vendor extension living in the test suite. Its `activate()` runs the same registration calls an installed extension runs on import, and `deactivate()` pops the same entries back out, so a fixture can install and remove the vendor at well-defined points. It ships no `pyproject.toml`, so entry-point discovery is covered separately, with a stub entry point, in `tests/test_vendor_discovery.py`. ## What each module owns -The AST is the center of the package. `qprogram.py` holds the `QProgram` -builder, the private context-manager classes its control-flow methods return, -the class-level vendor-namespace registry, and the whole-program transforms -(`expand`, `rebind`, `with_waveforms`). `blocks/` holds the container nodes: -the `Block` base plus `Sweep`, `Average`, `Parallel`, and `Conditional`. -`operations/` holds one module per leaf node (12 classes, from `play` and -`measure` through `set_parameter` and the fragment `Call`), plus the -`Operation` base and the `MeasurementField` vocabulary in `operation.py`. - -Four vocabularies feed those nodes. `variable.py` is the symbolic expression -AST: `Expression` and its `Variable`, `Constant`, `BinaryOp`, `UnaryOp`, -`Comparison`, `LogicalBinaryOp`, `LogicalNot`, `MathFunc`, `Where`, and -`MeasurementRef` nodes. `waveforms/` holds the `Waveform` and `IQWaveform` -bases and 17 shapes, one per module. `sweeps/` holds the `SweepSource` -contract, the five parameter-only sources in `builtin.py` (`Range`, `Values`, -`Linspace`, `Logspace`, `File`) and the three that wrap another source in -`combinators.py` (`Repeat`, `Rotate`, `Concat`). `buses.py` holds `BusSchema`, -`BusNaming`, and `BusRef`, which subclasses `str` so a typed reference is a -plain string everywhere downstream. - -The rest of the AST layer is supporting structure. `fragments.py` holds -`Fragment`, `Parameter`, and the `expand_program` lowering that inlines every -call site. `result.py` holds `MeasurementHandle`, `MeasurementResult`, and -`QProgramResult`. `plotting/` is what `QProgramResult.plot` runs: `build.py` -turns a result array into the `Figure` description in `model.py`, and a -renderer registered in `renderers.py` draws it. Only `matplotlib_renderer.py` -imports a plotting library, and it is imported on first use, which is what -keeps `matplotlib` optional. `waveform_library.py` resolves a waveform alias -per bus and owns the `.wfl` text format, which is deliberately not part of a -`.qp` file: calibration state travels alongside a program, not inside it. -`errors.py` defines the whole exception hierarchy under `QProgramError`, -including the platform-side classes that core QProgram never raises but every -backend shares. `_reserved.py` holds `RESERVED_KEYWORDS`, and `_structural.py` -the two equality helpers described below. - -Analysis sits above the AST. `protocol.py` defines what a platform declares: -`PlatformCapabilities` (per-bus profiles plus one platform-wide profile), -`BusCapabilities(rt, host)`, `CompilerCapabilities` for a single slot, -`Domain`, `DomainConstraint`, `Diagnostic`, `Profile`, `ValidationContext`, and -the two registries behind them, `CAPABILITY_REGISTRY` (67 core tokens) and -`PROFILE_REGISTRY`. `profiles.py` registers `QPROGRAM_BASE_V1`, the -platform-level base of block, sweep, and expression tokens, as a side effect of -`import qprogram`. `validation.py` is the two-pass validator: a per-node -capability check and a bottom-up domain classification, returning a list of -`Diagnostic`s and an `ExecutionPlan`. `paths.py` gives every node a structural -address that survives a `.qp` round-trip, which is how a diagnostic maps back -to a line. `explain.py` renders a plan as an annotated tree, and -`optimization.py` applies the one rewrite the validator's -`reorderable-averaging` hint suggests, sharing the match decision with the -validator so the hint and the rewrite cannot disagree. - -Execution and tooling sit at the top. `platform.py` defines -`PlatformProtocol`, the seam a backend implements; its `validate`, `plan`, and -`explain` have working defaults, so a concrete platform supplies its resources, -its `PlatformCapabilities`, and `execute`. `executor.py` is `ReferencePlatform` -and `simulate()`, the in-tree interpreter that defines the reference semantics -vendor compilers are tested against. `serialization/` is the `.qp` writer, -parser, registries, and per-operation callbacks. `grammar/` ships `qp.lark`, -the normative grammar, and builds a reference Lark parser from it for the CI -cross-check. `lsp.py` exposes the real toolchain to editors through -`check_text()` and a `check | explain | serve` command line. +The AST is the center of the package. `qprogram.py` holds the `QProgram` builder, the private context-manager classes its control-flow methods return, the class-level vendor-namespace registry, and the whole-program transforms (`expand`, `rebind`, `with_waveforms`). `blocks/` holds the container nodes: the `Block` base plus `Sweep`, `Average`, `Parallel`, and `Conditional`. `operations/` holds one module per leaf node (12 classes, from `play` and `measure` through `set_parameter` and the fragment `Call`), plus the `Operation` base and the `MeasurementField` vocabulary in `operation.py`. + +Four vocabularies feed those nodes. `variable.py` is the symbolic expression AST: `Expression` and its `Variable`, `Constant`, `BinaryOp`, `UnaryOp`, `Comparison`, `LogicalBinaryOp`, `LogicalNot`, `MathFunc`, `Where`, and `MeasurementRef` nodes. `waveforms/` holds the `Waveform` and `IQWaveform` bases and 17 shapes, one per module. `sweeps/` holds the `SweepSource` contract, the five parameter-only sources in `builtin.py` (`Range`, `Values`, `Linspace`, `Logspace`, `File`) and the three that wrap another source in `combinators.py` (`Repeat`, `Rotate`, `Concat`). `buses.py` holds `BusSchema`, `BusNaming`, and `BusRef`, which subclasses `str` so a typed reference is a plain string everywhere downstream. + +The rest of the AST layer is supporting structure. `fragments.py` holds `Fragment`, `Parameter`, and the `expand_program` lowering that inlines every call site. `result.py` holds `MeasurementHandle`, `MeasurementResult`, and `QProgramResult`. `plotting/` is what `QProgramResult.plot` runs: `build.py` turns a result array into the `Figure` description in `model.py`, and a renderer registered in `renderers.py` draws it. Only `matplotlib_renderer.py` imports a plotting library, and it is imported on first use, which is what keeps `matplotlib` optional. `waveform_library.py` resolves a waveform alias per bus and owns the `.wfl` text format, which is deliberately not part of a `.qp` file: calibration state travels alongside a program, not inside it. `errors.py` defines the whole exception hierarchy under `QProgramError`, including the platform-side classes that core QProgram never raises but every backend shares. `_reserved.py` holds `RESERVED_KEYWORDS`, and `_structural.py` the two equality helpers described below. + +Analysis sits above the AST. `protocol.py` defines what a platform declares: `PlatformCapabilities` (per-bus profiles plus one platform-wide profile), `BusCapabilities(rt, host)`, `CompilerCapabilities` for a single slot, `Domain`, `DomainConstraint`, `Diagnostic`, `Profile`, `ValidationContext`, and the two registries behind them, `CAPABILITY_REGISTRY` (67 core tokens) and `PROFILE_REGISTRY`. `profiles.py` registers `QPROGRAM_BASE_V1`, the platform-level base of block, sweep, and expression tokens, as a side effect of `import qprogram`. `validation.py` is the two-pass validator: a per-node capability check and a bottom-up domain classification, returning a list of `Diagnostic`s and an `ExecutionPlan`. `paths.py` gives every node a structural address that survives a `.qp` round-trip, which is how a diagnostic maps back to a line. `explain.py` renders a plan as an annotated tree, and `optimization.py` applies the one rewrite the validator's `reorderable-averaging` hint suggests, sharing the match decision with the validator so the hint and the rewrite cannot disagree. + +Execution and tooling sit at the top. `platform.py` defines `PlatformProtocol`, the seam a backend implements; its `validate`, `plan`, and `explain` have working defaults, so a concrete platform supplies its resources, its `PlatformCapabilities`, and `execute`. `executor.py` is `ReferencePlatform` and `simulate()`, the in-tree interpreter that defines the reference semantics vendor compilers are tested against. `serialization/` is the `.qp` writer, parser, registries, and per-operation callbacks. `grammar/` ships `qp.lark`, the normative grammar, and builds a reference Lark parser from it for the CI cross-check. `lsp.py` exposes the real toolchain to editors through `check_text()` and a `check | explain | serve` command line. ## Which way the imports run -The floor of the package is seven modules that anything else may import without -ordering concerns: `_reserved.py`, `_structural.py`, `errors.py`, `buses.py`, -`protocol.py`, `platform.py`, and `serialization/_format.py`. None of them -imports from `qprogram` at module scope, which is what makes them safe to reach -from anywhere: `protocol.py` is what every operation, block, and sweep source -has to reach for its capability tokens, and `platform.py` is what a vendor -package subclasses. Two more modules have no module-scope package import -either, but they sit at the far end of the tree rather than under it: nothing in -`src/qprogram/` imports `lsp.py` or `grammar/__init__.py`, and each defers an -optional dependency into a function body alongside its package imports, `pygls` -and `lsprotocol` from the `lsp` extra in `lsp.py` and the dev-only `lark` in -`grammar/__init__.py`. - -Above that floor the direction is one way, from the AST outward. The -vocabularies (`variable`, `waveforms/waveform`, `sweeps/source`) import only -the floor. `operations/operation.py` imports the vocabularies; the concrete -operations import the base; `blocks/` imports the `Block` base and, in `Sweep`, -the sweep-source contract, naming `Operation` only under `TYPE_CHECKING`; and -`qprogram.py` imports blocks, operations, buses, sweeps, waveforms, and the -waveform library. Analysis imports the AST (`validation` reads blocks and -operations, `explain` reads `validation` and the writer), execution imports -analysis (`executor` imports `platform` and `validation`), and serialization -imports the AST plus its own registries. Nothing in the AST layer imports the -analysis, execution, or serialization layers at module scope. - -Where an edge genuinely has to run the other way it is deferred into a function -body instead of the module scope. The main cases: +The floor of the package is seven modules that anything else may import without ordering concerns: `_reserved.py`, `_structural.py`, `errors.py`, `buses.py`, `protocol.py`, `platform.py`, and `serialization/_format.py`. None of them imports from `qprogram` at module scope, which is what makes them safe to reach from anywhere: `protocol.py` is what every operation, block, and sweep source has to reach for its capability tokens, and `platform.py` is what a vendor package subclasses. Two more modules have no module-scope package import either, but they sit at the far end of the tree rather than under it: nothing in `src/qprogram/` imports `lsp.py` or `grammar/__init__.py`, and each defers an optional dependency into a function body alongside its package imports, `pygls` and `lsprotocol` from the `lsp` extra in `lsp.py` and the dev-only `lark` in `grammar/__init__.py`. + +Above that floor the direction is one way, from the AST outward. The vocabularies (`variable`, `waveforms/waveform`, `sweeps/source`) import only the floor. `operations/operation.py` imports the vocabularies; the concrete operations import the base; `blocks/` imports the `Block` base and, in `Sweep`, the sweep-source contract, naming `Operation` only under `TYPE_CHECKING`; and `qprogram.py` imports blocks, operations, buses, sweeps, waveforms, and the waveform library. Analysis imports the AST (`validation` reads blocks and operations, `explain` reads `validation` and the writer), execution imports analysis (`executor` imports `platform` and `validation`), and serialization imports the AST plus its own registries. Nothing in the AST layer imports the analysis, execution, or serialization layers at module scope. + +Where an edge genuinely has to run the other way it is deferred into a function body instead of the module scope. The main cases: | In | Deferred import | Resolved when | |---|---|---| @@ -184,20 +91,11 @@ body instead of the module scope. The main cases: | `platform.py` | `qprogram.validation`, `qprogram.explain` | a default `validate` / `plan` / `explain` runs | | `protocol.py` | `qprogram.buses`, `.variable`, `.waveforms`, `.paths` | a routing, token, or path helper is called | -The reasons differ but the shape does not. `protocol.py` needs `paths.py` to -stamp a diagnostic, and `paths.py` imports `qprogram.py`, so an eager import -there would pull the builder into the descriptor module. The sweep builder's -`from_*` lookup initializes the entire `qprogram.serialization` package, which -nothing in the builder needs until a reader actually writes `from_range(...)`. -Every deferred import in the package carries the annotation ruff wants -(`# ruff: ignore[import-outside-top-level]`), so a deferred import is always -visible as a decision rather than an accident. +The reasons differ but the shape does not. `protocol.py` needs `paths.py` to stamp a diagnostic, and `paths.py` imports `qprogram.py`, so an eager import there would pull the builder into the descriptor module. The sweep builder's `from_*` lookup initializes the entire `qprogram.serialization` package, which nothing in the builder needs until a reader actually writes `from_range(...)`. Every deferred import in the package carries the annotation ruff wants (`# ruff: ignore[import-outside-top-level]`), so a deferred import is always visible as a decision rather than an accident. ### The parser's lazily resolved names -`loads`, `load`, and `ParseError` are the only names in `__all__` that -`qprogram/__init__.py` does not import at module scope. They are resolved on -first attribute access instead: +`loads`, `load`, and `ParseError` are the only names in `__all__` that `qprogram/__init__.py` does not import at module scope. They are resolved on first attribute access instead: ```python # src/qprogram/__init__.py @@ -210,16 +108,9 @@ def __getattr__(name: str): raise AttributeError(msg) ``` -`qprogram/serialization/__init__.py` carries the same three names behind the -same `__getattr__`. The parser is the one module that sits above almost -everything: it constructs `QProgram`, `Fragment`, `BusSchema`, `BusRef`, -`MeasurementHandle`, and `Variable` instances, and it reaches back into -`qprogram.serialization` for the spec callbacks. Importing it from either -`__init__.py` closes a cycle in the module graph, through a package that is -still executing its own import block. +`qprogram/serialization/__init__.py` carries the same three names behind the same `__getattr__`. The parser is the one module that sits above almost everything: it constructs `QProgram`, `Fragment`, `BusSchema`, `BusRef`, `MeasurementHandle`, and `Variable` instances, and it reaches back into `qprogram.serialization` for the spec callbacks. Importing it from either `__init__.py` closes a cycle in the module graph, through a package that is still executing its own import block. -The effect a caller can observe is that `import qprogram` does not load the -parser at all: +The effect a caller can observe is that `import qprogram` does not load the parser at all: ```python import sys @@ -231,16 +122,11 @@ qp.loads # resolves the name, which imports the parser now "qprogram.serialization.parser" in sys.modules # True ``` -`qprogram.lsp` and `qprogram.grammar` stay out of that set too, for the same -reason from the other end: neither is imported by `__init__.py`, so the -optional `pygls` and `lark` dependencies are only reached by a caller that -asks for them. +`qprogram.lsp` and `qprogram.grammar` stay out of that set too, for the same reason from the other end: neither is imported by `__init__.py`, so the optional `pygls` and `lark` dependencies are only reached by a caller that asks for them. ## The builder and the block stack -`QProgram` is a fluent builder over a stack of open blocks. `__init__` creates -the root `Block` and puts it on the stack; `_active_block` is the top of that -stack; every operation appender goes through `_append_to_active`. +`QProgram` is a fluent builder over a stack of open blocks. `__init__` creates the root `Block` and puts it on the stack; `_active_block` is the top of that stack; every operation appender goes through `_append_to_active`. ```python # src/qprogram/qprogram.py, abridged @@ -259,47 +145,17 @@ class QProgram: self._append_to_active(Play(bus=bus, waveform=waveform)) ``` -Past the read-only properties (`body`, `schema`, `buses`, `variables`, -`fragments`, `source_map`), the `measurement_handles()` accessor, and the two -declaration helpers (`variable`, `register_vendor`), every method on the builder -is one of three kinds. Operation appenders (`play`, `measure`, `wait`, `sync`, -`set_frequency`, `set_phase`, `set_gain`, `set_offset`, `reset_phase`, -`set_parameter`, `get_parameter`, `call`) construct one `Operation` and append -it to the active block, after checking what can only be checked at the call -site: that the bus belongs to this program's schema, and that a concrete -waveform's channel count matches the bus's. - -Control-flow methods (`sweep`, `average`, `block`, and the `if_` / `elif_` / -`else_` chain) return a context manager rather than a node. Its `__enter__` -appends the new `Block` to the active block and then pushes it onto the stack; -its `__exit__` pops it. Nesting `with` statements therefore nests blocks, and -the depth of the Python indentation is the depth of the tree. The -context-manager classes are private, and none of them is meant to be -constructed directly. - -Two details of the stack are worth knowing before changing it. The `if_` chain -is not a stack discipline: `elif_` and `else_` mutate the `Conditional` that -`if_` left in `_pending_conditional` and push a new arm body themselves, which -is why they bypass `_append_to_active`. That pending chain is closed by the -first append that lands at the conditional's own parent level, since anything -other than an `elif_` or `else_` there would make the chain ambiguous. And -`_LoopContext.__or__`, `repeat`, and `rotate` are all pure: each returns a -fresh context and touches the program only in `__enter__`, which is what lets -`functools.reduce(operator.or_, ...)` fold a list of sweeps into one `Parallel` -block. - -Transformers (`expand`, `rebind`, `with_waveforms`) never mutate. Each deep -copies the program and rewrites the copy: `expand` replaces every fragment -`Call` with the fragment body inlined, `rebind` re-resolves schema-backed bus -references through a schema factory so the result is still a typed `BusRef`, -and `with_waveforms` resolves string waveform aliases per bus against a -`WaveformLibrary`. +Past the read-only properties (`body`, `schema`, `buses`, `variables`, `fragments`, `source_map`), the `measurement_handles()` accessor, and the two declaration helpers (`variable`, `register_vendor`), every method on the builder is one of three kinds. Operation appenders (`play`, `measure`, `wait`, `sync`, `set_frequency`, `set_phase`, `set_gain`, `set_offset`, `reset_phase`, `set_parameter`, `get_parameter`, `call`) construct one `Operation` and append it to the active block, after checking what can only be checked at the call site: that the bus belongs to this program's schema, and that a concrete waveform's channel count matches the bus's. + +Control-flow methods (`sweep`, `average`, `block`, and the `if_` / `elif_` / `else_` chain) return a context manager rather than a node. Its `__enter__` appends the new `Block` to the active block and then pushes it onto the stack; its `__exit__` pops it. Nesting `with` statements therefore nests blocks, and the depth of the Python indentation is the depth of the tree. The context-manager classes are private, and none of them is meant to be constructed directly. + +Two details of the stack are worth knowing before changing it. The `if_` chain is not a stack discipline: `elif_` and `else_` mutate the `Conditional` that `if_` left in `_pending_conditional` and push a new arm body themselves, which is why they bypass `_append_to_active`. That pending chain is closed by the first append that lands at the conditional's own parent level, since anything other than an `elif_` or `else_` there would make the chain ambiguous. And `_LoopContext.__or__`, `repeat`, and `rotate` are all pure: each returns a fresh context and touches the program only in `__enter__`, which is what lets `functools.reduce(operator.or_, ...)` fold a list of sweeps into one `Parallel` block. + +Transformers (`expand`, `rebind`, `with_waveforms`) never mutate. Each deep copies the program and rewrites the copy: `expand` replaces every fragment `Call` with the fragment body inlined, `rebind` re-resolves schema-backed bus references through a schema factory so the result is still a typed `BusRef`, and `with_waveforms` resolves string waveform aliases per bus against a `WaveformLibrary`. ## Operations and blocks -The AST has exactly two kinds of node, and they share one introspection -contract. Operations are the leaves: a typed class whose `__init__` parameters -are its attributes, with nothing hidden. +The AST has exactly two kinds of node, and they share one introspection contract. Operations are the leaves: a typed class whose `__init__` parameters are its attributes, with nothing hidden. ```python # src/qprogram/operations/play.py @@ -311,9 +167,7 @@ class Play(Operation): self.waveform = waveform ``` -Blocks are the containers. `Block` itself is an ordered list of children; -`Sweep`, `Average`, `Parallel`, and `Conditional` subclass it to add structure -the validator, writer, and executor understand. +Blocks are the containers. `Block` itself is an ordered list of children; `Sweep`, `Average`, `Parallel`, and `Conditional` subclass it to add structure the validator, writer, and executor understand. ```python # src/qprogram/blocks/block.py, abridged @@ -331,62 +185,23 @@ class Block: def required_capabilities(self) -> set[str]: ... ``` -`Operation` implements all of those but `append`, so a caller can write -`for node in program.body.walk():` and treat what comes back uniformly. -`Operation.walk()` yields just the leaf, and `variables()` walks every public -attribute, descending into expressions, waveform parameters, and lists. -`buses()` and `waveforms()` take the attribute names to read off two class -attributes, which is what makes them free for a vendor operation: `BUS_ATTRS` -(default `("bus",)`) names the attributes holding bus references, and -`WAVEFORM_ATTRS` (default empty) names the ones holding waveforms. `Sync` sets -`BUS_ATTRS = ("targets",)` because it holds a list; `Call` sets it empty, -because buses reach a call site only as bound argument values. - -Three more class attributes carry information the analysis layer needs and -would otherwise have to recover with an `isinstance` ladder. -`Block.REPEATS` is true on `Sweep`, `Parallel`, and `Average`, and is how the -validator computes `max_loop_nesting`; a `Parallel` counts as one level in -total, because its loop headers live on `loops` rather than among its children. -`Operation.AFFECTS_AVERAGING` is true on `MeasurementOperation` only, and marks -the ops whose presence decides whether an `Average` can run in real time. -`Operation.BROADCASTS_WHEN_NO_BUS` is true on `Sync`, and tells the validator to -route an op with no resolved bus across every bus in the program instead of the -default slot. A vendor class that sets any of these is counted correctly with no -core change. - -`required_capabilities()` is non-recursive on both kinds of node. The validator -visits every node and checks each one's own token set against the slot that node -routes to, so recursing here would double-count. See -[Capability protocol internals](capability-protocol.md). +`Operation` implements all of those but `append`, so a caller can write `for node in program.body.walk():` and treat what comes back uniformly. `Operation.walk()` yields just the leaf, and `variables()` walks every public attribute, descending into expressions, waveform parameters, and lists. `buses()` and `waveforms()` take the attribute names to read off two class attributes, which is what makes them free for a vendor operation: `BUS_ATTRS` (default `("bus",)`) names the attributes holding bus references, and `WAVEFORM_ATTRS` (default empty) names the ones holding waveforms. `Sync` sets `BUS_ATTRS = ("targets",)` because it holds a list; `Call` sets it empty, because buses reach a call site only as bound argument values. + +Three more class attributes carry information the analysis layer needs and would otherwise have to recover with an `isinstance` ladder. `Block.REPEATS` is true on `Sweep`, `Parallel`, and `Average`, and is how the validator computes `max_loop_nesting`; a `Parallel` counts as one level in total, because its loop headers live on `loops` rather than among its children. `Operation.AFFECTS_AVERAGING` is true on `MeasurementOperation` only, and marks the ops whose presence decides whether an `Average` can run in real time. `Operation.BROADCASTS_WHEN_NO_BUS` is true on `Sync`, and tells the validator to route an op with no resolved bus across every bus in the program instead of the default slot. A vendor class that sets any of these is counted correctly with no core change. + +`required_capabilities()` is non-recursive on both kinds of node. The validator visits every node and checks each one's own token set against the slot that node routes to, so recursing here would double-count. See [Capability protocol internals](capability-protocol.md). ## Expressions -`variable.py` is a small AST of its own, rooted at `Expression`. Every numeric -parameter of an operation or a waveform accepts an `Expression` in place of a -plain number, and `Expression.evaluate()` takes no arguments: each `Variable` -carries its own current value, written by the runtime once per loop iteration, -and the `UNASSIGNED` sentinel propagates upward while any variable is unbound. - -The operator overloads follow the NumPy and SymPy convention rather than -Python's keywords: `&`, `|`, and `~` build logical nodes, because `and`, `or`, -and `not` cannot be overloaded. `&` and `|` bind tighter than the comparison -operators, so a compound condition needs parentheses: -`(freq < 5e9) & (gain > 0.5)`. The ordering comparisons (`<`, `<=`, `>`, `>=`) -build `Comparison` nodes, but `==` and `!=` deliberately do not: `Variable.__eq__` -has to return a bool so that variables can live in the sets that -`Expression.variables()` returns and be used as dictionary keys. Equality -comparisons are written with the named helpers `qp.eq` and `qp.ne` instead. +`variable.py` is a small AST of its own, rooted at `Expression`. Every numeric parameter of an operation or a waveform accepts an `Expression` in place of a plain number, and `Expression.evaluate()` takes no arguments: each `Variable` carries its own current value, written by the runtime once per loop iteration, and the `UNASSIGNED` sentinel propagates upward while any variable is unbound. + +The operator overloads follow the NumPy and SymPy convention rather than Python's keywords: `&`, `|`, and `~` build logical nodes, because `and`, `or`, and `not` cannot be overloaded. `&` and `|` bind tighter than the comparison operators, so a compound condition needs parentheses: `(freq < 5e9) & (gain > 0.5)`. The ordering comparisons (`<`, `<=`, `>`, `>=`) build `Comparison` nodes, but `==` and `!=` deliberately do not: `Variable.__eq__` has to return a bool so that variables can live in the sets that `Expression.variables()` returns and be used as dictionary keys. Equality comparisons are written with the named helpers `qp.eq` and `qp.ne` instead. ## Structural equality -`_structural.py` holds the two helpers the whole AST shares, `ast_eq(a, b)` and -`ast_hash(value)`. Both recurse through the container shapes that actually -appear inside AST attributes, `ndarray`, `list`, and `dict`, and defer to the -value's own `==` or `hash` for everything else. +`_structural.py` holds the two helpers the whole AST shares, `ast_eq(a, b)` and `ast_hash(value)`. Both recurse through the container shapes that actually appear inside AST attributes, `ndarray`, `list`, and `dict`, and defer to the value's own `==` or `hash` for everything else. -Four places define the same pair of methods over them: `Operation`, `Block`, -`SweepSource`, and the `_StructuralEqMixin` that `Waveform` and `IQWaveform` -inherit. +Four places define the same pair of methods over them: `Operation`, `Block`, `SweepSource`, and the `_StructuralEqMixin` that `Waveform` and `IQWaveform` inherit. ```python # src/qprogram/operations/operation.py, abridged @@ -401,21 +216,9 @@ class Operation: return hash((type(self).__name__, items)) ``` -Routing every node through `vars(self)` means a new operation, block, waveform, -or sweep source needs no `__eq__` or `__hash__` of its own, and no edit to -either when it gains an attribute, and it treats the awkward attribute types the -way every other node does: a nested `IQPair` recurses, a `Variable` attribute -compares by id, an `ndarray` of samples compares by contents. That is what makes -whole-program comparison work across a `deepcopy` and across a `.qp` round-trip, -since `Variable` compares equal whenever the `id` matches and -`QProgram.variable` rejects a duplicate id, making id-equality identity within -one program. - -The two helpers are not exact mirrors. -`ast_eq` compares arrays with `np.array_equal`, which ignores dtype; `ast_hash` -hashes `(shape, tobytes())`, which does not. Two nodes that differ only in a -sample array's dtype therefore compare equal but hash apart, and land in -different buckets of a `dict` or `set`: +Routing every node through `vars(self)` means a new operation, block, waveform, or sweep source needs no `__eq__` or `__hash__` of its own, and no edit to either when it gains an attribute, and it treats the awkward attribute types the way every other node does: a nested `IQPair` recurses, a `Variable` attribute compares by id, an `ndarray` of samples compares by contents. That is what makes whole-program comparison work across a `deepcopy` and across a `.qp` round-trip, since `Variable` compares equal whenever the `id` matches and `QProgram.variable` rejects a duplicate id, making id-equality identity within one program. + +The two helpers are not exact mirrors. `ast_eq` compares arrays with `np.array_equal`, which ignores dtype; `ast_hash` hashes `(shape, tobytes())`, which does not. Two nodes that differ only in a sample array's dtype therefore compare equal but hash apart, and land in different buckets of a `dict` or `set`: ```python import numpy as np @@ -429,27 +232,15 @@ ints == floats # True: ast_eq compares contents hash(ints) == hash(floats) # False: ast_hash includes the dtype ``` -Every one of these classes is a value object whose attributes must stop -changing once it has been hashed. `QProgram.rebind` and the other transformers -rewrite operations on a fresh `deepcopy` for that reason, never in place. +Every one of these classes is a value object whose attributes must stop changing once it has been hashed. `QProgram.rebind` and the other transformers rewrite operations on a fresh `deepcopy` for that reason, never in place. ## The three vendor hooks -A vendor extension plugs into core QProgram in three places, each independent -of the others: the runtime namespace that makes its operations callable, the -serialization registries that make them survive a `.qp` round-trip, and the -capability protocol that says which of them a given platform supports. The -`activate()` function in `tests/_dummy_vendor.py` is the whole sequence in one -place: `register_vendor`, `register_vendor_version`, one -`register_vendor_operation` per operation, `register_capability_tokens`, and -`register_profile`. +A vendor extension plugs into core QProgram in three places, each independent of the others: the runtime namespace that makes its operations callable, the serialization registries that make them survive a `.qp` round-trip, and the capability protocol that says which of them a given platform supports. The `activate()` function in `tests/_dummy_vendor.py` is the whole sequence in one place: `register_vendor`, `register_vendor_version`, one `register_vendor_operation` per operation, `register_capability_tokens`, and `register_profile`. ### Runtime namespace -`QProgram._vendor_registry` is a class-level dict mapping a vendor name to a -`VendorNamespace` subclass. `QProgram.__getattr__` looks the name up and caches -an instantiated namespace on the program with `object.__setattr__`, so a -namespace costs nothing until it is first reached. +`QProgram._vendor_registry` is a class-level dict mapping a vendor name to a `VendorNamespace` subclass. `QProgram.__getattr__` looks the name up and caches an instantiated namespace on the program with `object.__setattr__`, so a namespace costs nothing until it is first reached. ```python # qprogram-myvendor/src/qprogram_myvendor/__init__.py @@ -460,71 +251,23 @@ from qprogram_myvendor.namespace import MyVendorNamespace qp.QProgram.register_vendor("myvendor", MyVendorNamespace) ``` -After that call `program.myvendor.(...)` resolves on any `QProgram` -instance, including one built from the base class. `register_vendor` refuses -three kinds of name: a reserved keyword or the `"core"` sentinel, a name that -collides with a `QProgram` attribute (normal attribute lookup wins over -`__getattr__`, so the namespace would be unreachable), and a name already -registered to a different class. Re-registering the same class under the same -name is a no-op, since import-time side-effect modules can run twice. -`__getattr__` also refuses any underscore-prefixed name immediately, so -protocol probes such as `__deepcopy__` fail fast instead of reaching the -registry. - -`VendorNamespace` gives a namespace method the two helpers it needs: `_append` -for a plain operation, which validates any `BusRef` attribute against the -program's schema before appending, and `_append_measurement` for one that -returns a `MeasurementHandle`, which shares the per-bus name counter with -`QProgram.measure` so vendor and core measurements on a bus cannot collide. - -Each vendor package also ships a mixin with one typed `@property` and a -pre-combined `QProgram` class (`qprogram_myvendor.QProgram`), and several -vendors compose through multiple inheritance. That is a typing aid, not a -fourth hook: the dynamic `__getattr__` resolves the namespace at runtime -whether or not the mixin is present, and the mixin exists so that an editor can -complete `program.myvendor.` and a type checker can check the arguments. +After that call `program.myvendor.(...)` resolves on any `QProgram` instance, including one built from the base class. `register_vendor` refuses three kinds of name: a reserved keyword or the `"core"` sentinel, a name that collides with a `QProgram` attribute (normal attribute lookup wins over `__getattr__`, so the namespace would be unreachable), and a name already registered to a different class. Re-registering the same class under the same name is a no-op, since import-time side-effect modules can run twice. `__getattr__` also refuses any underscore-prefixed name immediately, so protocol probes such as `__deepcopy__` fail fast instead of reaching the registry. + +`VendorNamespace` gives a namespace method the two helpers it needs: `_append` for a plain operation, which validates any `BusRef` attribute against the program's schema before appending, and `_append_measurement` for one that returns a `MeasurementHandle`, which shares the per-bus name counter with `QProgram.measure` so vendor and core measurements on a bus cannot collide. + +Each vendor package also ships a mixin with one typed `@property` and a pre-combined `QProgram` class (`qprogram_myvendor.QProgram`), and several vendors compose through multiple inheritance. That is a typing aid, not a fourth hook: the dynamic `__getattr__` resolves the namespace at runtime whether or not the mixin is present, and the mixin exists so that an editor can complete `program.myvendor.` and a type checker can check the arguments. ### Serialization registries -`qprogram/serialization/registry.py` holds seven module-level dicts making up -five registries: operations by class and by `(vendor, name)`, blocks by keyword -and by class, sweep sources by class name, waveforms by class name, and vendor -protocol versions by vendor name. The writer looks an operation up by its class -and the parser reverses the lookup by `(vendor, name)`, so neither one contains -an `isinstance` ladder or a hard-coded keyword list. - -A vendor calls `register_vendor_operation(vendor, name, cls)` and -`register_vendor_version(vendor, version)` at import time, plus -`register_vendor_block` for a control-flow block of its own. It also declares a -`[project.entry-points."qprogram.vendors"]` entry point, which is what lets -`loads()` import an installed-but-unimported extension when a file's `require` -line names it. - -The block registry covers the keyword-led headers, `block:` and -`average 1000:`, and a vendor block's `.:`. The three -structural blocks are not registered: a `Sweep` header is emitted as -`for in (...)` with the source rendered from its own attributes, -and `Parallel` and `Conditional` have fixed grammar in both the writer and the -parser. Adding a sweep shape is therefore a new `SweepSource`, not a new block. -`qp.lark` in `grammar/` is the normative statement of all of this, and -`tests/test_grammar.py` parses the writer's output with it so the hand-written -parser and the grammar cannot drift. +`qprogram/serialization/registry.py` holds seven module-level dicts making up five registries: operations by class and by `(vendor, name)`, blocks by keyword and by class, sweep sources by class name, waveforms by class name, and vendor protocol versions by vendor name. The writer looks an operation up by its class and the parser reverses the lookup by `(vendor, name)`, so neither one contains an `isinstance` ladder or a hard-coded keyword list. + +A vendor calls `register_vendor_operation(vendor, name, cls)` and `register_vendor_version(vendor, version)` at import time, plus `register_vendor_block` for a control-flow block of its own. It also declares a `[project.entry-points."qprogram.vendors"]` entry point, which is what lets `loads()` import an installed-but-unimported extension when a file's `require` line names it. + +The block registry covers the keyword-led headers, `block:` and `average 1000:`, and a vendor block's `.:`. The three structural blocks are not registered: a `Sweep` header is emitted as `for in (...)` with the source rendered from its own attributes, and `Parallel` and `Conditional` have fixed grammar in both the writer and the parser. Adding a sweep shape is therefore a new `SweepSource`, not a new block. `qp.lark` in `grammar/` is the normative statement of all of this, and `tests/test_grammar.py` parses the writer's output with it so the hand-written parser and the grammar cannot drift. ### Capability protocol -Every `Operation` and `Block` subclass implements `required_capabilities()`, -returning the dotted-string tokens that instance needs. A vendor registers its -tokens with `register_capability_tokens`, maps its waveform classes to tokens -with `register_waveform_token`, and registers one or more `Profile` bundles of -capabilities, limits, and predicates with `register_profile`. `Profile` rejects -an unknown token in `__post_init__`, so the token registration has to happen -before the profile is constructed. A vendor profile either extends -`qprogram-base-v1` or fills its platform-level slot from it with -`CompilerCapabilities.from_profile("qprogram-base-v1", limit_overrides=...)`. -`validate(program, capabilities)` then walks the AST, checking each node's -tokens against the routed slot and classifying each block's execution domain, -and returns the diagnostics and the plan. Full details in -[Capability protocol internals](capability-protocol.md). +Every `Operation` and `Block` subclass implements `required_capabilities()`, returning the dotted-string tokens that instance needs. A vendor registers its tokens with `register_capability_tokens`, maps its waveform classes to tokens with `register_waveform_token`, and registers one or more `Profile` bundles of capabilities, limits, and predicates with `register_profile`. `Profile` rejects an unknown token in `__post_init__`, so the token registration has to happen before the profile is constructed. A vendor profile either extends `qprogram-base-v1` or fills its platform-level slot from it with `CompilerCapabilities.from_profile("qprogram-base-v1", limit_overrides=...)`. `validate(program, capabilities)` then walks the AST, checking each node's tokens against the routed slot and classifying each block's execution domain, and returns the diagnostics and the plan. Full details in [Capability protocol internals](capability-protocol.md). ## Where to add things @@ -541,15 +284,6 @@ and returns the diagnostics and the plan. Full details in ## Why a vendor extension is a separate package -A platform library that supports many instruments tends to pull in many vendor -SDKs. Keeping each extension in its own package, registering itself on import, -keeps `qprogram` installable with two runtime dependencies for someone who only -wants the language, and keeps every vendor's hard dependencies out of -`qprogram`'s dependency graph. The cost is that a program's `.qp` file can -`require` a vendor the reader does not have installed, which is why the parser -checks the `require` lines against the registry before parsing the body and -raises rather than loading a program it cannot represent. - -The boundary is enforced at import time: `import qprogram` does not -transitively import any vendor package. The other direction is intended, and -vendor packages import from `qprogram` freely. +A platform library that supports many instruments tends to pull in many vendor SDKs. Keeping each extension in its own package, registering itself on import, keeps `qprogram` installable with two runtime dependencies for someone who only wants the language, and keeps every vendor's hard dependencies out of `qprogram`'s dependency graph. The cost is that a program's `.qp` file can `require` a vendor the reader does not have installed, which is why the parser checks the `require` lines against the registry before parsing the body and raises rather than loading a program it cannot represent. + +The boundary is enforced at import time: `import qprogram` does not transitively import any vendor package. The other direction is intended, and vendor packages import from `qprogram` freely. diff --git a/docs/developer/capability-protocol.md b/docs/developer/capability-protocol.md index e95895b..1c584d0 100644 --- a/docs/developer/capability-protocol.md +++ b/docs/developer/capability-protocol.md @@ -1,73 +1,24 @@ # Capability protocol internals -This page is the developer companion to -[Capabilities, diagnostics, and profiles](../guide/capabilities.md). The guide -covers using the protocol from the outside; this page covers how the pieces are -built and what to touch when you extend them. - -Three kinds of snippet appear below. A snippet whose first line is a -`# src/qprogram/...` path comment is package source, and keeps the real -intra-package imports it has in the tree, because `import qprogram` from inside -the package would close an import cycle. A snippet headed with a -`# qprogram-myvendor/...` path comment is source inside a vendor package, which -is ordinary external code: it reaches QProgram symbols through `qp.` and its own -modules through its own dotted paths. Every other example is user code and -reaches the library through `import qprogram as qp`. +This page is the developer companion to [Capabilities, diagnostics, and profiles](../guide/capabilities.md). The guide covers using the protocol from the outside; this page covers how the pieces are built and what to touch when you extend them. + +Three kinds of snippet appear below. A snippet whose first line is a `# src/qprogram/...` path comment is package source, and keeps the real intra-package imports it has in the tree, because `import qprogram` from inside the package would close an import cycle. A snippet headed with a `# qprogram-myvendor/...` path comment is source inside a vendor package, which is ordinary external code: it reaches QProgram symbols through `qp.` and its own modules through its own dotted paths. Every other example is user code and reaches the library through `import qprogram as qp`. ## Design choices -The capability surface is split per bus and per domain. -`PlatformCapabilities` has exactly three fields: `bus`, a -`Mapping[BusSelector, BusCapabilities]` keyed by the `(element_kind, bus_kind)` -tuple that `BusSelector` names; `platform`, the slot for control flow, -expressions, and bus-less operations; and `default_bus_profile`, the fallback -for raw-string buses and for keys absent from `bus`. Each of those slots is a -`BusCapabilities(rt, host)`, and either half may be `None`: -`BusCapabilities.supported_domains()` reports which halves exist. A flux bus -driven by a slow DAC has `rt=None`, a sequencer-only bus has `host=None`. The -alternative, one flat capability set per platform, cannot express that a -platform plays a DRAG pulse on its drive bus but not on its flux bus, which is -the ordinary case on real racks. - -Inside a slot the three axes stay separate because they are checked in -different ways. Capabilities are a `frozenset[str]` of flat tokens answered by -set membership, limits are a `Mapping[str, float]` compared against a number -the validator measured, and predicates are callables that look at the node and -its data-flow context. Vulkan made the same split into features, limits, and -extensions; collapsing them produces one axis with three kinds of entry and a -check that has to switch on which kind it holds. The predicate axis is MLIR's -dynamically-legal operations: legality is decided per node from what the node -holds, not per operation class. - -Requirements are declared by the nodes rather than centrally. Each `Operation` -and `Block` subclass returns the tokens it needs from -`required_capabilities()`, computed from its own instance state, and the -validator walks the AST and unions per-node sets. This is the arrangement -MLIR's SPIR-V dialect uses: each operation declares its own availability -requirements, and one conversion target checks them. The cost is that which -tokens a node asks for is decided in the operation's module rather than in a -table, so `_BASE_TOKENS` tells a reader that a token exists but not what makes -a node require it. - -The two domains share one token space. `required_capabilities()` is -domain-agnostic, and the same set is checked against the `rt` and the `host` -half of the routed slot; nothing in the DSL declares "this is a real-time -token". Domain-specific behavior comes from predicates instead, which emit a -`DomainConstraint` when a combination rules out a domain but leaves another -working, and a `Diagnostic` when it rules out all of them. The block classifier -turns those into per-node domain sets and reports the consequence once, as a -`forced-host` warning on the highest block that lost `"rt"`. - -`PlatformCapabilities` is both what the validator consumes and what users -introspect; there is no separate advertised-versus-enforced surface to keep in -step. `qprogram.validation.validate` returns `(diagnostics, plan)`, and -`PlatformProtocol.validate`, `.plan`, and `.explain` are views over that one -call. +The capability surface is split per bus and per domain. `PlatformCapabilities` has exactly three fields: `bus`, a `Mapping[BusSelector, BusCapabilities]` keyed by the `(element_kind, bus_kind)` tuple that `BusSelector` names; `platform`, the slot for control flow, expressions, and bus-less operations; and `default_bus_profile`, the fallback for raw-string buses and for keys absent from `bus`. Each of those slots is a `BusCapabilities(rt, host)`, and either half may be `None`: `BusCapabilities.supported_domains()` reports which halves exist. A flux bus driven by a slow DAC has `rt=None`, a sequencer-only bus has `host=None`. The alternative, one flat capability set per platform, cannot express that a platform plays a DRAG pulse on its drive bus but not on its flux bus, which is the ordinary case on real racks. + +Inside a slot the three axes stay separate because they are checked in different ways. Capabilities are a `frozenset[str]` of flat tokens answered by set membership, limits are a `Mapping[str, float]` compared against a number the validator measured, and predicates are callables that look at the node and its data-flow context. Vulkan made the same split into features, limits, and extensions; collapsing them produces one axis with three kinds of entry and a check that has to switch on which kind it holds. The predicate axis is MLIR's dynamically-legal operations: legality is decided per node from what the node holds, not per operation class. + +Requirements are declared by the nodes rather than centrally. Each `Operation` and `Block` subclass returns the tokens it needs from `required_capabilities()`, computed from its own instance state, and the validator walks the AST and unions per-node sets. This is the arrangement MLIR's SPIR-V dialect uses: each operation declares its own availability requirements, and one conversion target checks them. The cost is that which tokens a node asks for is decided in the operation's module rather than in a table, so `_BASE_TOKENS` tells a reader that a token exists but not what makes a node require it. + +The two domains share one token space. `required_capabilities()` is domain-agnostic, and the same set is checked against the `rt` and the `host` half of the routed slot; nothing in the DSL declares "this is a real-time token". Domain-specific behavior comes from predicates instead, which emit a `DomainConstraint` when a combination rules out a domain but leaves another working, and a `Diagnostic` when it rules out all of them. The block classifier turns those into per-node domain sets and reports the consequence once, as a `forced-host` warning on the highest block that lost `"rt"`. + +`PlatformCapabilities` is both what the validator consumes and what users introspect; there is no separate advertised-versus-enforced surface to keep in step. `qprogram.validation.validate` returns `(diagnostics, plan)`, and `PlatformProtocol.validate`, `.plan`, and `.explain` are views over that one call. ## Module layout -Capability code lives in three files plus `platform.py`, with two more that -present its output: +Capability code lives in three files plus `platform.py`, with two more that present its output: ``` src/qprogram/ @@ -101,18 +52,9 @@ src/qprogram/ | `WAVEFORM_TOKEN` | `dict[type, str]`, waveform class to refinement token. | | `MEASUREMENT_FIELD_TOKEN_PREFIX` | `"measure.fields."`, the namespace that defines which `fields=` names exist. | -Plus the helpers `register_capability_tokens`, `validate_tokens`, -`register_profile`, `resolve_profile`, `register_waveform_token`, -`waveform_token`, `expression_tokens`, `measurement_field_token`, and -`known_measurement_fields`. The names re-exported at package top level are -listed in `qprogram.__all__`; the rest are reachable as `qp.protocol.`. +Plus the helpers `register_capability_tokens`, `validate_tokens`, `register_profile`, `resolve_profile`, `register_waveform_token`, `waveform_token`, `expression_tokens`, `measurement_field_token`, and `known_measurement_fields`. The names re-exported at package top level are listed in `qprogram.__all__`; the rest are reachable as `qp.protocol.`. -`profiles.py` defines `QPROGRAM_BASE_V1`, the core platform-level profile, and -registers it as a side effect of `import qprogram`. It carries the five -`block.*` tokens, the nine `expr.*` node tokens and the nine `expr.math.*` -tokens, the two `sweep.` tokens, and one `sweep.` token per -built-in source. It carries no bus tokens and no limits, and declares no -predicates. +`profiles.py` defines `QPROGRAM_BASE_V1`, the core platform-level profile, and registers it as a side effect of `import qprogram`. It carries the five `block.*` tokens, the nine `expr.*` node tokens and the nine `expr.math.*` tokens, the two `sweep.` tokens, and one `sweep.` token per built-in source. It carries no bus tokens and no limits, and declares no predicates. `validation.py` exports one function: @@ -124,20 +66,11 @@ def validate( ) -> tuple[list[Diagnostic], ExecutionPlan]: ... ``` -`platform.py` exposes `.capabilities` (an abstract property returning -`PlatformCapabilities`), `.validate(qp) -> list[Diagnostic]` and -`.plan(qp) -> ExecutionPlan`, both delegating to `qprogram.validation.validate` -and discarding the half they do not return, plus `.explain(qp) -> str`, which -delegates to `qprogram.explain`. +`platform.py` exposes `.capabilities` (an abstract property returning `PlatformCapabilities`), `.validate(qp) -> list[Diagnostic]` and `.plan(qp) -> ExecutionPlan`, both delegating to `qprogram.validation.validate` and discarding the half they do not return, plus `.explain(qp) -> str`, which delegates to `qprogram.explain`. ## Distributed declaration in practice -Every concrete `Operation` and `Block` subclass overrides -`required_capabilities()` to return the tokens it needs. The defaults are -permissive: `Operation`'s returns an empty set and `Block`'s returns -`{"block.block"}`, so a subclass that does not override adds no token of its -own: such an operation is accepted anywhere, and such a block is checked for -`block.block` alone. +Every concrete `Operation` and `Block` subclass overrides `required_capabilities()` to return the tokens it needs. The defaults are permissive: `Operation`'s returns an empty set and `Block`'s returns `{"block.block"}`, so a subclass that does not override adds no token of its own: such an operation is accepted anywhere, and such a block is checked for `block.block` alone. A typical core operation reads its own instance state: @@ -164,27 +97,13 @@ class Play(Operation): return caps ``` -So `Play("drive_q0", Square(0.5, 100))` needs -`{"op.play", "waveform.single", "waveform.square"}`, the same play with an -`IQDrag` needs `{"op.play", "waveform.iq", "waveform.iq_drag"}`, and the string -alias form needs `{"op.play", "waveform.alias"}`, because an alias is resolved -later by `QProgram.with_waveforms` and its class is not known yet. - -Blocks follow the same shape. `Sweep.required_capabilities()` returns -`{"block.sweep"}` unioned with `SweepSource.tokens()`, which is the source's own -`TOKEN` plus `sweep.`, so `Sweep(v, Range(0, 1, 0.1))` needs -`{"block.sweep", "sweep.range", "sweep.linear"}` and `Sweep(v, Values(...))` -needs `{"block.sweep", "sweep.values", "sweep.arbitrary"}`. A combinator unions -its wrapped source's tokens into its own, so a platform that does not declare -`sweep.logspace` also refuses `Rotate(Logspace(...))`. `Parallel` returns only -`{"block.parallel"}`: its loop headers are classified as block-children in their -own right, so their `sweep.*` tokens are checked there rather than repeated on -the parent. +So `Play("drive_q0", Square(0.5, 100))` needs `{"op.play", "waveform.single", "waveform.square"}`, the same play with an `IQDrag` needs `{"op.play", "waveform.iq", "waveform.iq_drag"}`, and the string alias form needs `{"op.play", "waveform.alias"}`, because an alias is resolved later by `QProgram.with_waveforms` and its class is not known yet. + +Blocks follow the same shape. `Sweep.required_capabilities()` returns `{"block.sweep"}` unioned with `SweepSource.tokens()`, which is the source's own `TOKEN` plus `sweep.`, so `Sweep(v, Range(0, 1, 0.1))` needs `{"block.sweep", "sweep.range", "sweep.linear"}` and `Sweep(v, Values(...))` needs `{"block.sweep", "sweep.values", "sweep.arbitrary"}`. A combinator unions its wrapped source's tokens into its own, so a platform that does not declare `sweep.logspace` also refuses `Rotate(Logspace(...))`. `Parallel` returns only `{"block.parallel"}`: its loop headers are classified as block-children in their own right, so their `sweep.*` tokens are checked there rather than repeated on the parent. ### Expression tokens propagate from parametric arguments -An operation with an expression-typed parameter unions in -`expression_tokens(value)`: +An operation with an expression-typed parameter unions in `expression_tokens(value)`: ```python # src/qprogram/operations/wait.py @@ -195,29 +114,13 @@ class Wait(Operation): return {"op.wait"} | expression_tokens(self.duration) ``` -`expression_tokens` walks the expression tree and returns one token per node -kind, plus `expr.math.` per math function. `Wait("drive_q0", 100)` needs -only `{"op.wait"}`, because a plain `int` is not an `Expression` node and the -token describes the shape of the parameter rather than its value. -`qp.protocol.expression_tokens(qp.sqrt(d) + 2)` returns -`{"expr.binary_op", "expr.constant", "expr.math.sqrt", "expr.variable"}`. An -`Expression` subclass the function does not recognize contributes an empty set -rather than raising, so a vendor expression node does not break token -collection on an older validator. - -`expr.*` tokens are checked against `caps.platform` wherever the operation -itself routes, since they describe which expression node kinds the platform's -compiler accepts rather than anything bus-specific. The split happens inside -the validator on the token prefix; an operation author calls -`expression_tokens` and unions the result in. +`expression_tokens` walks the expression tree and returns one token per node kind, plus `expr.math.` per math function. `Wait("drive_q0", 100)` needs only `{"op.wait"}`, because a plain `int` is not an `Expression` node and the token describes the shape of the parameter rather than its value. `qp.protocol.expression_tokens(qp.sqrt(d) + 2)` returns `{"expr.binary_op", "expr.constant", "expr.math.sqrt", "expr.variable"}`. An `Expression` subclass the function does not recognize contributes an empty set rather than raising, so a vendor expression node does not break token collection on an older validator. + +`expr.*` tokens are checked against `caps.platform` wherever the operation itself routes, since they describe which expression node kinds the platform's compiler accepts rather than anything bus-specific. The split happens inside the validator on the token prefix; an operation author calls `expression_tokens` and unions the result in. ### Per-node methods must not recurse -Both `Operation.required_capabilities()` and `Block.required_capabilities()` -return only the node's own tokens. The validator does the walking, so a block -that unions its children's sets makes every descendant token be checked once -per ancestor as well as at the child, against the ancestor's slot rather than -the child's: +Both `Operation.required_capabilities()` and `Block.required_capabilities()` return only the node's own tokens. The validator does the walking, so a block that unions its children's sets makes every descendant token be checked once per ancestor as well as at the child, against the ancestor's slot rather than the child's: ```python # WRONG - do not do this @@ -229,141 +132,56 @@ class MyBlock(qp.blocks.Block): return caps ``` -The concrete failure is a misattributed diagnostic. A block routes to -`caps.platform`, so a child's `op.play` folded into the parent's set is checked -against the platform slot, which does not carry bus tokens, and the reader gets -a `missing-capability` for `op.play` on the block. Nothing enforces the rule, -which is why it is stated here and in both base classes' docstrings. +The concrete failure is a misattributed diagnostic. A block routes to `caps.platform`, so a child's `op.play` folded into the parent's set is checked against the platform slot, which does not carry bus tokens, and the reader gets a `missing-capability` for `op.play` on the block. Nothing enforces the rule, which is why it is stated here and in both base classes' docstrings. ## The two-pass validator -`qprogram.validation.validate(qprogram, caps)` starts by expanding fragments: -if any node in `qprogram.body.walk()` is a `Call`, the whole program is -replaced by `qprogram.expand()`, and everything after that point sees the -expansion. Diagnostics then carry nodes and paths from the expanded copy, so a -caller that wants plan entries for nodes it holds should call -`QProgram.expand()` itself and validate the result. - -A pre-walk (`_build_context`) then collects the program-wide facts predicates -need, and the two cooperating checks run as one recursive post-order walk: - -1. **Per-node check.** For each node, resolve the slots it routes to, split its - required tokens on the `expr.` prefix, and work out which domains survive. - Predicates registered on the halves that were not skipped run here, and their - outputs are sorted into diagnostics and constraints. -2. **Block-level classification.** After a block's children have been - classified, the block's domain is derived from its immediate op-children's - consensus, intersected with what its own slot allows, minus the domains any - `DomainConstraint` targeting it excludes. - -Three passes follow, in order: whole-program limit checks; the two -profile-independent `Conditional` checks; then advisory emission, which appends -the `forced-host` warnings and the `reorderable-averaging` hints and finally -stamps every node-bearing diagnostic with its structural `path`. - -The validator never raises. Both halves of the return value are always -produced, and the caller decides what an error means. +`qprogram.validation.validate(qprogram, caps)` starts by expanding fragments: if any node in `qprogram.body.walk()` is a `Call`, the whole program is replaced by `qprogram.expand()`, and everything after that point sees the expansion. Diagnostics then carry nodes and paths from the expanded copy, so a caller that wants plan entries for nodes it holds should call `QProgram.expand()` itself and validate the result. + +A pre-walk (`_build_context`) then collects the program-wide facts predicates need, and the two cooperating checks run as one recursive post-order walk: + +1. **Per-node check.** For each node, resolve the slots it routes to, split its required tokens on the `expr.` prefix, and work out which domains survive. Predicates registered on the halves that were not skipped run here, and their outputs are sorted into diagnostics and constraints. +2. **Block-level classification.** After a block's children have been classified, the block's domain is derived from its immediate op-children's consensus, intersected with what its own slot allows, minus the domains any `DomainConstraint` targeting it excludes. + +Three passes follow, in order: whole-program limit checks; the two profile-independent `Conditional` checks; then advisory emission, which appends the `forced-host` warnings and the `reorderable-averaging` hints and finally stamps every node-bearing diagnostic with its structural `path`. + +The validator never raises. Both halves of the return value are always produced, and the caller decides what an error means. ### Token checking inside a slot -`_check_node_self` iterates the two domains in the order `("rt", "host")`. A -domain is skipped before any token is examined when any of the node's routed -slots has `None` for that half, and, when the node contributes `expr.*` tokens, -also when `caps.platform.get(domain)` is `None`. Otherwise every routed slot is -checked: a token missing from any one of them fails the domain, which is how -the intersection across a multi-bus `Sync` is implemented. A domain that -finishes with no missing token and no predicate-emitted `Diagnostic` is -*available*. - -Failure reasons are collected during the loop but only surface when the -available set comes out empty, so a node that works host-side does not also -report why real-time was refused. What surfaces is one `missing-capability` per -missing token, not one per domain, naming each profile and domain that lacked -it, followed by the deduplicated predicate diagnostics. The `domain` field is -populated only when a single domain was involved. So a token missing in both -halves of one profile reads: +`_check_node_self` iterates the two domains in the order `("rt", "host")`. A domain is skipped before any token is examined when any of the node's routed slots has `None` for that half, and, when the node contributes `expr.*` tokens, also when `caps.platform.get(domain)` is `None`. Otherwise every routed slot is checked: a token missing from any one of them fails the domain, which is how the intersection across a multi-bus `Sync` is implemented. A domain that finishes with no missing token and no predicate-emitted `Diagnostic` is *available*. + +Failure reasons are collected during the loop but only surface when the available set comes out empty, so a node that works host-side does not also report why real-time was refused. What surfaces is one `missing-capability` per missing token, not one per domain, naming each profile and domain that lacked it, followed by the deduplicated predicate diagnostics. The `domain` field is populated only when a single domain was involved. So a token missing in both halves of one profile reads: ``` [error] missing-capability: 'Play' requires capability 'waveform.square' which is not supported by 'awg-v1' (rt) / 'awg-v1' (host) (at body[0]) ``` -and a missing expression token says `expression capability` instead of -`capability` and names the platform profile: +and a missing expression token says `expression capability` instead of `capability` and names the platform profile: ``` [error] missing-capability: 'Wait' requires expression capability 'expr.variable' which is not supported by 'plat-v1' (rt) / 'plat-v1' (host) (at body[0][0]) ``` -The profile name in the message is `CompilerCapabilities.profile`, which is the -leaf profile's name, so a descriptor built from a profile that extends another -names only the leaf. When neither a token nor a predicate has anything to say -and the available set is still empty, the node gets an `empty-domain` error -instead: that is the case where both domains were skipped, because in each of -them a routed slot, or the platform slot for a node carrying `expr.*` tokens, -was `None`. - -Predicates run once per (domain, routed slot) pair. A single-bus operation on a -slot with both halves filled calls each predicate twice; a `Sync` broadcasting -over three buses calls them six times. Duplicate outputs are discarded, a -`Diagnostic` by dataclass equality and a `DomainConstraint` by identity of the -target node plus equality of `exclude` and `reason`, so the usual case of one -profile filling both halves reports each finding once. Write predicates as -cheap, side-effect-free functions of `(node, ctx)`. +The profile name in the message is `CompilerCapabilities.profile`, which is the leaf profile's name, so a descriptor built from a profile that extends another names only the leaf. When neither a token nor a predicate has anything to say and the available set is still empty, the node gets an `empty-domain` error instead: that is the case where both domains were skipped, because in each of them a routed slot, or the platform slot for a node carrying `expr.*` tokens, was `None`. + +Predicates run once per (domain, routed slot) pair. A single-bus operation on a slot with both halves filled calls each predicate twice; a `Sync` broadcasting over three buses calls them six times. Duplicate outputs are discarded, a `Diagnostic` by dataclass equality and a `DomainConstraint` by identity of the target node plus equality of `exclude` and `reason`, so the usual case of one profile filling both halves reports each finding once. Write predicates as cheap, side-effect-free functions of `(node, ctx)`. ### Block classification -A block's op-children and block-children are partitioned first -(`_immediate_children`). A `Conditional` has no op-children at all: its arm -bodies and its `else_body` are block-children. A `Parallel`'s loop headers are -block-children alongside any block in its body, and its body's operations are -its op-children. Every other block splits its `.elements` on `isinstance`. - -Both sets are recursed into before the block is judged. The natural domain is -then the intersection of the supports of the op-children that gate the block, -which is every op-child except in an `Average`, where it is only the -averaging-relevant ones: those with `Operation.AFFECTS_AVERAGING`, which -`MeasurementOperation` sets to `True` so that core `measure` and vendor -`acquire` opt in automatically. An averaging block accumulates measurement -results, so a host-side-only `set_offset` in its body does not pull the -averaging host-side, while a host-side-only measurement does. The relaxation -never widens: an `Average` with op-children but no averaging-relevant one falls -back to all of them. - -Op-children whose own support is already empty are excluded from the consensus, -because folding an empty set in would manufacture a `mixed-domain` error on the -parent on top of the child's own diagnostic. The block's support still goes -empty, silently. Healthy op-children with disjoint singleton supports, on the -other hand, produce a `mixed-domain` error naming each child and its domains, -and the block's `available` and `support` are both set to the empty set. - -What the consensus does not do is widen a block to host-side because host-side -would work. An all-real-time block stays `{rt}` until something takes `"rt"` -away, and two things can: a `DomainConstraint` targeting the block, and a -block-child whose support is exactly `{host}`, which propagates an implicit -`exclude={"rt"}` upward because a real-time parent cannot host a host-side -sub-block. When that leaves the block with nothing, the (e2) fallback applies: -a block whose natural domain was exactly `{rt}`, whose own token check passed -host-side, and which lost `"rt"` to an exclusion, drops to `{host}`. The block's -iteration mechanism becomes host-side dispatch, one real-time shot per -iteration, and its op-children keep their own real-time support. That is the -rule that lets a swept parameter change a loop's class without changing the -class of the operations inside it. - -Two errors come out of the same step. If the support is empty, `empty-domain` -is reported with one of three messages, according to whether constraints -emptied it (the message lists their reasons), the block's own slot supported -nothing, or the slot and the op-children consensus were disjoint. If the -support is exactly `{rt}`, each block-child whose support lacks `"rt"` gets a -`host-in-rt` error. In practice that fires when the platform slot has no host -half at all, since with one the propagation above would have moved the parent to -host-side first. +A block's op-children and block-children are partitioned first (`_immediate_children`). A `Conditional` has no op-children at all: its arm bodies and its `else_body` are block-children. A `Parallel`'s loop headers are block-children alongside any block in its body, and its body's operations are its op-children. Every other block splits its `.elements` on `isinstance`. + +Both sets are recursed into before the block is judged. The natural domain is then the intersection of the supports of the op-children that gate the block, which is every op-child except in an `Average`, where it is only the averaging-relevant ones: those with `Operation.AFFECTS_AVERAGING`, which `MeasurementOperation` sets to `True` so that core `measure` and vendor `acquire` opt in automatically. An averaging block accumulates measurement results, so a host-side-only `set_offset` in its body does not pull the averaging host-side, while a host-side-only measurement does. The relaxation never widens: an `Average` with op-children but no averaging-relevant one falls back to all of them. + +Op-children whose own support is already empty are excluded from the consensus, because folding an empty set in would manufacture a `mixed-domain` error on the parent on top of the child's own diagnostic. The block's support still goes empty, silently. Healthy op-children with disjoint singleton supports, on the other hand, produce a `mixed-domain` error naming each child and its domains, and the block's `available` and `support` are both set to the empty set. + +What the consensus does not do is widen a block to host-side because host-side would work. An all-real-time block stays `{rt}` until something takes `"rt"` away, and two things can: a `DomainConstraint` targeting the block, and a block-child whose support is exactly `{host}`, which propagates an implicit `exclude={"rt"}` upward because a real-time parent cannot host a host-side sub-block. When that leaves the block with nothing, the (e2) fallback applies: a block whose natural domain was exactly `{rt}`, whose own token check passed host-side, and which lost `"rt"` to an exclusion, drops to `{host}`. The block's iteration mechanism becomes host-side dispatch, one real-time shot per iteration, and its op-children keep their own real-time support. That is the rule that lets a swept parameter change a loop's class without changing the class of the operations inside it. + +Two errors come out of the same step. If the support is empty, `empty-domain` is reported with one of three messages, according to whether constraints emptied it (the message lists their reasons), the block's own slot supported nothing, or the slot and the op-children consensus were disjoint. If the support is exactly `{rt}`, each block-child whose support lacks `"rt"` gets a `host-in-rt` error. In practice that fires when the platform slot has no host half at all, since with one the propagation above would have moved the parent to host-side first. ### `DomainConstraint` versus `Diagnostic` -A predicate chooses between the two by asking whether any domain survives. The -in-tree example is `_swept_parameter_forces_host` in `executor.py`, which the -reference platform installs on every slot; the two below are the shapes a -vendor predicate takes. +A predicate chooses between the two by asking whether any domain survives. The in-tree example is `_swept_parameter_forces_host` in `executor.py`, which the reference platform installs on every slot; the two below are the shapes a vendor predicate takes. ```python import qprogram as qp @@ -404,21 +222,11 @@ def drag_sigma_is_host_only(node, ctx): ) ``` -The two outputs travel differently. A `Diagnostic` marks its domain failed and -is held back unless every domain fails, then reaches the caller as written; the -validator does not rewrite the `code` or the `message`, so a vendor prefixes its -codes to keep them apart from the core ones. A `DomainConstraint` is routed to -its target block's bucket, subtracts from that block's support silently, and is -reported once by the classifier as the `forced-host` warning, with its `reason` -quoted in the message. A constraint whose `node` is not a `Block` is dropped and -reported as a `bad-domain-constraint` error naming the node whose predicate -emitted it, because there is no sound place to apply it: the (e2) fallback is -defined on the iteration mechanism of a loop, and an operation has none. +The two outputs travel differently. A `Diagnostic` marks its domain failed and is held back unless every domain fails, then reaches the caller as written; the validator does not rewrite the `code` or the `message`, so a vendor prefixes its codes to keep them apart from the core ones. A `DomainConstraint` is routed to its target block's bucket, subtracts from that block's support silently, and is reported once by the classifier as the `forced-host` warning, with its `reason` quoted in the message. A constraint whose `node` is not a `Block` is dropped and reported as a `bad-domain-constraint` error naming the node whose predicate emitted it, because there is no sound place to apply it: the (e2) fallback is defined on the iteration mechanism of a loop, and an operation has none. ### Diagnostic codes -Everything the validator can emit, with what it attaches to. Vendor predicates -add their own codes, which by convention carry a vendor prefix. +Everything the validator can emit, with what it attaches to. Vendor predicates add their own codes, which by convention carry a vendor prefix. | Code | Severity | Node | Emitted when | |---|---|---|---| @@ -433,78 +241,31 @@ add their own codes, which by convention carry a vendor prefix. | `forced-host` | warning | the highest forced block | A block's support fell to `{host}` while its `available` still contained `"rt"`. `domain` is `"host"`. | | `reorderable-averaging` | info | the `Average` | `qprogram.optimize` could rewrite this average to run in real-time hardware. | -`Diagnostic.__str__` renders as `[severity] code: message (at path)`, with the -path omitted when there is none. The `path` is the structural address of the -node under the program body, stamped in the final pass by walking -`qprogram.paths.iter_child_edges` and rebuilding each frozen `Diagnostic` with -`dataclasses.replace`. Because the `.qp` round trip preserves structure, the -same path resolves against `qp.loads(qp.dumps(p))`, whose `source_map` maps it -to a 1-based line. +`Diagnostic.__str__` renders as `[severity] code: message (at path)`, with the path omitted when there is none. The `path` is the structural address of the node under the program body, stamped in the final pass by walking `qprogram.paths.iter_child_edges` and rebuilding each frozen `Diagnostic` with `dataclasses.replace`. Because the `.qp` round trip preserves structure, the same path resolves against `qp.loads(qp.dumps(p))`, whose `source_map` maps it to a 1-based line. ### Whole-program limits -Four limit keys are checked, and which slot they are read from differs. -`max_loop_nesting`, `max_parallel_loops`, and `max_measurements` come from -`caps.platform`; `min_wait_duration_ns` comes from the routed bus slot of each -`Wait`. A key the profile does not declare is not checked, and a key the -validator does not know is ignored, so a profile may declare a limit an older -validator has no check for. - -Within a slot the limits are read from one half only: `_pick_limits` returns -`slot.rt.limits` when the slot has a real-time half, otherwise -`slot.host.limits`, otherwise an empty mapping. A limit declared only on the -host half of a slot that also has a real-time half is therefore never read. The -real-time engine is normally the tighter one, which is why it wins, but the -practical consequence is that a platform should put its limits on both halves. - -The observed values come from the context. `max_loop_nesting` is compared -against `ctx.max_loop_nesting`, which counts repetition levels: a block adds a -level when it declares `Block.REPEATS`, so `Sweep`, `Average`, and `Parallel` -each add one, a `Parallel` adds one however many loops it composes, and a -`Conditional` arm or a plain grouping block adds none. `max_parallel_loops` is -compared against `ctx.max_parallel_arity`, the largest `len(parallel.loops)` in -the program, and note the two names differ. `max_measurements` is compared -against `ctx.measurement_count`. - -The wait check applies only when `node.duration` is a plain `int`. A duration -given as an `Expression` has no static value to compare and is left unchecked, -which is why a swept wait is a predicate's business rather than a limit's. +Four limit keys are checked, and which slot they are read from differs. `max_loop_nesting`, `max_parallel_loops`, and `max_measurements` come from `caps.platform`; `min_wait_duration_ns` comes from the routed bus slot of each `Wait`. A key the profile does not declare is not checked, and a key the validator does not know is ignored, so a profile may declare a limit an older validator has no check for. + +Within a slot the limits are read from one half only: `_pick_limits` returns `slot.rt.limits` when the slot has a real-time half, otherwise `slot.host.limits`, otherwise an empty mapping. A limit declared only on the host half of a slot that also has a real-time half is therefore never read. The real-time engine is normally the tighter one, which is why it wins, but the practical consequence is that a platform should put its limits on both halves. + +The observed values come from the context. `max_loop_nesting` is compared against `ctx.max_loop_nesting`, which counts repetition levels: a block adds a level when it declares `Block.REPEATS`, so `Sweep`, `Average`, and `Parallel` each add one, a `Parallel` adds one however many loops it composes, and a `Conditional` arm or a plain grouping block adds none. `max_parallel_loops` is compared against `ctx.max_parallel_arity`, the largest `len(parallel.loops)` in the program, and note the two names differ. `max_measurements` is compared against `ctx.measurement_count`. + +The wait check applies only when `node.duration` is a plain `int`. A duration given as an `Expression` has no static value to compare and is left unchecked, which is why a swept wait is a predicate's business rather than a limit's. ### Conditional checks -Two checks run over every `Conditional` in the program regardless of profile, -because they are about the program being self-consistent rather than about what -a platform supports. Each arm condition is descended for `MeasurementRef` -leaves. A reference to a name that is not in `ctx.known_measurement_names()` -gives `unknown-measurement`, which usually means a `MeasurementRef` was built -outside `measure(...)`. A reference to `handle.state` whose source measurement -did not request `MeasurementField.STATE` in `fields=` gives -`missing-classification`. Both attach to the `Conditional`, not to the -reference. +Two checks run over every `Conditional` in the program regardless of profile, because they are about the program being self-consistent rather than about what a platform supports. Each arm condition is descended for `MeasurementRef` leaves. A reference to a name that is not in `ctx.known_measurement_names()` gives `unknown-measurement`, which usually means a `MeasurementRef` was built outside `measure(...)`. A reference to `handle.state` whose source measurement did not request `MeasurementField.STATE` in `fields=` gives `missing-classification`. Both attach to the `Conditional`, not to the reference. ### The execution plan is identity-keyed -AST nodes use structural equality and hashing, which is right for round-trip -comparison and wrong for the classifier's bookkeeping: a plain `dict` would -collapse two identical `play` operations into one entry and hand the compiler a -plan with nodes missing. The plan is an `_IdentityNodeMap`, a `MutableMapping` -keyed by `id()` that holds a reference to each key so ids cannot be recycled. It -satisfies the public `Mapping` contract: iteration yields the node objects and -`plan[node]` resolves by identity. +AST nodes use structural equality and hashing, which is right for round-trip comparison and wrong for the classifier's bookkeeping: a plain `dict` would collapse two identical `play` operations into one entry and hand the compiler a plan with nodes missing. The plan is an `_IdentityNodeMap`, a `MutableMapping` keyed by `id()` that holds a reference to each key so ids cannot be recycled. It satisfies the public `Mapping` contract: iteration yields the node objects and `plan[node]` resolves by identity. -So `len(plan)` counts node instances: a three-operation program has three -entries even when two of them are equal. A structurally identical node that is -not in the program raises `KeyError` on lookup. The root `body` block has no -entry; the plan covers everything below it. +So `len(plan)` counts node instances: a three-operation program has three entries even when two of them are equal. A structurally identical node that is not in the program raises `KeyError` on lookup. The root `body` block has no entry; the plan covers everything below it. ## `ValidationContext` queries -A `Predicate` is any callable with the signature -`(node, ctx) -> Iterable[Diagnostic | DomainConstraint]`; `Predicate` is a -runtime-checkable `Protocol` and `PredicateFn` is the same shape as a -`Callable` alias. The context is built once per `validate()` call by -`_build_context`, has a keyword-only constructor, and is read-only. Predicates -must treat it as immutable. +A `Predicate` is any callable with the signature `(node, ctx) -> Iterable[Diagnostic | DomainConstraint]`; `Predicate` is a runtime-checkable `Protocol` and `PredicateFn` is the same shape as a `Callable` alias. The context is built once per `validate()` call by `_build_context`, has a keyword-only constructor, and is read-only. Predicates must treat it as immutable. | Method | Returns | |---|---| @@ -517,33 +278,21 @@ must treat it as immutable. | `known_measurement_names()` | Every measurement name in the program, whether the author spelled it or the builder allocated it. | | `program_buses` (property) | Every bus referenced anywhere in the program. Elements may be `BusRef` instances, which subclass `str`, so per-bus routing over them keeps its schema awareness. | -The fields mapping is keyed by name, so two measurements sharing a name keep -one entry between them while both still count toward `measurement_count`. +The fields mapping is keyed by name, so two measurements sharing a name keep one entry between them while both still count toward `measurement_count`. -To add a query, add the method to `ValidationContext`, populate the underlying -data in the `visit` closure of `validation._build_context`, and document it -here. The surface is kept small so that predicate authors can read all of it. +To add a query, add the method to `ValidationContext`, populate the underlying data in the `visit` closure of `validation._build_context`, and document it here. The surface is kept small so that predicate authors can read all of it. ## Token registry -`CAPABILITY_REGISTRY` starts as a copy of `_BASE_TOKENS`, the canonical set of -every dotted token an in-tree node may emit: eleven `op.*`, five `block.*`, -three waveform channel kinds and seventeen per-class waveform tokens, two -`sweep.` and eight `sweep.`, nine `expr.*` node kinds and nine -`expr.math.*` functions, and three `measure.fields.*`. It exists for two -reasons. +`CAPABILITY_REGISTRY` starts as a copy of `_BASE_TOKENS`, the canonical set of every dotted token an in-tree node may emit: eleven `op.*`, five `block.*`, three waveform channel kinds and seventeen per-class waveform tokens, two `sweep.` and eight `sweep.`, nine `expr.*` node kinds and nine `expr.math.*` functions, and three `measure.fields.*`. It exists for two reasons. -The first is typo defense. `Profile.__post_init__` calls `validate_tokens` on -the profile's capability set, so an unknown token raises at -profile-construction time rather than being silently absent at validate time: +The first is typo defense. `Profile.__post_init__` calls `validate_tokens` on the profile's capability set, so an unknown token raises at profile-construction time rather than being silently absent at validate time: ``` ValueError: Unknown capability token(s): ['op.playy']. Register via qprogram.protocol.register_capability_tokens before use. ``` -The second is discoverability: reading `qp.protocol.CAPABILITY_REGISTRY` tells -a vendor author which tokens exist without grepping the source, and the read is -live, so it includes whatever the imported vendor packages have added. +The second is discoverability: reading `qp.protocol.CAPABILITY_REGISTRY` tells a vendor author which tokens exist without grepping the source, and the read is live, so it includes whatever the imported vendor packages have added. The registry is mutable, and vendor packages add to it at import time: @@ -553,24 +302,15 @@ import qprogram as qp qp.register_capability_tokens("vendor.myvendor.acquire", "vendor.myvendor.set_markers") ``` -`register_capability_tokens` is idempotent, and rejects a token that is empty, -starts or ends with `.`, or contains `..`: +`register_capability_tokens` is idempotent, and rejects a token that is empty, starts or ends with `.`, or contains `..`: ``` ValueError: Invalid capability token 'vendor..myvendor' (empty / leading-dot / trailing-dot / doubled dot) ``` -That is a shape check, not a namespace policy: each vendor owns its own -`vendor..*` prefix, and nothing stops a package registering a token -outside it. +That is a shape check, not a namespace policy: each vendor owns its own `vendor..*` prefix, and nothing stops a package registering a token outside it. -One namespace is load-bearing beyond validation. `measure.fields.` tokens -are the single source of truth for which `fields=` names exist: -`known_measurement_fields()` derives the set from the live registry, and -`normalize_fields` uses it to reject a typo at the `measure(...)` call site. So -`qp.register_capability_tokens("measure.fields.demod")` widens the DSL's field -vocabulary as well as the token registry, and `measurement_field_token("demod")` -builds the token name for you. +One namespace is load-bearing beyond validation. `measure.fields.` tokens are the single source of truth for which `fields=` names exist: `known_measurement_fields()` derives the set from the live registry, and `normalize_fields` uses it to reject a typo at the `measure(...)` call site. So `qp.register_capability_tokens("measure.fields.demod")` widens the DSL's field vocabulary as well as the token registry, and `measurement_field_token("demod")` builds the token name for you. ## Waveform-class dispatch @@ -581,14 +321,7 @@ builds the token name for you. WAVEFORM_TOKEN: dict[type, str] = {} ``` -The map starts empty and `_register_builtin_waveform_tokens` fills it with the -seventeen built-ins on the first `waveform_token()` call, importing -`qprogram.waveforms` inside the function to break the import cycle between the -two modules. Its guard is that the population is skipped whenever the map is -already non-empty, so the order of the first two writes matters: a package that -registers a waveform class before anything has classified a waveform suppresses -the built-in population, and `waveform_token(Square(...))` then returns `None` -and the play loses its `waveform.square` refinement. +The map starts empty and `_register_builtin_waveform_tokens` fills it with the seventeen built-ins on the first `waveform_token()` call, importing `qprogram.waveforms` inside the function to break the import cycle between the two modules. Its guard is that the population is skipped whenever the map is already non-empty, so the order of the first two writes matters: a package that registers a waveform class before anything has classified a waveform suppresses the built-in population, and `waveform_token(Square(...))` then returns `None` and the play loses its `waveform.square` refinement. Vendor packages register their own classes through the same map: @@ -598,18 +331,9 @@ import qprogram as qp qp.register_waveform_token(MyCustomPulse, "waveform.my_custom_pulse") ``` -`register_waveform_token` also calls `register_capability_tokens(token)`, so a -vendor never has to call both, and a profile that lists the token validates. +`register_waveform_token` also calls `register_capability_tokens(token)`, so a vendor never has to call both, and a profile that lists the token validates. -Dispatch is on the exact class: `waveform_token` returns -`WAVEFORM_TOKEN.get(type(wf))`, not the first matching base class, so a -subclass of `Square` gets no token until it registers its own. The function -returns `None` for a `str` alias, which is why `Play` adds `waveform.alias` -itself, and `None` for an unregistered class. An unregistered waveform still -contributes its channel kind, `waveform.single` or `waveform.iq`, because that -comes from an `isinstance` check in `required_capabilities` rather than from the -map; it just carries no per-class refinement. A prototype waveform is therefore -validated as a generic pulse rather than rejected. +Dispatch is on the exact class: `waveform_token` returns `WAVEFORM_TOKEN.get(type(wf))`, not the first matching base class, so a subclass of `Square` gets no token until it registers its own. The function returns `None` for a `str` alias, which is why `Play` adds `waveform.alias` itself, and `None` for an unregistered class. An unregistered waveform still contributes its channel kind, `waveform.single` or `waveform.iq`, because that comes from an `isinstance` check in `required_capabilities` rather than from the map; it just carries no per-class refinement. A prototype waveform is therefore validated as a generic pulse rather than rejected. ## Profile bundles @@ -628,39 +352,15 @@ class Profile: vendor_versions: Mapping[str, tuple[int, int, int]] = field(default_factory=dict) ``` -`name`, `version`, `extends`, and `capabilities` are required; the rest default -to empty. `vendor_versions` is informational, recording which vendor extension -versions the profile was written against, and mirrors the `.qp` -`require ` line. `__post_init__` validates the capability set -against `CAPABILITY_REGISTRY`, so a vendor's `profiles.py` has to register its -tokens before it constructs the `Profile`, not after. - -Profiles are domain-agnostic. Nothing in a `Profile` says `rt` or `host`; the -platform decides which profile fills each half of each slot, and the same -profile commonly fills both. Core ships `qprogram-base-v1` in -`src/qprogram/profiles.py`, which vendors use for the platform-level slot either -by naming it in `CompilerCapabilities.from_profile` or by declaring their own -profile with `extends="qprogram-base-v1"`. Naming a bundle and letting another -bundle extend it is the arrangement QIR profiles use, where each profile names a -subset of the instruction set a backend accepts and a larger profile builds on a -smaller one. +`name`, `version`, `extends`, and `capabilities` are required; the rest default to empty. `vendor_versions` is informational, recording which vendor extension versions the profile was written against, and mirrors the `.qp` `require ` line. `__post_init__` validates the capability set against `CAPABILITY_REGISTRY`, so a vendor's `profiles.py` has to register its tokens before it constructs the `Profile`, not after. + +Profiles are domain-agnostic. Nothing in a `Profile` says `rt` or `host`; the platform decides which profile fills each half of each slot, and the same profile commonly fills both. Core ships `qprogram-base-v1` in `src/qprogram/profiles.py`, which vendors use for the platform-level slot either by naming it in `CompilerCapabilities.from_profile` or by declaring their own profile with `extends="qprogram-base-v1"`. Naming a bundle and letting another bundle extend it is the arrangement QIR profiles use, where each profile names a subset of the instruction set a backend accepts and a larger profile builds on a smaller one. ### Registering a profile -`register_profile(profile)` adds the profile to `PROFILE_REGISTRY` under its -`name`. It is idempotent for an equal `Profile`, so a module whose import side -effects run twice is safe even when it builds the bundle in a factory rather than -holding it as a constant, and raises -`ValueError: Profile 'x' is already registered with different content` only when -the content actually differs. Of an equal pair the registry keeps the first -object, so mutating `limits` on the one you built will not be visible through -`from_profile`. Predicates compare as objects, so a profile carrying a `lambda`, -a closure, or a `functools.partial` is never equal to a rebuild of itself. -`resolve_profile(name)` is the read side, and raises `KeyError` with the -currently registered names listed in the message. - -Vendor packages register at import time, alongside their vendor-namespace, -vendor-version, and operation registration: +`register_profile(profile)` adds the profile to `PROFILE_REGISTRY` under its `name`. It is idempotent for an equal `Profile`, so a module whose import side effects run twice is safe even when it builds the bundle in a factory rather than holding it as a constant, and raises `ValueError: Profile 'x' is already registered with different content` only when the content actually differs. Of an equal pair the registry keeps the first object, so mutating `limits` on the one you built will not be visible through `from_profile`. Predicates compare as objects, so a profile carrying a `lambda`, a closure, or a `functools.partial` is never equal to a rebuild of itself. `resolve_profile(name)` is the read side, and raises `KeyError` with the currently registered names listed in the message. + +Vendor packages register at import time, alongside their vendor-namespace, vendor-version, and operation registration: ```python # qprogram-myvendor/src/qprogram_myvendor/__init__.py @@ -671,8 +371,7 @@ _register_myvendor_profile() ### Building `CompilerCapabilities` from a profile -`CompilerCapabilities.from_profile` materializes a descriptor from a registered -profile: +`CompilerCapabilities.from_profile` materializes a descriptor from a registered profile: ```python # src/qprogram/protocol.py @@ -686,21 +385,9 @@ def from_profile( ) -> CompilerCapabilities: ... ``` -It resolves the name through `resolve_profile`, which raises `KeyError` if it is -not registered, then walks the `extends` chain with `_profile_chain`, which -returns it root-first and raises -`ValueError: Profile inheritance cycle detected at 'a'` if a profile is revisited. -Walking root-first is what makes a child override a parent. Along the chain -capabilities are unioned and predicates are accumulated in parent-to-child -order, while limits and `vendor_versions` are merged key by key so the leaf -wins. Then `limit_overrides` is applied on top, which is where a live device -tightens a limit its profile states loosely, and `extra_predicates` is appended -after the profile's own, which is where a rack-level constraint goes that does -not belong in the vendor-shipped profile. - -The resulting `profile` and `version` fields name the leaf, not the chain, so a -diagnostic about a token inherited from `qprogram-base-v1` names the vendor -profile that pulled it in. +It resolves the name through `resolve_profile`, which raises `KeyError` if it is not registered, then walks the `extends` chain with `_profile_chain`, which returns it root-first and raises `ValueError: Profile inheritance cycle detected at 'a'` if a profile is revisited. Walking root-first is what makes a child override a parent. Along the chain capabilities are unioned and predicates are accumulated in parent-to-child order, while limits and `vendor_versions` are merged key by key so the leaf wins. Then `limit_overrides` is applied on top, which is where a live device tightens a limit its profile states loosely, and `extra_predicates` is appended after the profile's own, which is where a rack-level constraint goes that does not belong in the vendor-shipped profile. + +The resulting `profile` and `version` fields name the leaf, not the chain, so a diagnostic about a token inherited from `qprogram-base-v1` names the vendor profile that pulled it in. ```python import qprogram as qp @@ -717,35 +404,20 @@ tight.limits["max_loop_nesting"] # 4, the override value as given ## Routing rules in detail -`PlatformCapabilities.for_bus(bus)` resolves a bus to a slot. A `BusRef` whose -`schema` is not `None` looks up `bus[(ref.element, ref.kind)]` and falls back to -`default_bus_profile` when that key is absent. A plain `str`, or a `BusRef` with -no schema, always goes to `default_bus_profile`. +`PlatformCapabilities.for_bus(bus)` resolves a bus to a slot. A `BusRef` whose `schema` is not `None` looks up `bus[(ref.element, ref.kind)]` and falls back to `default_bus_profile` when that key is absent. A plain `str`, or a `BusRef` with no schema, always goes to `default_bus_profile`. `validation._route(node, caps, ctx)` decides which slots a node touches: - Blocks route to `[caps.platform]`. -- Operations whose `BUS_ATTRS` is empty, such as `Call`, route to - `[caps.platform]`. -- Bus-touching operations route to one slot per bus value read off their - `BUS_ATTRS`, collecting plain strings and the string elements of lists. The - caller intersects across the list, which is what makes a multi-target `Sync` - need its token on every bus it names. -- An operation whose bus list comes out empty and whose class sets - `BROADCASTS_WHEN_NO_BUS = True`, which is `Sync(targets=None)`, routes to one - slot per bus in `sorted(ctx.program_buses)`, so the broadcast form and the - explicit-targets form intersect the same way. With no buses in the program at - all, or for a non-broadcast operation with an empty bus list, the fallback is - `[caps.default_bus_profile]`. - -The list is never empty, so every node is checked against at least one slot. -Within the routed slots, `expr.*` tokens are checked against `caps.platform` -and everything else against the routed slots. +- Operations whose `BUS_ATTRS` is empty, such as `Call`, route to `[caps.platform]`. +- Bus-touching operations route to one slot per bus value read off their `BUS_ATTRS`, collecting plain strings and the string elements of lists. The caller intersects across the list, which is what makes a multi-target `Sync` need its token on every bus it names. +- An operation whose bus list comes out empty and whose class sets `BROADCASTS_WHEN_NO_BUS = True`, which is `Sync(targets=None)`, routes to one slot per bus in `sorted(ctx.program_buses)`, so the broadcast form and the explicit-targets form intersect the same way. With no buses in the program at all, or for a non-broadcast operation with an empty bus list, the fallback is `[caps.default_bus_profile]`. + +The list is never empty, so every node is checked against at least one slot. Within the routed slots, `expr.*` tokens are checked against `caps.platform` and everything else against the routed slots. ## How `PlatformProtocol` consumes the descriptor -`PlatformProtocol` also carries the platform's schema, parameter, and `execute` -surface; the four capability-facing members are: +`PlatformProtocol` also carries the platform's schema, parameter, and `execute` surface; the four capability-facing members are: ```python # src/qprogram/platform.py @@ -772,25 +444,11 @@ class PlatformProtocol(ABC): return _explain(qprogram, self.capabilities) ``` -`capabilities` is a property rather than a method because callers introspect it -like data. A concrete platform typically builds its `PlatformCapabilities` once -in `__init__` and returns the cached object, since `validate` reads it on every -call. An `execute()` that gates on diagnostics and then compiles against the -plan should call `qprogram.validation.validate` directly rather than -`self.validate` followed by `self.plan`, which would walk the program twice. - -The convention for `execute()` is to validate first, raise -`UnsupportedOperationError` on any `severity="error"` diagnostic, surface -`severity="warning"` without raising, and pass `severity="info"` through as -advisory. `ReferencePlatform` in `qprogram.executor` does exactly that, warning -through the `ExecutionWarning` category, and is the worked example. The base -class does not enforce the convention, because platforms differ in where they -put the gate. - -`qp.reference_capabilities()` builds that platform's descriptor from the live -token registry, with `set_parameter` and `get_parameter` present only in each -bus slot's `host` half and one predicate installed on every slot, so it is a -real descriptor to try a program against: +`capabilities` is a property rather than a method because callers introspect it like data. A concrete platform typically builds its `PlatformCapabilities` once in `__init__` and returns the cached object, since `validate` reads it on every call. An `execute()` that gates on diagnostics and then compiles against the plan should call `qprogram.validation.validate` directly rather than `self.validate` followed by `self.plan`, which would walk the program twice. + +The convention for `execute()` is to validate first, raise `UnsupportedOperationError` on any `severity="error"` diagnostic, surface `severity="warning"` without raising, and pass `severity="info"` through as advisory. `ReferencePlatform` in `qprogram.executor` does exactly that, warning through the `ExecutionWarning` category, and is the worked example. The base class does not enforce the convention, because platforms differ in where they put the gate. + +`qp.reference_capabilities()` builds that platform's descriptor from the live token registry, with `set_parameter` and `get_parameter` present only in each bus slot's `host` half and one predicate installed on every slot, so it is a real descriptor to try a program against: ```python import qprogram as qp @@ -805,89 +463,46 @@ with p.average(100), p.sweep(amp, qp.Range(0.0, 1.0, 0.1)): diagnostics, plan = qp.validate(p, caps) ``` -The sweep drives a `set_parameter`, so the core predicate constrains the -binding loop out of real-time, the enclosing average follows it host-side, and -the two advisory diagnostics come back: +The sweep drives a `set_parameter`, so the core predicate constrains the binding loop out of real-time, the enclosing average follows it host-side, and the two advisory diagnostics come back: ``` [warning] forced-host: Block 'Average' falls back to host-side execution: contains host-side-only sub-block 'Sweep' (parameter 'power' is swept via set_parameter (host-side dispatch per iteration)). (at body[0]) [info] reorderable-averaging: Block 'Average' runs host-side only because it encloses a host-side sweep; its measurement sequence supports real-time hardware. Moving the sweep outside the average (hoisting the host-side-only setup with it) would let the averaging run in real-time hardware — see qprogram.optimize(). (at body[0]) ``` -The `Measure` keeps `{"rt", "host"}` in the plan while the `Sweep` and the -`Average` above it are `{"host"}`: the loop's iteration mechanism moved, not the -measurement. The hint fires only for the shape `qprogram.optimize` can actually -rewrite, which the validator and the rewrite agree on by sharing one predicate, -`validation.reorderable_average_split`: the average's sole child is a flat -`Sweep` whose body is a leading contiguous run of host-side-only operations -followed by real-time-capable ones including at least one that affects -averaging. A host-side-only operation after a kept one cannot be hoisted without -reordering it past that operation, so such an average is not reorderable and -gets no hint. +The `Measure` keeps `{"rt", "host"}` in the plan while the `Sweep` and the `Average` above it are `{"host"}`: the loop's iteration mechanism moved, not the measurement. The hint fires only for the shape `qprogram.optimize` can actually rewrite, which the validator and the rewrite agree on by sharing one predicate, `validation.reorderable_average_split`: the average's sole child is a flat `Sweep` whose body is a leading contiguous run of host-side-only operations followed by real-time-capable ones including at least one that affects averaging. A host-side-only operation after a kept one cannot be hoisted without reordering it past that operation, so such an average is not reorderable and gets no hint. ## Adding things ### A new capability token -Add it to `_BASE_TOKENS` in `protocol.py`, under the prefix that matches its -category. Tokens are flat strings and the dots carry no structure; nothing -parses a prefix except the `expr.` test in the validator and the -`measure.fields.` test in `known_measurement_fields`. +Add it to `_BASE_TOKENS` in `protocol.py`, under the prefix that matches its category. Tokens are flat strings and the dots carry no structure; nothing parses a prefix except the `expr.` test in the validator and the `measure.fields.` test in `known_measurement_fields`. -If the token is vendor-specific, do not edit `_BASE_TOKENS`. Register it from -the vendor package with `register_capability_tokens(...)` instead. -`tests/test_protocol.py::test_register_capability_tokens_rejects_malformed_tokens` -guards the shape rules. +If the token is vendor-specific, do not edit `_BASE_TOKENS`. Register it from the vendor package with `register_capability_tokens(...)` instead. `tests/test_protocol.py::test_register_capability_tokens_rejects_malformed_tokens` guards the shape rules. ### A new waveform token -Either add the class to `_register_builtin_waveform_tokens` in `protocol.py` -for a core waveform, or call `register_waveform_token(cls, token)` from a vendor -package's `__init__.py` for a vendor one. The function adds the token to -`CAPABILITY_REGISTRY` for you, so a profile that lists it validates. +Either add the class to `_register_builtin_waveform_tokens` in `protocol.py` for a core waveform, or call `register_waveform_token(cls, token)` from a vendor package's `__init__.py` for a vendor one. The function adds the token to `CAPABILITY_REGISTRY` for you, so a profile that lists it validates. ### A new core operation -Implement `required_capabilities(self) -> set[str]` returning the operation's -identity token plus any refinement tokens computed from instance state, and add -the identity token to `_BASE_TOKENS`. Decide where it belongs: a bus-touching -operation's token goes on bus profiles, a bus-less operation's on the platform -profile. If the operation is one an `average` block accumulates, set -`AFFECTS_AVERAGING = True` on the class. See -[Adding operations](adding-operations.md) for the full walkthrough. +Implement `required_capabilities(self) -> set[str]` returning the operation's identity token plus any refinement tokens computed from instance state, and add the identity token to `_BASE_TOKENS`. Decide where it belongs: a bus-touching operation's token goes on bus profiles, a bus-less operation's on the platform profile. If the operation is one an `average` block accumulates, set `AFFECTS_AVERAGING = True` on the class. See [Adding operations](adding-operations.md) for the full walkthrough. ### A new vendor operation -The vendor walkthrough at -[Building a vendor extension](vendor-extensions.md) covers the protocol side: -implement `required_capabilities()` returning `{"vendor.."}` plus any -refinement, register the token with `register_capability_tokens(...)`, and -include it in the profile's capability set. +The vendor walkthrough at [Building a vendor extension](vendor-extensions.md) covers the protocol side: implement `required_capabilities()` returning `{"vendor.."}` plus any refinement, register the token with `register_capability_tokens(...)`, and include it in the profile's capability set. ### A new profile -Create a `Profile` in the vendor package's `profiles.py`, named following the -`--v` convention, optionally extending an existing -profile, and register it with `register_profile(profile)` from the package's -`__init__.py`. Register the tokens it lists first, since the constructor -validates them. +Create a `Profile` in the vendor package's `profiles.py`, named following the `--v` convention, optionally extending an existing profile, and register it with `register_profile(profile)` from the package's `__init__.py`. Register the tokens it lists first, since the constructor validates them. ### A new `ValidationContext` query -Add the method to `ValidationContext`, populate the underlying data in -`validation._build_context`, and document it on this page and in the guide. -Keep the surface small, because predicate authors read all of it. +Add the method to `ValidationContext`, populate the underlying data in `validation._build_context`, and document it on this page and in the guide. Keep the surface small, because predicate authors read all of it. ### A new domain-constraint predicate -Write it as a callable returning `Iterable[Diagnostic | DomainConstraint]`. -Yield a `DomainConstraint(node, exclude, reason)` for a restriction that leaves -another domain working, targeting the block whose iteration mechanism has to -change, which is usually the binding loop from `ctx.binding_loop_of`. Yield a -`Diagnostic` when no domain can run the node, and give it a vendor-prefixed -code. Register it on the profile of the slot where it should fire, which for a -bus-touching operation is the bus profile, and remember that it will be called -once per (domain, slot) pair. +Write it as a callable returning `Iterable[Diagnostic | DomainConstraint]`. Yield a `DomainConstraint(node, exclude, reason)` for a restriction that leaves another domain working, targeting the block whose iteration mechanism has to change, which is usually the binding loop from `ctx.binding_loop_of`. Yield a `Diagnostic` when no domain can run the node, and give it a vendor-prefixed code. Register it on the profile of the slot where it should fire, which for a bus-touching operation is the bus profile, and remember that it will be called once per (domain, slot) pair. ## Testing @@ -901,41 +516,21 @@ The protocol has dedicated test modules: | `tests/test_explain.py`, `tests/test_paths.py` | Plan rendering, and the structural paths diagnostics are stamped with. | | A vendor package's `tests/test_profile.py` | Vendor profile integration: registration, happy path, predicate, `DomainConstraint` flow. | -`tests/test_validation.py` builds its descriptors from two token sets and a -`_slot` helper whose `rt=` and `host=` flags drop a half, which is the shortest -way to write a platform where one bus has no real-time engine. Copy that shape -rather than a real vendor profile when what you are testing is the validator. +`tests/test_validation.py` builds its descriptors from two token sets and a `_slot` helper whose `rt=` and `host=` flags drop a half, which is the shortest way to write a platform where one bus has no real-time engine. Copy that shape rather than a real vendor profile when what you are testing is the validator. -When you add an operation, mirror an existing `test_required_capabilities.py` -block and cover at least one case per refinement axis: for an operation with a -waveform attribute, that means the single-channel path, the IQ path, and the -string alias. +When you add an operation, mirror an existing `test_required_capabilities.py` block and cover at least one case per refinement axis: for an operation with a waveform attribute, that means the single-channel path, the IQ path, and the string alias. -When you add a profile, mirror a vendor `test_profile.py`: register the profile, -build the descriptor, validate a representative happy-path program, and -exercise each predicate you ship in both the firing and the non-firing case. +When you add a profile, mirror a vendor `test_profile.py`: register the profile, build the descriptor, validate a representative happy-path program, and exercise each predicate you ship in both the firing and the non-firing case. ## Out of scope -Three things the protocol does not do, with where the change would go if it had -to: - -- **Subtractive profiles.** `extends` only adds, so a vendor whose constraints - are narrower than a parent's builds a profile from scratch rather than - removing from one. A `removes=` field would be a localized change to - `_profile_chain` in `protocol.py`. -- **Profile names in `.qp` files.** Profiles are platform-side; the file format - carries vendor requirements through `require `, and nothing - in it names a profile. -- **Domains beyond `rt` and `host`.** `Domain` is a `Literal` of exactly the - language's two execution domains. Adding a third, host-side dispatch to a - remote runner for instance, means widening the literal and revisiting the - classifier, which currently reasons about a two-element set by intersection - and one rt-to-host fallback. +Three things the protocol does not do, with where the change would go if it had to: + +- **Subtractive profiles.** `extends` only adds, so a vendor whose constraints are narrower than a parent's builds a profile from scratch rather than removing from one. A `removes=` field would be a localized change to `_profile_chain` in `protocol.py`. +- **Profile names in `.qp` files.** Profiles are platform-side; the file format carries vendor requirements through `require `, and nothing in it names a profile. +- **Domains beyond `rt` and `host`.** `Domain` is a `Literal` of exactly the language's two execution domains. Adding a third, host-side dispatch to a remote runner for instance, means widening the literal and revisiting the classifier, which currently reasons about a two-element set by intersection and one rt-to-host fallback. ## See also -- [Architecture](architecture.md): where the capability protocol sits in the - rest of the codebase. -- [API reference](../reference/api-qprogram.md#capability-protocol): the - generated reference. +- [Architecture](architecture.md): where the capability protocol sits in the rest of the codebase. +- [API reference](../reference/api-qprogram.md#capability-protocol): the generated reference. diff --git a/docs/developer/contributing.md b/docs/developer/contributing.md index 395176a..84ee96e 100644 --- a/docs/developer/contributing.md +++ b/docs/developer/contributing.md @@ -1,21 +1,12 @@ # Contributing -Open a small pull request, run the linter, the type checker, and the tests, and -bring the docs along for anything that changes user-visible behavior. What -follows is the detail behind that. +Open a small pull request, run the linter, the type checker, and the tests, and bring the docs along for anything that changes user-visible behavior. What follows is the detail behind that. ## Before you start -Skim [Architecture](architecture.md) first, which explains the AST builder -pattern, the vendor-extension hooks, and the serialization registry; most -changes touch one of these. Then read the reference section: -[The `.qp` file format](../reference/qp-format.md) is the normative description -of the text format, `src/qprogram/grammar/qp.lark` is its machine-readable -form, and the [API reference](../reference/api-qprogram.md) is generated from -the docstrings under `src/`, so the code and the reference move together. +Skim [Architecture](architecture.md) first, which explains the AST builder pattern, the vendor-extension hooks, and the serialization registry; most changes touch one of these. Then read the reference section: [The `.qp` file format](../reference/qp-format.md) is the normative description of the text format, `src/qprogram/grammar/qp.lark` is its machine-readable form, and the [API reference](../reference/api-qprogram.md) is generated from the docstrings under `src/`, so the code and the reference move together. -A change that alters user-visible behavior changes the docs in the same pull -request. Say so in the description, and name the guide pages you touched. +A change that alters user-visible behavior changes the docs in the same pull request. Say so in the description, and name the guide pages you touched. ## Development workflow @@ -26,9 +17,7 @@ request. Say so in the description, and name the guide pages you touched. cd qprogram ``` -2. **Install the package.** The `dev` dependency group installs by default, so - this one command gets the linter, the type checker, pytest, hypothesis, and - lark alongside the package and its extras. +2. **Install the package.** The `dev` dependency group installs by default, so this one command gets the linter, the type checker, pytest, hypothesis, and lark alongside the package and its extras. ```bash uv sync --all-extras @@ -41,41 +30,31 @@ request. Say so in the description, and name the guide pages you touched. uv run --all-extras --group docs zensical serve ``` - `--all-extras` matters here: mkdocstrings imports the package to render the - API reference, so the project and its optional dependencies have to be - installed, not just the docs tooling. + `--all-extras` matters here: mkdocstrings imports the package to render the API reference, so the project and its optional dependencies have to be installed, not just the docs tooling. 4. **Make your change.** -5. **Lint and format.** Both run from `pyproject.toml` and neither imports the - code. +5. **Lint and format.** Both run from `pyproject.toml` and neither imports the code. ```bash uv run ruff check . uv run ruff format . ``` -6. **Type-check.** `[tool.ty.src]` sets `include = ["src"]`, so this covers the - package and not the test suite. +6. **Type-check.** `[tool.ty.src]` sets `include = ["src"]`, so this covers the package and not the test suite. ```bash uv run ty check ``` -7. **Run the tests.** See [Testing](testing.md) for what the suite covers and - what a new feature needs from it. +7. **Run the tests.** See [Testing](testing.md) for what the suite covers and what a new feature needs from it. ```bash uv run pytest uv run pytest --cov=qprogram ``` -8. **Update the docs.** Anything user-visible needs an entry in the relevant - guide page. New operations, waveforms, and sweep sources also need a mention - in [`docs/reference/qp-format.md`](../reference/qp-format.md), and a new - public symbol needs a `::: qprogram.` directive in - [`docs/reference/api-qprogram.md`](../reference/api-qprogram.md). A new page - is unreachable until it appears in the nav in `zensical.toml`. +8. **Update the docs.** Anything user-visible needs an entry in the relevant guide page. New operations, waveforms, and sweep sources also need a mention in [`docs/reference/qp-format.md`](../reference/qp-format.md), and a new public symbol needs a `::: qprogram.` directive in [`docs/reference/api-qprogram.md`](../reference/api-qprogram.md). A new page is unreachable until it appears in the nav in `zensical.toml`. 9. **Build the site**, if you touched docstrings or a reference page. @@ -83,102 +62,41 @@ request. Say so in the description, and name the guide pages you touched. uv run --all-extras --group docs zensical build --strict ``` - `--strict` turns a warning into a failure. The one that matters is an - unresolved mkdocstrings cross-reference, which would otherwise ship as a - dead link. This is the same command `docs.yml` runs. + `--strict` turns a warning into a failure. The one that matters is an unresolved mkdocstrings cross-reference, which would otherwise ship as a dead link. This is the same command `docs.yml` runs. -10. **Add a changelog entry.** Anything a user would notice gets one news - fragment under `changelog/`, named `..md`. The types are - `added`, `changed`, `fixed`, `removed`, and `misc`; the first four render - their text under a heading of the same name, while `misc` is configured with - `showcontent = false`, so a `misc` fragment contributes only its pull - request link. +10. **Add a changelog entry.** Anything a user would notice gets one news fragment under `changelog/`, named `..md`. The types are `added`, `changed`, `fixed`, `removed`, and `misc`; the first four render their text under a heading of the same name, while `misc` is configured with `showcontent = false`, so a `misc` fragment contributes only its pull request link. ```bash uv run towncrier create 123.added.md ``` - That writes a file containing the placeholder `Add your info here`, which - you then replace with one or two sentences about what changed for somebody - using the library; `--content "..."` sets the text in the same command. A - fragment written before the pull request has a number takes a `+` prefix and - any name, as in `+lazy-waveform-binding.added.md`; towncrier renders such a - fragment without a link, so rename it once the number exists. Internal - refactors, test-only changes, and docs corrections do not need one. - -11. **Open the pull request.** Three of the workflows under - `.github/workflows/` run on it, and each skips while the pull request is a - draft: `tests.yml` runs the suite on Python 3.11 and 3.14 and uploads - coverage from the 3.13 job on `main`; `code_quality.yml` runs `ruff check` - and `ruff format --diff` once on Python 3.13, then `ty check` once per - supported version from 3.11 through 3.14; and `docs.yml` builds this site - with `zensical build --strict`. [Testing](testing.md#ci) describes the jobs - in more detail. + That writes a file containing the placeholder `Add your info here`, which you then replace with one or two sentences about what changed for somebody using the library; `--content "..."` sets the text in the same command. A fragment written before the pull request has a number takes a `+` prefix and any name, as in `+lazy-waveform-binding.added.md`; towncrier renders such a fragment without a link, so rename it once the number exists. Internal refactors, test-only changes, and docs corrections do not need one. + +11. **Open the pull request.** Three of the workflows under `.github/workflows/` run on it, and each skips while the pull request is a draft: `tests.yml` runs the suite on Python 3.11 and 3.14 and uploads coverage from the 3.13 job on `main`; `code_quality.yml` runs `ruff check` and `ruff format --diff` once on Python 3.13, then `ty check` once per supported version from 3.11 through 3.14; and `docs.yml` builds this site with `zensical build --strict`. [Testing](testing.md#ci) describes the jobs in more detail. ## What "small PR" means -One concept per pull request. A new operation plus a new waveform plus a bug fix -in the parser is three pull requests. Each one is easier to review, easier to -revert, and easier to bisect against. +One concept per pull request. A new operation plus a new waveform plus a bug fix in the parser is three pull requests. Each one is easier to review, easier to revert, and easier to bisect against. -If you find yourself in a long branch with many concepts, split it. The -maintainers will ask you to anyway. +If you find yourself in a long branch with many concepts, split it. The maintainers will ask you to anyway. ## Style notes -Most of this is configured in `pyproject.toml` and enforced by ruff or by a -test; the rest is a review convention. The list is not exhaustive. - -Ruff runs with `preview = true` and `select = ["ALL"]`, minus an ignore list -written as rule *names* rather than codes, so the reason for each exemption -reads off the config. Line length is 120 and the formatter owns it, which is why -`line-too-long` is one of the exemptions. `tests/**` carries its own -per-file-ignores, dropping the annotation and docstring families and the -security rules, so a test may reach into a private helper and skip its type -hints. Expect the linter to push back on code brought in from elsewhere. - -Docstrings are enforced under the Google convention. Preview mode means both the -`D` and the `DOC` families run, so every parameter gets a -`name (type): Description.` entry, where the parenthesized type is house style -even though the signature is annotated. A non-`None` return needs a `Returns:` -section and every exception a caller can observe needs a `Raises:` entry. -Constructor arguments are documented on the **class** docstring, not on -`__init__`, which is why `undocumented-public-init` is in the ignore list. -`docstring-code-format = true` means the formatter reformats code inside -docstrings, so an example in one has to be valid Python. - -Cross-references in docstrings are Markdown, not Sphinx roles. Write -`` [`Variable`][qprogram.Variable] `` for a target the -[API reference](../reference/api-qprogram.md) documents, and plain -`` `Variable` `` for anything it does not: a builtin, a stdlib name, a private -helper no page renders. `` [`qprogram.Range`][] `` is the shorthand when the -text is already the full path. mkdocstrings reads a docstring as Markdown and -has no reStructuredText reader, so a role such as -`` :class:`~qprogram.Variable` `` would reach the page as literal text. -`tests/test_docstring_style.py` scans every module under `src/` and fails the -suite on one, and it also catches a cross-reference that lost its target. - -Every file carries the Apache header: the standard 13-line notice with -`Copyright 2026 Qilimanjaro Quantum Tech`. Ruff's `missing-copyright-notice` -rule reads the expected author from -`[tool.ruff.lint.flake8-copyright]` and fails the lint on a file without it. - -Type hints go everywhere. `ty` checks `src` only, and one rule is switched off -there: `unused-ignore-comment`, because a `Values(...)` call needs an -`invalid-argument-type` suppression on Python 3.12 and later, where numpy's -`ArrayLike` does not admit the narrowed operand type, and that suppression is -then reported as unused on 3.11. - -`.qp` files use two-space indentation, in test fixtures as well as in examples. -Tests are functions, not methods on a class, and use fixtures and -parametrization for shared setup. - -New runtime dependencies need discussion first. `qprogram` depends on -`numpy>=2.1` and `xarray>=2026.4.0` and nothing else, which is what lets it -install next to whatever a lab already has; anything heavier belongs in an -extra, the way `matplotlib` sits behind `qprogram[viz]` and `pygls` behind -`qprogram[lsp]`. Supported Python versions are 3.11 through 3.14, so anything -that only works on a newer interpreter needs a fallback. +Most of this is configured in `pyproject.toml` and enforced by ruff or by a test; the rest is a review convention. The list is not exhaustive. + +Ruff runs with `preview = true` and `select = ["ALL"]`, minus an ignore list written as rule *names* rather than codes, so the reason for each exemption reads off the config. Line length is 120 and the formatter owns it, which is why `line-too-long` is one of the exemptions. `tests/**` carries its own per-file-ignores, dropping the annotation and docstring families and the security rules, so a test may reach into a private helper and skip its type hints. Expect the linter to push back on code brought in from elsewhere. + +Docstrings are enforced under the Google convention. Preview mode means both the `D` and the `DOC` families run, so every parameter gets a `name (type): Description.` entry, where the parenthesized type is house style even though the signature is annotated. A non-`None` return needs a `Returns:` section and every exception a caller can observe needs a `Raises:` entry. Constructor arguments are documented on the **class** docstring, not on `__init__`, which is why `undocumented-public-init` is in the ignore list. `docstring-code-format = true` means the formatter reformats code inside docstrings, so an example in one has to be valid Python. + +Cross-references in docstrings are Markdown, not Sphinx roles. Write `` [`Variable`][qprogram.Variable] `` for a target the [API reference](../reference/api-qprogram.md) documents, and plain `` `Variable` `` for anything it does not: a builtin, a stdlib name, a private helper no page renders. `` [`qprogram.Range`][] `` is the shorthand when the text is already the full path. mkdocstrings reads a docstring as Markdown and has no reStructuredText reader, so a role such as `` :class:`~qprogram.Variable` `` would reach the page as literal text. `tests/test_docstring_style.py` scans every module under `src/` and fails the suite on one, and it also catches a cross-reference that lost its target. + +Every file carries the Apache header: the standard 13-line notice with `Copyright 2026 Qilimanjaro Quantum Tech`. Ruff's `missing-copyright-notice` rule reads the expected author from `[tool.ruff.lint.flake8-copyright]` and fails the lint on a file without it. + +Type hints go everywhere. `ty` checks `src` only, and one rule is switched off there: `unused-ignore-comment`, because a `Values(...)` call needs an `invalid-argument-type` suppression on Python 3.12 and later, where numpy's `ArrayLike` does not admit the narrowed operand type, and that suppression is then reported as unused on 3.11. + +`.qp` files use two-space indentation, in test fixtures as well as in examples. Tests are functions, not methods on a class, and use fixtures and parametrization for shared setup. + +New runtime dependencies need discussion first. `qprogram` depends on `numpy` and `xarray` and nothing else, which is what lets it install next to whatever a lab already has; anything heavier belongs in an extra, the way `matplotlib` sits behind `qprogram[viz]` and `pygls` behind `qprogram[lsp]`. Supported Python versions are 3.11 through 3.14, so anything that only works on a newer interpreter needs a fallback. ## What goes where @@ -199,91 +117,51 @@ Use this when you are not sure which file to touch. ## Releasing -`CHANGELOG.md` is assembled from the fragments in `changelog/`, so it is written -once per release rather than edited per pull request. A release goes out from -its own pull request: +`CHANGELOG.md` is assembled from the fragments in `changelog/`, so it is written once per release rather than edited per pull request. A release goes out from its own pull request: 1. Branch from an up-to-date `main`. -2. Set the new version. This writes both `pyproject.toml` and `uv.lock`; nothing - else holds the literal, since `qprogram.__version__` is read from the - installed metadata. +2. Set the new version. This writes both `pyproject.toml` and `uv.lock`; nothing else holds the literal, since `qprogram.__version__` is read from the installed metadata. ```bash uv version 0.2.0 uv sync ``` -3. Assemble the changelog. Pass the version explicitly. Left to guess, towncrier - reads the *installed* metadata and can render a stale number into a heading - that is never regenerated. +3. Assemble the changelog. Pass the version explicitly. Left to guess, towncrier reads the *installed* metadata and can render a stale number into a heading that is never regenerated. ```bash uv run towncrier build --draft --version "$(uv version --short)" # preview uv run towncrier build --version "$(uv version --short)" --yes ``` - The second command writes a `## ()` section into - `CHANGELOG.md` under the `` marker and - deletes the fragments it consumed. Each entry carries a - `[PR #](https://github.com/qilimanjaro-tech/qprogram/pull/)` link built - from the fragment's file name. + The second command writes a `## ()` section into `CHANGELOG.md` under the `` marker and deletes the fragments it consumed. Each entry carries a `[PR #](https://github.com/qilimanjaro-tech/qprogram/pull/)` link built from the fragment's file name. -4. Read the rendered section and edit it. Fragments are written weeks apart by - different people and rarely read as one voice when they land together. +4. Read the rendered section and edit it. Fragments are written weeks apart by different people and rarely read as one voice when they land together. 5. Open the release pull request, and merge it once CI is green. -6. Create the GitHub Release on the merge commit, with a tag matching the - version now in `pyproject.toml`, and use the new changelog section as the - release body. - -Publishing the release triggers `publish.yml`. Its `build` job runs `uv build`, -which produces one wheel and one sdist; `qprogram` is pure Python, so a single -wheel covers every interpreter and platform, and a package with compiled -extensions would need a build matrix here instead. The publish job downloads -those artifacts, lists them, validates them with `twine check`, and uploads them -with `uv publish --trusted-publishing always`. The `--check-url` pointing at -`https://pypi.org/simple/qprogram/` lets a retried run skip files that already -landed. Pre-releases publish the same way, so a version such as `0.2.0rc1` -reaches PyPI and `pip` installs it only when asked with `--pre`. - -The upload job runs in the `pypi` GitHub environment, so any protection rule on -that environment (a required reviewer, a wait timer) gates the upload. PyPI -never lets a file be replaced, so that gate is the last point at which a wrong -version can be stopped. The workflow's concurrency group is keyed on the release -tag with `cancel-in-progress: false`, because a publish cancelled mid-upload can -leave an index in a state that is hard to recover from. - -`publish.yml` can also be started by hand from the Actions tab, which is how the -first release goes out and how a run that failed on a transient error is -retried. A manual run takes three inputs. `platform` chooses between PyPI and -the `qilimanjaro` AWS CodeArtifact domain; the CodeArtifact path authenticates -through OIDC role chaining and uploads with `twine upload` against a -CodeArtifact authorization token rather than through trusted publishing. -`repository` names the CodeArtifact repository, and a dispatch that selects -`aws` without it fails in seconds in the `check-inputs` job rather than after -the distributions build. `dry_run` passes `--dry-run` to `uv publish` on the -PyPI path and skips the upload step entirely on the CodeArtifact path, so both -build and validate without publishing. +6. Create the GitHub Release on the merge commit, with a tag matching the version now in `pyproject.toml`, and use the new changelog section as the release body. +7. Bump `main` to the next patch as a development version, so a checkout of `main` stops being indistinguishable from the release that just went out. `uv sync` matters as much as the bump: the format version is read from the installed metadata, not from `pyproject.toml`. + + ```bash + uv version 0.2.1.dev0 + uv sync + ``` + +Bump the patch, not the minor. The version is not only what PyPI serves: both file headers carry it truncated to `major.minor`, so the minor *is* the format version. `0.2.1.dev0` still writes `#!QProgram 0.2`, which is what the release writes, while `0.3.0.dev0` would stamp `#!QProgram 0.3` into every file written from `main` for the rest of the cycle. If that cycle then ships as 0.2.1, the release refuses those files, since 0.3 reads as later than 0.2 and nothing runs backwards. Move the minor in the pull request that actually changes the format, next to the migration it registers, rather than in advance. + +Publishing the release triggers `publish.yml`. Its `build` job runs `uv build`, which produces one wheel and one sdist; `qprogram` is pure Python, so a single wheel covers every interpreter and platform, and a package with compiled extensions would need a build matrix here instead. The publish job downloads those artifacts, lists them, validates them with `twine check`, and uploads them with `uv publish --trusted-publishing always`. The `--check-url` pointing at `https://pypi.org/simple/qprogram/` lets a retried run skip files that already landed. Pre-releases publish the same way, so a version such as `0.2.0rc1` reaches PyPI and `pip` installs it only when asked with `--pre`. + +The upload job runs in the `pypi` GitHub environment, so any protection rule on that environment (a required reviewer, a wait timer) gates the upload. PyPI never lets a file be replaced, so that gate is the last point at which a wrong version can be stopped. The workflow's concurrency group is keyed on the release tag with `cancel-in-progress: false`, because a publish cancelled mid-upload can leave an index in a state that is hard to recover from. + +`publish.yml` can also be started by hand from the Actions tab, which is how the first release goes out and how a run that failed on a transient error is retried. A manual run takes three inputs. `platform` chooses between PyPI and the `qilimanjaro` AWS CodeArtifact domain; the CodeArtifact path authenticates through OIDC role chaining and uploads with `twine upload` against a CodeArtifact authorization token rather than through trusted publishing. `repository` names the CodeArtifact repository, and a dispatch that selects `aws` without it fails in seconds in the `check-inputs` job rather than after the distributions build. `dry_run` passes `--dry-run` to `uv publish` on the PyPI path and skips the upload step entirely on the CodeArtifact path, so both build and validate without publishing. ## Commit messages -Short, imperative, explanatory. The body is more important than the title; -explain *why*, not *what*. The history in `git log` is a good template. +Short, imperative, explanatory. The body is more important than the title; explain *why*, not *what*. The history in `git log` is a good template. ## License and attribution -QProgram is licensed under the Apache License, Version 2.0. The full text is in -the `LICENSE` file at the repository root, and every source file carries the -matching 13-line header. By opening a pull request you agree to license your -contribution under the same terms. +QProgram is licensed under the Apache License, Version 2.0. The full text is in the `LICENSE` file at the repository root, and every source file carries the matching 13-line header. By opening a pull request you agree to license your contribution under the same terms. ## Where to ask -Bug reports, feature requests, and chores each have an issue template under -`.github/ISSUE_TEMPLATE/`. A bug report wants the shortest program that shows -the problem, and the `.qp` text for it if serialization is involved. A feature -request wants what you cannot do today and why the workaround is not good -enough; an operation, waveform, or sweep source that only one vendor's hardware -can run belongs in that vendor's extension package rather than here, and -[Building a vendor extension](vendor-extensions.md) covers the hooks. For design -discussion, open an issue that names the affected code, the behavior you expect, -and the guide page that documents it. +Bug reports, feature requests, and chores each have an issue template under `.github/ISSUE_TEMPLATE/`. A bug report wants the shortest program that shows the problem, and the `.qp` text for it if serialization is involved. A feature request wants what you cannot do today and why the workaround is not good enough; an operation, waveform, or sweep source that only one vendor's hardware can run belongs in that vendor's extension package rather than here, and [Building a vendor extension](vendor-extensions.md) covers the hooks. For design discussion, open an issue that names the affected code, the behavior you expect, and the guide page that documents it. diff --git a/docs/developer/index.md b/docs/developer/index.md index 0145e32..9ae10e3 100644 --- a/docs/developer/index.md +++ b/docs/developer/index.md @@ -1,7 +1,6 @@ # Developer guide -These pages cover the internals: how the package is laid out, the extension -points it exposes, and what changing each one involves. +These pages cover the internals: how the package is laid out, the extension points it exposes, and what changing each one involves. | Page | What it documents | |---|---| @@ -14,20 +13,6 @@ points it exposes, and what changing each one involves. | [Testing](testing.md) | How the suite is organized, the shared fixtures, the coverage settings, and what is worth a test. | | [Contributing](contributing.md) | The development loop, the checks CI runs, changelog fragments, and the release steps. | -Which page you need depends on whether the change is inside `qprogram` or -outside it. A waveform registered with `qp.register_waveform`, a sweep source -registered with `qp.register_sweep_source`, and everything a vendor package -registers on import need no change to the package at all, so those pages -describe a contract to satisfy. A new core operation, a new core block kind, or -a new capability token changes the package and the `.qp` format together, so -those pages read as ordered checklists, down to the tests and the -documentation that keep the code and the format description in agreement. +Which page you need depends on whether the change is inside `qprogram` or outside it. A waveform registered with `qp.register_waveform`, a sweep source registered with `qp.register_sweep_source`, and everything a vendor package registers on import need no change to the package at all, so those pages describe a contract to satisfy. A new core operation, a new core block kind, or a new capability token changes the package and the `.qp` format together, so those pages read as ordered checklists, down to the tests and the documentation that keep the code and the format description in agreement. -The [Reference](../reference/index.md) section holds the normative material -these pages build on: the [`.qp` file format](../reference/qp-format.md), the -[reserved keywords](../reference/reserved.md), the -[error hierarchy](../reference/errors.md), and the generated -[API reference](../reference/api-qprogram.md). The machine-readable grammar -ships with the package as `src/qprogram/grammar/qp.lark`, and -`tests/test_grammar.py` checks it against the hand-written parser so the two -cannot drift. +The [Reference](../reference/index.md) section holds the normative material these pages build on: the [`.qp` file format](../reference/qp-format.md), the [reserved keywords](../reference/reserved.md), the [error hierarchy](../reference/errors.md), and the generated [API reference](../reference/api-qprogram.md). The machine-readable grammar ships with the package as `src/qprogram/grammar/qp.lark`, and `tests/test_grammar.py` checks it against the hand-written parser so the two cannot drift. diff --git a/docs/developer/serialization-internals.md b/docs/developer/serialization-internals.md index 9e627b3..baeb059 100644 --- a/docs/developer/serialization-internals.md +++ b/docs/developer/serialization-internals.md @@ -1,46 +1,23 @@ # Serialization internals -The writer and the parser live under `qprogram.serialization`. Both are pure -Python and both dispatch through the same registries rather than through -`isinstance` ladders over keywords, so adding an operation, a block, a -waveform, or a sweep source is a registration rather than an edit to either -side. The parser's only imports from outside the package are `re`, `pathlib`, -and `typing`; the writer adds `io` for its output buffer and numpy for array -values. - -Snippets below that carry a `# src/qprogram/...` path comment are package -source and keep their intra-package imports, since `import qprogram` from -inside the package would close an import cycle. Anything written against the -installed package uses `import qprogram as qp`. - -The format version is one constant, shared by both directions, and it follows -the library version truncated to `major.minor`: +The writer and the parser live under `qprogram.serialization`. Both are pure Python and both dispatch through the same registries rather than through `isinstance` ladders over keywords, so adding an operation, a block, a waveform, or a sweep source is a registration rather than an edit to either side. The parser's only imports from outside the package are `re`, `pathlib`, and `typing`; the writer adds `io` for its output buffer and numpy for array values. + +Snippets below that carry a `# src/qprogram/...` path comment are package source and keep their intra-package imports, since `import qprogram` from inside the package would close an import cycle. Anything written against the installed package uses `import qprogram as qp`. + +The format version is one constant, shared by both directions, and it follows the library version truncated to `major.minor`: ```python # src/qprogram/serialization/_format.py FORMAT_VERSION: Final[str] = library_major_minor() ``` -`library_major_minor` lives in `src/qprogram/_version.py` and reads the -installed distribution version through `importlib.metadata`, not -`qprogram.__version__`, so `_format` stays a leaf importing one stdlib-only -module and nothing else in the package. A source tree with no installed -metadata falls back to `"0.0"`. The `.wfl` format's -`WAVEFORM_LIBRARY_FORMAT_VERSION` is derived the same way, which is why the two -headers carry the same number. - -The version is emitted in the `#!QProgram` header and checked on load. A file -from a later release is rejected with `Unsupported format version`, whichever -component moved; an older file is migrated up to this version. The header -carries `major.minor` exactly, since a patch release cannot change the format, -and a `require ` line is read the same way against its extension's -version. +`library_major_minor` lives in `src/qprogram/_version.py` and reads the installed distribution version through `importlib.metadata`, not `qprogram.__version__`, so `_format` stays a leaf importing one stdlib-only module and nothing else in the package. A source tree with no installed metadata falls back to `"0.0"`. The `.wfl` format's `WAVEFORM_LIBRARY_FORMAT_VERSION` is derived the same way, which is why the two headers carry the same number. + +The version is emitted in the `#!QProgram` header and checked on load. A file from a later release is rejected with `Unsupported format version`, whichever component moved; an older file is migrated up to this version. The header carries `major.minor` exactly, since a patch release cannot change the format, and a `require ` line is read the same way against its extension's version. ## Migrations -`src/qprogram/serialization/migrations.py` holds the rewrites that let today's -parser read yesterday's syntax. Each one is registered under the version that -broke something: +`src/qprogram/serialization/migrations.py` holds the rewrites that let today's parser read yesterday's syntax. Each one is registered under the version that broke something: ```python _SWEEP_KEYWORD = re.compile(r"(?<=^ )sweep\b") @@ -51,51 +28,20 @@ def _sweep_became_for(lines: list[str]) -> list[str]: return [_SWEEP_KEYWORD.sub("for", line) for line in lines] ``` -`_parse_header` reads the version off the header and, when the file is older -than the running one, replaces the parser's lines with the result of running -every migration in `(file version, running version]`, oldest first. Nothing -else in the parser knows a migration happened, and the file on disk is never -touched: the rewrite lives as long as the parse. `WaveformLibrary.loads` does -the same through `_migrated` in `src/qprogram/waveform_library.py`, against the -`"wfl"` table. +`_parse_header` reads the version off the header and, when the file is older than the running one, replaces the parser's lines with the result of running every migration in `(file version, running version]`, oldest first. Nothing else in the parser knows a migration happened, and the file on disk is never touched: the rewrite lives as long as the parse. `WaveformLibrary.loads` does the same through `_migrated` in `src/qprogram/waveform_library.py`, against the `"wfl"` table. Three rules make that safe to rely on: -- **One migration per breaking change, not per release.** A release that leaves - the syntax alone registers nothing, and a file two releases behind collects - every step in between. This is why the registry is a sorted list of steps - rather than a chain of parent revisions: there is no node to write for a - quiet release. -- **Lines in, as many lines out.** A migration may rewrite a line, and may look - at its neighbours, but may not add or drop one. That is what keeps a - `ParseError`'s line number and every `source_map` entry naming a line of the - file its author opened. `migrate_lines` checks the count and raises - `ValueError` naming the migration that broke it. A change that genuinely - needs to restructure lines is the point at which this mechanism grows a line - map; until then the invariant is worth more than the flexibility. -- **The header is not a migration's business.** The rewrite is handed every line - including the header, so the indices line up with the file, but the reader has - already read the version off it and moves past it. -- **One table per format, one version scale for both.** `FORMAT_VERSION` and - `WAVEFORM_LIBRARY_FORMAT_VERSION` are the same library version cut the same - way, so `_RUNNING_VERSION` bounds both chains and a release's breaking change - carries the same number in either file. The rewrites stay apart, since - `"pi" = Square(...)` in a library and `play "drive" Square(...)` in a program - are not the same text; a change to the vocabulary they do share is one - function registered under both formats, which is what the stacked decorator - in `register_migration`'s docstring shows. - -A migration registered under a version that has not shipped yet is skipped, -since the runner only applies steps up to the running version. That makes it -safe to write the migration in the same commit as the change that needs it, -before the release is cut. +- **One migration per breaking change, not per release.** A release that leaves the syntax alone registers nothing, and a file two releases behind collects every step in between. This is why the registry is a sorted list of steps rather than a chain of parent revisions: there is no node to write for a quiet release. +- **Lines in, as many lines out.** A migration may rewrite a line, and may look at its neighbours, but may not add or drop one. That is what keeps a `ParseError`'s line number and every `source_map` entry naming a line of the file its author opened. `migrate_lines` checks the count and raises `ValueError` naming the migration that broke it. A change that genuinely needs to restructure lines is the point at which this mechanism grows a line map; until then the invariant is worth more than the flexibility. +- **The header is not a migration's business.** The rewrite is handed every line including the header, so the indices line up with the file, but the reader has already read the version off it and moves past it. +- **One table per format, one version scale for both.** `FORMAT_VERSION` and `WAVEFORM_LIBRARY_FORMAT_VERSION` are the same library version cut the same way, so `_RUNNING_VERSION` bounds both chains and a release's breaking change carries the same number in either file. The rewrites stay apart, since `"pi" = Square(...)` in a library and `play "drive" Square(...)` in a program are not the same text; a change to the vocabulary they do share is one function registered under both formats, which is what the stacked decorator in `register_migration`'s docstring shows. + +A migration registered under a version that has not shipped yet is skipped, since the runner only applies steps up to the running version. That makes it safe to write the migration in the same commit as the change that needs it, before the release is cut. ### Adding a migration -Say 0.3 renames the `Rectangular` waveform to `Square`. Waveform constructors -are keyed by class name in the registry, so one rename changes how a pulse is -spelled in a program body and in a library entry at once, and both of these are -files somebody already has on disk: +Say 0.3 renames the `Rectangular` waveform to `Square`. Waveform constructors are keyed by class name in the registry, so one rename changes how a pulse is spelled in a program body and in a library entry at once, and both of these are files somebody already has on disk: ``` @@ -110,10 +56,7 @@ body: "pi_pulse" q[0].drive = Rectangular(amplitude=0.5, duration=200) ``` -The rewrite goes at the bottom of -`src/qprogram/serialization/migrations.py`, below the runners, so that -registration happens on the import both readers already perform. The module -imports no `re` today, so the first migration brings it: +The rewrite goes at the bottom of `src/qprogram/serialization/migrations.py`, below the runners, so that registration happens on the import both readers already perform. The module imports no `re` today, so the first migration brings it: ```python # src/qprogram/serialization/migrations.py @@ -127,21 +70,9 @@ def _rectangular_became_square(lines: list[str]) -> list[str]: return [_RECTANGULAR.sub("Square(", line) for line in lines] ``` -Two decisions are worth spelling out. The decorator is stacked because a -waveform constructor is vocabulary the two formats share; a change to the body -grammar, a block header or an operation keyword, is `"qp"` alone, and the -`"alias" coord = waveform` entry line is `"wfl"` alone. The pattern is anchored -because a migration is text in and text out with no parse in between: -`\bRectangular\(` matches the constructor call and nothing else on the line, -leaving a waveform aliased `"Rectangular"` and a program label reading -`Rectangular pulse calibration` alone, where a pattern matching the bare word -would rewrite both. - -The rewrite earns two tests in two places, because `tests/test_migrations.py` -empties both tables around every test so that its fixtures can register -throwaway rewrites without the shipped chain running underneath them. That puts -the registered chain out of reach there, so what that file tests is the function -itself, called on the lines it would be handed: +Two decisions are worth spelling out. The decorator is stacked because a waveform constructor is vocabulary the two formats share; a change to the body grammar, a block header or an operation keyword, is `"qp"` alone, and the `"alias" coord = waveform` entry line is `"wfl"` alone. The pattern is anchored because a migration is text in and text out with no parse in between: `\bRectangular\(` matches the constructor call and nothing else on the line, leaving a waveform aliased `"Rectangular"` and a program label reading `Rectangular pulse calibration` alone, where a pattern matching the bare word would rewrite both. + +The rewrite earns two tests in two places, because `tests/test_migrations.py` empties both tables around every test so that its fixtures can register throwaway rewrites without the shipped chain running underneath them. That puts the registered chain out of reach there, so what that file tests is the function itself, called on the lines it would be handed: ```python # tests/test_migrations.py @@ -150,9 +81,7 @@ def test_rectangular_became_square_leaves_an_alias_of_the_same_name_alone(): assert _rectangular_became_square([entry]) == ['"Rectangular" q[0].drive = Square(amplitude=0.5, duration=200)'] ``` -Whether it runs on the file that needs it is the other half, and belongs -wherever the registered chain is left in place, such as -`tests/test_serialization.py`: +Whether it runs on the file that needs it is the other half, and belongs wherever the registered chain is left in place, such as `tests/test_serialization.py`: ```python # tests/test_serialization.py @@ -161,35 +90,15 @@ def test_a_file_written_before_the_waveform_rename_still_loads(): assert isinstance(qp.loads(text).body.elements[0].waveform, qp.waveforms.Square) ``` -The version there is written out rather than taken from `tests/_header.py`, -since the point of the test is that one particular older version still loads. It -starts passing when 0.3 is cut: until then the file it writes carries the -running version rather than an earlier one, so nothing runs and the assertion -fails. That is the one claim about a migration its own release has to make true, -which is why the unit test above it is what guards the rewrite in review. +The version there is written out rather than taken from `tests/_header.py`, since the point of the test is that one particular older version still loads. It starts passing when 0.3 is cut: until then the file it writes carries the running version rather than an earlier one, so nothing runs and the assertion fails. That is the one claim about a migration its own release has to make true, which is why the unit test above it is what guards the rewrite in review. ### Vendor migrations -One level down, a `require ` line has the same problem: an extension -that renames an operation orphans the files its users already have. -`register_vendor_migration(vendor, version)` is the same mechanism against that -extension's version. `_check_vendor_compat` runs the vendor's chain over the -lines when the line asks for an earlier release than the one installed, which is -why an earlier vendor major is no longer refused. The ceiling there is the -installed extension, not the library, so `migrate_vendor_lines` takes it as an -argument rather than reading `_RUNNING_VERSION`. `.wfl` files declare no vendor, -having no `require` line, so vendor tables are consulted for `.qp` only. The -same worked example from inside an extension package, registration site and test -included, is under [keeping older files -loading](vendor-extensions.md#keeping-older-files-loading). +One level down, a `require ` line has the same problem: an extension that renames an operation orphans the files its users already have. `register_vendor_migration(vendor, version)` is the same mechanism against that extension's version. `_check_vendor_compat` runs the vendor's chain over the lines when the line asks for an earlier release than the one installed, which is why an earlier vendor major is no longer refused. The ceiling there is the installed extension, not the library, so `migrate_vendor_lines` takes it as an argument rather than reading `_RUNNING_VERSION`. `.wfl` files declare no vendor, having no `require` line, so vendor tables are consulted for `.qp` only. The same worked example from inside an extension package, registration site and test included, is under [keeping older files loading](vendor-extensions.md#keeping-older-files-loading). ## The registries -Seven module-level dicts in `src/qprogram/serialization/registry.py` hold -everything the two directions dispatch on. Each pair of operation and block -tables exists because the two directions look up from opposite ends: the parser -has a keyword and wants a class, the writer has an instance and wants a -keyword. +Seven module-level dicts in `src/qprogram/serialization/registry.py` hold everything the two directions dispatch on. Each pair of operation and block tables exists because the two directions look up from opposite ends: the parser has a keyword and wants a class, the writer has an instance and wants a keyword. | Table | Key | Read by | |---|---|---| @@ -201,32 +110,15 @@ keyword. | `_waveform_registry` | class name (`"Gaussian"`) | both, for inline waveform constructors | | `_vendor_versions` | vendor name, holding the declared semver string | the writer's `require` lines and the parser's compatibility check | -Class lookups are exact rather than by inheritance, so a subclass of a -registered operation needs a registration of its own before it can be written. - -Population happens at import time. `qprogram.serialization.__init__` calls -`_register_core_specs()` for the core operations, blocks, and sweep sources; -`registry.py` calls `_register_builtin_waveforms()` at the bottom of the module -for the seventeen built-in waveform classes; a vendor package calls -`register_vendor_operation`, `register_vendor_block`, and -`register_vendor_version` as import side effects, either because the user -imported it or because a `require` line activated it through the -`qprogram.vendors` entry-point group. - -Registration is conservative. Re-registering the *same* class under a key it -already holds is allowed, which lets an owner refresh its callbacks and lets a -side-effect module run twice. Claiming a key held by a *different* class raises -`ValueError` (`operation 'play' is already registered to ...; refusing to -replace it with ...`), because silently taking over another package's keyword -would change how every existing file using it parses. A vendor name in -`RESERVED_VENDOR_NAMES`, which is the [reserved -keywords](../reference/reserved.md) plus the `core` sentinel, is refused -outright. +Class lookups are exact rather than by inheritance, so a subclass of a registered operation needs a registration of its own before it can be written. + +Population happens at import time. `qprogram.serialization.__init__` calls `_register_core_specs()` for the core operations, blocks, and sweep sources; `registry.py` calls `_register_builtin_waveforms()` at the bottom of the module for the seventeen built-in waveform classes; a vendor package calls `register_vendor_operation`, `register_vendor_block`, and `register_vendor_version` as import side effects, either because the user imported it or because a `require` line activated it through the `qprogram.vendors` entry-point group. + +Registration is conservative. Re-registering the *same* class under a key it already holds is allowed, which lets an owner refresh its callbacks and lets a side-effect module run twice. Claiming a key held by a *different* class raises `ValueError` (`operation 'play' is already registered to ...; refusing to replace it with ...`), because silently taking over another package's keyword would change how every existing file using it parses. A vendor name in `RESERVED_VENDOR_NAMES`, which is the [reserved keywords](../reference/reserved.md) plus the `core` sentinel, is refused outright. ## What a spec declares -Every registered operation carries an `OperationSpec`, and every registered -keyword-led block a `BlockSpec`: +Every registered operation carries an `OperationSpec`, and every registered keyword-led block a `BlockSpec`: ```python # src/qprogram/serialization/registry.py @@ -248,42 +140,15 @@ class BlockSpec: parse_header: BlockParseHeaderFn | None = None ``` -So a spec declares four things: the keyword, the namespace it belongs to, the -class to construct, and optionally the pair of callbacks that own the text -between the keyword and the end of the statement. `spec.qualified_name` is -`name` for a core entry and `"."` for a vendor one, which is the -exact token that appears on the wire and, for blocks, also the registry key. -Recording `vendor` on `BlockSpec` is what lets the writer emit a `require` line -for a file whose only vendor content is a block. - -A `BlockSpec`'s callbacks own the header only, everything between the keyword -and the trailing colon; indentation and child statements are handled uniformly -by the body writer and the statement parser, so a block never has to think -about its own contents. - -When both callbacks are `None` the writer falls back to -`default_serialize_operation` and the parser to `default_parse_operation`, both -in `_specs.py`. They reflect on `cls.__init__`, minus `self`. On the write -side, a parameter with no default emits positionally in declaration order; a -parameter with a default emits as `key=value` only when the stored value -differs from that default; a parameter with no matching attribute on the -instance is skipped, so `__init__` may accept a kwarg its body does not store. -On the parse side, a token counts as a keyword argument when it contains `=`, -does not open with a quote, and has no `(` before the first `=`; the remaining -tokens bind positionally by index, and the whole thing is then constructed with -keywords only, so positional ordering cannot drift between the two sides. More -positional tokens than the constructor has parameters is a `ParseError` that -names the excess and suggests the likely cause: ``If you meant an arithmetic -expression, parenthesize it: `(100 - t)`.`` - -Constructor failures are converted rather than allowed to escape, because the -line number is what `source_map` and the editor tooling depend on. A -`TypeError` becomes `cannot construct 'Play' from the given arguments: ...`, -and a `ValidationError` is passed through verbatim under the line tag, since -its message is already specific about the argument it rejected. - -A handful of nodes need explicit callbacks because their wire form is not an -argument list: +So a spec declares four things: the keyword, the namespace it belongs to, the class to construct, and optionally the pair of callbacks that own the text between the keyword and the end of the statement. `spec.qualified_name` is `name` for a core entry and `"."` for a vendor one, which is the exact token that appears on the wire and, for blocks, also the registry key. Recording `vendor` on `BlockSpec` is what lets the writer emit a `require` line for a file whose only vendor content is a block. + +A `BlockSpec`'s callbacks own the header only, everything between the keyword and the trailing colon; indentation and child statements are handled uniformly by the body writer and the statement parser, so a block never has to think about its own contents. + +When both callbacks are `None` the writer falls back to `default_serialize_operation` and the parser to `default_parse_operation`, both in `_specs.py`. They reflect on `cls.__init__`, minus `self`. On the write side, a parameter with no default emits positionally in declaration order; a parameter with a default emits as `key=value` only when the stored value differs from that default; a parameter with no matching attribute on the instance is skipped, so `__init__` may accept a kwarg its body does not store. On the parse side, a token counts as a keyword argument when it contains `=`, does not open with a quote, and has no `(` before the first `=`; the remaining tokens bind positionally by index, and the whole thing is then constructed with keywords only, so positional ordering cannot drift between the two sides. More positional tokens than the constructor has parameters is a `ParseError` that names the excess and suggests the likely cause: ``If you meant an arithmetic expression, parenthesize it: `(100 - t)`.`` + +Constructor failures are converted rather than allowed to escape, because the line number is what `source_map` and the editor tooling depend on. A `TypeError` becomes `cannot construct 'Play' from the given arguments: ...`, and a `ValidationError` is passed through verbatim under the line tag, since its message is already specific about the argument it rejected. + +A handful of nodes need explicit callbacks because their wire form is not an argument list: | Node | Wire form | Callbacks | |---|---|---| @@ -292,112 +157,41 @@ argument list: | `Measure`, and vendor measurement ops | `measure name="..."` | `measurement_op_serialize` / `make_measurement_op_parse(cls)` | | `Average` | `average ` | `average_serialize_header` / `average_parse_header` | -`Sync` needs one because `targets` is a single list-valued parameter emitted as -a run of bare bus tokens, and because the bare keyword carries meaning of its -own (synchronize every bus in the program, stored as `targets=None`). It renders -each target through `ctx.serialize_value` rather than `ctx.serialize_bus`, which -is what every other operation's bus argument goes through: `serialize_bus` knows -`BusRef` and quotes everything else, so a fragment `Parameter` would go out as a -quoted `"Parameter('drive')"` and stop substituting at expansion. -`serialize_value` delegates a `BusRef` straight back to `serialize_bus`, so the -two agree everywhere else. -`GetParameter` places its output variable after a `->` arrow, which it reaches -through `ctx.var_ident`. A measurement operation skips its `handle` parameter -and re-emits it as `name="..."`, and the parse side resolves that name through -`ctx.get_or_create_handle` so every measurement operation and every -`MeasurementRef` naming it share one Python instance after a load. It accepts -three spellings: the canonical `name=` kwarg, a quoted token in the `handle` -positional slot, or nothing at all, in which case the parser allocates a name -with the same convention the builder uses. The retired `returns=` kwarg gets -its own diagnostic rather than a generic unexpected-keyword error, because both -the keyword and the value shape changed: ``write `fields=["state", "iq"]` -instead of `returns="state,iq"`.`` - -`Parallel` and `Conditional` are not in the block registry at all. Neither is -keyword-led: `a | b | c:` composes other blocks' headers, and a conditional has -one header per arm rather than one for the node, so the writer special-cases -both. `Call` is an `Operation` subclass but has its own `name(args)` statement -form, so the writer tests for it before the generic operation branch. +`Sync` needs one because `targets` is a single list-valued parameter emitted as a run of bare bus tokens, and because the bare keyword carries meaning of its own (synchronize every bus in the program, stored as `targets=None`). It renders each target through `ctx.serialize_value` rather than `ctx.serialize_bus`, which is what every other operation's bus argument goes through: `serialize_bus` knows `BusRef` and quotes everything else, so a fragment `Parameter` would go out as a quoted `"Parameter('drive')"` and stop substituting at expansion. `serialize_value` delegates a `BusRef` straight back to `serialize_bus`, so the two agree everywhere else. `GetParameter` places its output variable after a `->` arrow, which it reaches through `ctx.var_ident`. A measurement operation skips its `handle` parameter and re-emits it as `name="..."`, and the parse side resolves that name through `ctx.get_or_create_handle` so every measurement operation and every `MeasurementRef` naming it share one Python instance after a load. It accepts three spellings: the canonical `name=` kwarg, a quoted token in the `handle` positional slot, or nothing at all, in which case the parser allocates a name with the same convention the builder uses. The retired `returns=` kwarg gets its own diagnostic rather than a generic unexpected-keyword error, because both the keyword and the value shape changed: ``write `fields=["state", "iq"]` instead of `returns="state,iq"`.`` + +`Parallel` and `Conditional` are not in the block registry at all. Neither is keyword-led: `a | b | c:` composes other blocks' headers, and a conditional has one header per arm rather than one for the node, so the writer special-cases both. `Call` is an `Operation` subclass but has its own `name(args)` statement form, so the writer tests for it before the generic operation branch. ## Sweep sources and waveforms -Sweep sources are neither operations nor blocks. A `for in (...)` -header is driven by the sweep-source registry, keyed by class name and -signature-driven in both directions exactly as a waveform constructor is, so -registering the class is the whole extension step. `register_sweep_source` also -adds the class's `TOKEN` to the capability registry, which is what lets a -`Profile` list `sweep.file` without a separate `register_capability_tokens` -call. - -`Values` is the one source with sugar: it writes as a bare bracket literal -(`for t in [10, 20, 40]:`) rather than as a constructor call, and the parser -treats a leading `[` in the source position as a `Values` constructor. -Combinators nest to any depth, because a nested constructor comes back through -the same argument parser: `Concat(sources=[Rotate(source=[...], by=1)])` parses -as readily as a bare `Range`. +Sweep sources are neither operations nor blocks. A `for in (...)` header is driven by the sweep-source registry, keyed by class name and signature-driven in both directions exactly as a waveform constructor is, so registering the class is the whole extension step. `register_sweep_source` also adds the class's `TOKEN` to the capability registry, which is what lets a `Profile` list `sweep.file` without a separate `register_capability_tokens` call. + +`Values` is the one source with sugar: it writes as a bare bracket literal (`for t in [10, 20, 40]:`) rather than as a constructor call, and the parser treats a leading `[` in the source position as a `Values` constructor. Combinators nest to any depth, because a nested constructor comes back through the same argument parser: `Concat(sources=[Rotate(source=[...], by=1)])` parses as readily as a bare `Range`. ## The writer -`dumps(program)` builds a `_Writer`, which walks the AST and emits lines. The -same instance doubles as the *write context* handed to every spec callback: -`serialize_value`, `serialize_bus`, `serialize_waveform`, -`serialize_sweep_source`, and `var_ident` are the whole surface a callback may -rely on. `save(program, path)` is `dumps` plus a UTF-8 write, independent of -the platform's locale. +`dumps(program)` builds a `_Writer`, which walks the AST and emits lines. The same instance doubles as the *write context* handed to every spec callback: `serialize_value`, `serialize_bus`, `serialize_waveform`, `serialize_sweep_source`, and `var_ident` are the whole surface a callback may rely on. `save(program, path)` is `dumps` plus a UTF-8 write, independent of the platform's locale. -Handing `dumps` a `Fragment` is an error rather than a partial file: fragments -are emitted as `fragment ...:` sections of the host program that calls them, so -the message tells you to serialize that program instead. +Handing `dumps` a `Fragment` is an error rather than a partial file: fragments are emitted as `fragment ...:` sections of the host program that calls them, so the message tells you to serialize that program instead. `_Writer.dump` runs the sections in file order: -1. **Collect variable identifiers.** `_allocate_var_idents` maps each variable - to the identifier it will carry in the file, which is its `id` verbatim. +1. **Collect variable identifiers.** `_allocate_var_idents` maps each variable to the identifier it will carry in the file, which is its `id` verbatim. 2. **Emit the header,** `#!QProgram .`. -3. **Emit `require` lines.** Walk the body *and* every fragment body, collect - the vendors referenced by operations and by blocks, and emit one - `require .` per vendor, sorted by vendor name, with - the patch component truncated because compatibility is defined at - major.minor. A vendor with no registered version raises - `SerializationError`, since the resulting file could not be - compatibility-checked on load. +3. **Emit `require` lines.** Walk the body *and* every fragment body, collect the vendors referenced by operations and by blocks, and emit one `require .` per vendor, sorted by vendor name, with the patch component truncated because compatibility is defined at major.minor. A vendor with no registered version raises `SerializationError`, since the resulting file could not be compatibility-checked on load. 4. **Emit metadata,** if `label` is non-empty or `description` is not `None`. 5. **Emit the schema declaration,** if the program has one. 6. **Emit fragments,** in dependency order. 7. **Emit the body:** variable declarations, then the block tree. -The vendor walk uses `Block.walk` rather than recursing over `.elements`, -because `Conditional` keeps its arm bodies on `.arms` and `.else_body`; an -elements-only recursion would miss a vendor operation inside an `if_` arm and -emit a file with no `require` line for it. - -Ordering inside those sections is computed at write time, not taken from -whatever order the program was built in. Fragments come from `_topo_fragments`, -a depth-first walk over nested `Call` nodes, so every definition precedes its -first use, which is the define-before-use rule the parser enforces. A call -cycle and two different fragments reachable under one name are both -`SerializationError`. Variables are emitted in `program.variables` order, -followed by one blank line. Metadata omits `label` when it is empty, because -the parser's default matches, but emits `description` whenever it is not -`None`, because an explicit empty string is a distinct value that has to -survive. - -The schema is always written in the expanded inline form, one `element` header -per element with its bus kinds beneath, even for a preset such as -`qp.BusSchema.transmon()`. The preset classes are construction-time -conveniences on the Python side; the file records the structural contents -directly, so adding a bus to a preset can never silently change the meaning of -an existing `.qp` file. Custom subclasses and dynamic schemas take the same -path. - -Indentation is a function of depth alone. `_write_body` starts the block walk -at column 2 and each nested block adds two more columns. A `Parallel` costs one -level for the whole composition rather than one per composed loop, since the -composed `for` headers are joined with ` | ` on a single line. Conditional arm -headers sit at the parent's indent and each arm body two columns further. - -Put together, a program with metadata, a schema, a fragment, an `average`, and -a sweep writes as: +The vendor walk uses `Block.walk` rather than recursing over `.elements`, because `Conditional` keeps its arm bodies on `.arms` and `.else_body`; an elements-only recursion would miss a vendor operation inside an `if_` arm and emit a file with no `require` line for it. + +Ordering inside those sections is computed at write time, not taken from whatever order the program was built in. Fragments come from `_topo_fragments`, a depth-first walk over nested `Call` nodes, so every definition precedes its first use, which is the define-before-use rule the parser enforces. A call cycle and two different fragments reachable under one name are both `SerializationError`. Variables are emitted in `program.variables` order, followed by one blank line. Metadata omits `label` when it is empty, because the parser's default matches, but emits `description` whenever it is not `None`, because an explicit empty string is a distinct value that has to survive. + +The schema is always written in the expanded inline form, one `element` header per element with its bus kinds beneath, even for a preset such as `qp.BusSchema.transmon()`. The preset classes are construction-time conveniences on the Python side; the file records the structural contents directly, so adding a bus to a preset can never silently change the meaning of an existing `.qp` file. Custom subclasses and dynamic schemas take the same path. + +Indentation is a function of depth alone. `_write_body` starts the block walk at column 2 and each nested block adds two more columns. A `Parallel` costs one level for the whole composition rather than one per composed loop, since the composed `for` headers are joined with ` | ` on a single line. Conditional arm headers sit at the parent's indent and each arm body two columns further. + +Put together, a program with metadata, a schema, a fragment, an `average`, and a sweep writes as: ``` #!QProgram 0.2 @@ -423,222 +217,67 @@ body: measure q[0].readout "ro" "w" name="q0/readout/m0" ``` -Every section is preceded by a blank line, the fragment call is emitted with -its arguments positional in the fragment's parameter order (the keyword -spelling a caller used at build time is not part of the wire form), and the -measurement's auto-allocated handle name is emitted so the reload resolves the -same handle. - -`_serialize_operation` and `_serialize_block_header` both raise -`SerializationError` for an unregistered class rather than emitting a -placeholder, because a placeholder would silently drop the node, or the whole -subtree beneath it, on reload. `serialize_value` raises for any value type the -format has no representation for, for an array of rank other than 1, for a -dict with non-string keys, and for a `MeasurementRef` whose handle name carries -a character that the unquoted `.` wire form cannot hold -(whitespace, a quote, `#`, a comma, a dot, or a bracket, brace, or -parenthesis). +Every section is preceded by a blank line, the fragment call is emitted with its arguments positional in the fragment's parameter order (the keyword spelling a caller used at build time is not part of the wire form), and the measurement's auto-allocated handle name is emitted so the reload resolves the same handle. + +`_serialize_operation` and `_serialize_block_header` both raise `SerializationError` for an unregistered class rather than emitting a placeholder, because a placeholder would silently drop the node, or the whole subtree beneath it, on reload. `serialize_value` raises for any value type the format has no representation for, for an array of rank other than 1, for a dict with non-string keys, and for a `MeasurementRef` whose handle name carries a character that the unquoted `.` wire form cannot hold (whitespace, a quote, `#`, a comma, a dot, or a bracket, brace, or parenthesis). ## The parser -`loads(text, *, auto_activate=True)` builds a `_Parser`, which is single-use -and works line by line. That instance is the *parse context* the spec callbacks -reach through: `parse_value`, `parse_error`, `get_or_declare_variable`, -`declared_variable`, `get_or_create_handle`, `allocate_measurement_handle`, and -the `line_num` property a callback reads when it builds an error of its own. -`load(path)` reads UTF-8 and calls `loads`. - -The header and its `require` lines come first, then a dispatch loop over -top-level lines routes `metadata:`, `schema:`, `fragment (...):`, and -`body:` to their own parsers. The loop does not fix an order, so sections may -appear in any sequence, with three constraints it does enforce: a `require` -line reached by the loop rather than by the header pass is an error -(``` `require` declarations must appear directly after the header, before any -section ```), a second `schema:` is a duplicate-declaration error, and a -`fragment` section after `body:` is rejected. Emitting the schema before the -fragments, as the writer does, is what lets bus paths inside a fragment body -resolve; a hand-written file that declares the schema later keeps those paths -as raw strings. An unrecognized top-level line raises rather than being -skipped, since skipping it would hide a typo such as `bodyy:` behind an -empty-but-valid program. - -`require` handling is where a file's vendor dependencies are checked. Majors -must match exactly and the file's minor must be no newer than the installed -extension's; the patch component is informational. When the named vendor has no -registered version and `auto_activate` is on, the parser looks up the -`qprogram.vendors` entry point whose *name* is the vendor and imports its -target module, whose registration side effects supply the namespace, the -version, and the operations. That is what makes a `.qp` file self-contained: -any environment with the extension installed can load it, imported or not. An -entry point that imports but registers no version is a packaging bug in the -extension and raises `VendorActivationError`, which the parser wraps into a -`ParseError` carrying the line number. Passing `auto_activate=False` forbids -the implicit import, and the resulting message says so -(`auto-activation is disabled; import the extension before loading`). +`loads(text, *, auto_activate=True)` builds a `_Parser`, which is single-use and works line by line. That instance is the *parse context* the spec callbacks reach through: `parse_value`, `parse_error`, `get_or_declare_variable`, `declared_variable`, `get_or_create_handle`, `allocate_measurement_handle`, and the `line_num` property a callback reads when it builds an error of its own. `load(path)` reads UTF-8 and calls `loads`. + +The header and its `require` lines come first, then a dispatch loop over top-level lines routes `metadata:`, `schema:`, `fragment (...):`, and `body:` to their own parsers. The loop does not fix an order, so sections may appear in any sequence, with three constraints it does enforce: a `require` line reached by the loop rather than by the header pass is an error (``` `require` declarations must appear directly after the header, before any section ```), a second `schema:` is a duplicate-declaration error, and a `fragment` section after `body:` is rejected. Emitting the schema before the fragments, as the writer does, is what lets bus paths inside a fragment body resolve; a hand-written file that declares the schema later keeps those paths as raw strings. An unrecognized top-level line raises rather than being skipped, since skipping it would hide a typo such as `bodyy:` behind an empty-but-valid program. + +`require` handling is where a file's vendor dependencies are checked. Majors must match exactly and the file's minor must be no newer than the installed extension's; the patch component is informational. When the named vendor has no registered version and `auto_activate` is on, the parser looks up the `qprogram.vendors` entry point whose *name* is the vendor and imports its target module, whose registration side effects supply the namespace, the version, and the operations. That is what makes a `.qp` file self-contained: any environment with the extension installed can load it, imported or not. An entry point that imports but registers no version is a packaging bug in the extension and raises `VendorActivationError`, which the parser wraps into a `ParseError` carrying the line number. Passing `auto_activate=False` forbids the implicit import, and the resulting message says so (`auto-activation is disabled; import the extension before loading`). ### Resolving a keyword to a registry entry -`_parse_statements` reads one statement at a time until a line outdents past -the block's `min_indent`, dispatching on the first significant token in a fixed -order. - -A line beginning `var` is a declaration, parsed for its id and its optional -`label`, `units`, and `description`, and a duplicate id is an error. Anything -ending in `:` is a block header, handed to `_try_parse_block_header`: `for`, or -a `|`-composed run of `for` headers, builds a `Sweep` or a `Parallel` of sweeps -through the sweep-source registry; `if` opens a conditional chain, and an -`elif` or `else` reached here without a preceding `if` at the same indent is an -error; anything else takes its first word to `get_block_spec`, and an -unregistered keyword raises rather than skipping the indented body it heads. - -A whole statement shaped `name(args)` is a fragment call. Operations never take -that shape, since their keyword is followed by whitespace-separated tokens, and -block headers end with a colon, so the form is unambiguous at statement -position. A name that is a registered waveform rather than a defined fragment -gets its own message, because writing `Gaussian(...)` on a line of its own is a -common mistake with an obvious fix. - -Everything left is an operation. `_tokenize` splits the line, the first token -is split at its first `.` into `(vendor, name)` (no dot means `vendor=None`), -and `get_operation_spec(vendor, name)` returns the spec. A spec with a `parse` -callback receives the remaining tokens; otherwise `default_parse_operation` -reconstructs the operation from the signature. The three unknown-keyword -messages are different: a dotted name points at the extension package and the -file's `require` line, a bare name that happens to be a registered block -keyword says the header needs a trailing colon, and anything else reports that -no core operation is registered under that name. - -`_tokenize` splits on whitespace at nesting depth zero only, tracking quote -state at every depth and honoring `\"` inside strings, so `fields=["state", -"iq"]` and `matrix={"a": 1.0}` survive as single tokens and a parenthesis -inside a quoted string never perturbs the nesting count. `parse_value` then -decodes one token into a value: quoted strings, `true`, `false`, `null`, -bracket and brace literals, parenthesized expressions, function-call shapes -(math functions and `where` first, then waveform and sweep-source -constructors), `.` measurement references, identifiers already -declared as variables, and numbers. A token matching none of those comes back -as a plain string, which is how a bus path flows through untyped until the -promotion pass. +`_parse_statements` reads one statement at a time until a line outdents past the block's `min_indent`, dispatching on the first significant token in a fixed order. + +A line beginning `var` is a declaration, parsed for its id and its optional `label`, `units`, and `description`, and a duplicate id is an error. Anything ending in `:` is a block header, handed to `_try_parse_block_header`: `for`, or a `|`-composed run of `for` headers, builds a `Sweep` or a `Parallel` of sweeps through the sweep-source registry; `if` opens a conditional chain, and an `elif` or `else` reached here without a preceding `if` at the same indent is an error; anything else takes its first word to `get_block_spec`, and an unregistered keyword raises rather than skipping the indented body it heads. + +A whole statement shaped `name(args)` is a fragment call. Operations never take that shape, since their keyword is followed by whitespace-separated tokens, and block headers end with a colon, so the form is unambiguous at statement position. A name that is a registered waveform rather than a defined fragment gets its own message, because writing `Gaussian(...)` on a line of its own is a common mistake with an obvious fix. + +Everything left is an operation. `_tokenize` splits the line, the first token is split at its first `.` into `(vendor, name)` (no dot means `vendor=None`), and `get_operation_spec(vendor, name)` returns the spec. A spec with a `parse` callback receives the remaining tokens; otherwise `default_parse_operation` reconstructs the operation from the signature. The three unknown-keyword messages are different: a dotted name points at the extension package and the file's `require` line, a bare name that happens to be a registered block keyword says the header needs a trailing colon, and anything else reports that no core operation is registered under that name. + +`_tokenize` splits on whitespace at nesting depth zero only, tracking quote state at every depth and honoring `\"` inside strings, so `fields=["state", "iq"]` and `matrix={"a": 1.0}` survive as single tokens and a parenthesis inside a quoted string never perturbs the nesting count. `parse_value` then decodes one token into a value: quoted strings, `true`, `false`, `null`, bracket and brace literals, parenthesized expressions, function-call shapes (math functions and `where` first, then waveform and sweep-source constructors), `.` measurement references, identifiers already declared as variables, and numbers. A token matching none of those comes back as a plain string, which is how a bus path flows through untyped until the promotion pass. ### Bus references -A bus reference is one of two things on the wire: a quoted string, or a path -like `q[0].drive` or `c[0,1].flux`, where a comma-joined index denotes a tuple. -Quoting *is* the type distinction, and it is tracked through parsing by the -`_QuotedStr` marker subclass, so a raw-string bus that happens to *look* like a -path is never promoted. Promotion runs post-parse and only over the attributes -the operation lists in `Operation.BUS_ATTRS`, which defaults to `("bus",)`; -`Sync` declares `("targets",)` and is handled element-wise, and `Call` declares -`()`. Promoting every string attribute would mangle a legitimate quoted string -that resembles a path, such as a vendor `set_parameter` alias of -`"cluster[0].module"`. - -Within a bus attribute, a string that does not match the path syntax is left -alone, because that is a raw-string bus opting out of schema validation. A -program with no schema keeps every bus exactly as written. A path-shaped token -that does not resolve against the schema raises -`bus path 'q[7].drive' does not resolve against the program schema: ...`. - -What comes back from a successful resolution is a fully populated `BusRef`, -with `element`, `idx`, `kind`, `channel`, `acquires`, and a back-pointer to the -schema, so the post-load validators (`_validate_waveform_channel`, -`_validate_acquires`) have everything they need. The schema itself is rebuilt -as a dynamic `BusSchema` through `add_element` calls, never as the typed preset -class, with each bus declared as ` info=[+acquires]` where the -channel is exactly one of `single` or `IQ`. A program that was schema-backed -when it was written stays schema-backed after loading; only the Python class -identity is lost. +A bus reference is one of two things on the wire: a quoted string, or a path like `q[0].drive` or `c[0,1].flux`, where a comma-joined index denotes a tuple. Quoting *is* the type distinction, and it is tracked through parsing by the `_QuotedStr` marker subclass, so a raw-string bus that happens to *look* like a path is never promoted. Promotion runs post-parse and only over the attributes the operation lists in `Operation.BUS_ATTRS`, which defaults to `("bus",)`; `Sync` declares `("targets",)` and is handled element-wise, and `Call` declares `()`. Promoting every string attribute would mangle a legitimate quoted string that resembles a path, such as a vendor `set_parameter` alias of `"cluster[0].module"`. + +Within a bus attribute, a string that does not match the path syntax is left alone, because that is a raw-string bus opting out of schema validation. A program with no schema keeps every bus exactly as written. A path-shaped token that does not resolve against the schema raises `bus path 'q[7].drive' does not resolve against the program schema: ...`. + +What comes back from a successful resolution is a fully populated `BusRef`, with `element`, `idx`, `kind`, `channel`, `acquires`, and a back-pointer to the schema, so the post-load validators (`_validate_waveform_channel`, `_validate_acquires`) have everything they need. The schema itself is rebuilt as a dynamic `BusSchema` through `add_element` calls, never as the typed preset class, with each bus declared as ` info=[+acquires]` where the channel is exactly one of `single` or `IQ`. A program that was schema-backed when it was written stays schema-backed after loading; only the Python class identity is lost. ### Lazy imports -`loads`, `load`, and `ParseError` are resolved by a module-level `__getattr__` -on both `qprogram/__init__.py` and `qprogram/serialization/__init__.py`. The -parser module constructs `QProgram` instances, so importing it eagerly from -either place would close an import cycle; deferring the import until the -attribute is first read keeps the names on the package surface without one. -`dumps` and `save` are imported eagerly, because nothing the writer imports at -module level reaches `QProgram`. +`loads`, `load`, and `ParseError` are resolved by a module-level `__getattr__` on both `qprogram/__init__.py` and `qprogram/serialization/__init__.py`. The parser module constructs `QProgram` instances, so importing it eagerly from either place would close an import cycle; deferring the import until the attribute is first read keeps the names on the package surface without one. `dumps` and `save` are imported eagerly, because nothing the writer imports at module level reaches `QProgram`. -If you refactor this, the rule is that anything importing `qprogram.QProgram` -has to be reached through that `__getattr__` rather than imported at the top of -`qprogram/__init__.py`. +If you refactor this, the rule is that anything importing `qprogram.QProgram` has to be reached through that `__getattr__` rather than imported at the top of `qprogram/__init__.py`. ## The canonical grammar -The format has a normative machine-readable grammar in -`src/qprogram/grammar/qp.lark`: a Lark dialect using LALR with a two-space -`Indenter` and no bracket types that suppress newlines, since `.qp` is strictly -line-based. `qprogram.grammar.grammar_text()` returns its source, `parser()` -builds the reference parser, and `parse_text()` parses a document with it after -normalizing a missing trailing newline. `lark` is a dev-only dependency, so -`parser()` raises `ModuleNotFoundError` on an ordinary install. - -It is a specification artifact, not the production parser. Generating the -production parser from it would cost the three things the hand-written one -gives: a line number on every error, registry lookups that decide whether a -keyword exists at all, and schema resolution for bus paths. None of those are -expressible in a context-free grammar. What the separation costs in return is -that the two have to be kept in step, which `tests/test_grammar.py` does in -both directions. - -Positively, the writer's output for a full-feature program, a schema program, a -fragment program, and a vendor program must parse under the grammar, as must -every program the round-trip hypothesis strategies generate (`programs()` and -`fragment_programs()`, imported from `tests/test_round_trip_property.py`). -Negatively, a corpus of thirteen syntactic malformations, from a missing header -through an unparenthesized expression to a dangling dict literal, must be -rejected by `qp.loads` and by the reference parser both. A last test asserts -that every hard keyword the grammar declares (`var`, `for`, `in`, `if`, `elif`, -`else`, `and`, `or`, `not`, `true`, `false`, `null`, `fragment`) is in -`qp.RESERVED_KEYWORDS`, so `var for` cannot be accepted by one side and -rejected by the other. - -The grammar over-approximates whatever is semantic rather than syntactic: any -identifier is a valid operation or block keyword, section order is free, and -version shapes, duplicate declarations, and bus-path resolution are all -post-parse checks. It is exact about token shapes: quoting, -parenthesized expressions, call adjacency (`name(` with no space between), -list and dict literals, and two-space indentation. +The format has a normative machine-readable grammar in `src/qprogram/grammar/qp.lark`: a Lark dialect using LALR with a two-space `Indenter` and no bracket types that suppress newlines, since `.qp` is strictly line-based. `qprogram.grammar.grammar_text()` returns its source, `parser()` builds the reference parser, and `parse_text()` parses a document with it after normalizing a missing trailing newline. `lark` is a dev-only dependency, so `parser()` raises `ModuleNotFoundError` on an ordinary install. + +It is a specification artifact, not the production parser. Generating the production parser from it would cost the three things the hand-written one gives: a line number on every error, registry lookups that decide whether a keyword exists at all, and schema resolution for bus paths. None of those are expressible in a context-free grammar. What the separation costs in return is that the two have to be kept in step, which `tests/test_grammar.py` does in both directions. + +Positively, the writer's output for a full-feature program, a schema program, a fragment program, and a vendor program must parse under the grammar, as must every program the round-trip hypothesis strategies generate (`programs()` and `fragment_programs()`, imported from `tests/test_round_trip_property.py`). Negatively, a corpus of thirteen syntactic malformations, from a missing header through an unparenthesized expression to a dangling dict literal, must be rejected by `qp.loads` and by the reference parser both. A last test asserts that every hard keyword the grammar declares (`var`, `for`, `in`, `if`, `elif`, `else`, `and`, `or`, `not`, `true`, `false`, `null`, `fragment`) is in `qp.RESERVED_KEYWORDS`, so `var for` cannot be accepted by one side and rejected by the other. + +The grammar over-approximates whatever is semantic rather than syntactic: any identifier is a valid operation or block keyword, section order is free, and version shapes, duplicate declarations, and bus-path resolution are all post-parse checks. It is exact about token shapes: quoting, parenthesized expressions, call adjacency (`name(` with no space between), list and dict literals, and two-space indentation. ## Variable identifiers -The serializer key for a variable is `Variable.id`, and the identifier in the -file is that id verbatim. That works because the constraint is enforced -upstream: `Variable` validates its id against `[A-Za-z_][A-Za-z0-9_]*` and -rejects [reserved keywords](../reference/reserved.md) at construction, and -`QProgram.variable` rejects a duplicate id within one program. So the writer -never sanitizes and never invents a disambiguation suffix, and `.qp` files stay -stable across re-serializations. Routing every emission through the -`_var_idents` table anyway keeps emit-time renaming a single-point change if -that ever becomes necessary. - -A fragment section gets its own identifier scope. `_write_fragments` saves the -table, adds the fragment's parameters and locals to a copy, and restores it -afterwards, so a fragment may shadow a host id: a fragment body can only -reference its own parameters and locals, so there is nothing for the shadow to -hide. On the parse side a loop variable is declared on demand, which means a -hand-written file can drive `for t in Range(0, 100, 10):` with no `var t` line -of its own. +The serializer key for a variable is `Variable.id`, and the identifier in the file is that id verbatim. That works because the constraint is enforced upstream: `Variable` validates its id against `[A-Za-z_][A-Za-z0-9_]*` and rejects [reserved keywords](../reference/reserved.md) at construction, and `QProgram.variable` rejects a duplicate id within one program. So the writer never sanitizes and never invents a disambiguation suffix, and `.qp` files stay stable across re-serializations. Routing every emission through the `_var_idents` table anyway keeps emit-time renaming a single-point change if that ever becomes necessary. + +A fragment section gets its own identifier scope. `_write_fragments` saves the table, adds the fragment's parameters and locals to a copy, and restores it afterwards, so a fragment may shadow a host id: a fragment body can only reference its own parameters and locals, so there is nothing for the shadow to hide. On the parse side a loop variable is declared on demand, which means a hand-written file can drive `for t in Range(0, 100, 10):` with no `var t` line of its own. ## Arrays and file-backed sweeps -Array-valued arguments are emitted as bracket literals, in full, never -truncated, because the literal has to reload to exactly the same values. That -covers `Values.points`, which gets the bare `[...]` sugar in a `for` header, -and `Arbitrary.samples` alike. Only 1-D arrays have a `.qp` form; anything else -raises `Cannot serialize a 2-D array; only 1-D arrays have a .qp form`. On the -way back in, a bracket literal decodes to a plain Python list and the -constructor converts it, so a consumer that wants an array is the one that -makes it. +Array-valued arguments are emitted as bracket literals, in full, never truncated, because the literal has to reload to exactly the same values. That covers `Values.points`, which gets the bare `[...]` sugar in a `for` header, and `Arbitrary.samples` alike. Only 1-D arrays have a `.qp` form; anything else raises `Cannot serialize a 2-D array; only 1-D arrays have a .qp form`. On the way back in, a bracket literal decodes to a plain Python list and the constructor converts it, so a consumer that wants an array is the one that makes it. -`_parse_number` keeps `int` and `float` distinct: a literal written without a -decimal point or an exponent, whose value is integral, comes back as an `int`. -Without that, integer sweep bounds would silently become floats and the second -write would differ from the first. Non-finite literals (`inf`, `nan`) stay -floats, since `int()` on them raises. +`_parse_number` keeps `int` and `float` distinct: a literal written without a decimal point or an exponent, whose value is integral, comes back as an `int`. Without that, integer sweep bounds would silently become floats and the second write would differ from the first. Non-finite literals (`inf`, `nan`) stay floats, since `int()` on them raises. -For a sweep whose values are large or live outside the program, use the file -source instead. The path, not the data, is what the `.qp` file carries: +For a sweep whose values are large or live outside the program, use the file source instead. The path, not the data, is what the `.qp` file carries: ``` #!QProgram 0.2 @@ -650,54 +289,15 @@ body: play "drive" "pi" ``` -Both `File.length()` and `File.values()` call `np.load`, and neither caches; a -cached array would join the source's structural equality, so a loaded instance -would stop comparing equal to a fresh one. The `.npy` file therefore has to be -readable wherever the program is used, not only where it runs: `length()` is -what `Parallel` calls to check lockstep when two sweeps compose with `|`, and -what the executor calls to size its result arrays. Composing a `File` sweep -into a parallel pair with the file missing raises `FileNotFoundError` at build -time, and a file holding an empty array or an array of rank other than 1 raises -`ValidationError` naming the path. +Both `File.length()` and `File.values()` call `np.load`, and neither caches; a cached array would join the source's structural equality, so a loaded instance would stop comparing equal to a fresh one. The `.npy` file therefore has to be readable wherever the program is used, not only where it runs: `length()` is what `Parallel` calls to check lockstep when two sweeps compose with `|`, and what the executor calls to size its result arrays. Composing a `File` sweep into a parallel pair with the file missing raises `FileNotFoundError` at build time, and a file holding an empty array or an array of rank other than 1 raises `ValidationError` naming the path. ## Round-trip stability -`dumps(loads(dumps(p))) == dumps(p)` holds because every choice the writer -makes is a function of the AST rather than of history, and every choice the -parser makes preserves the distinctions the writer's choices depend on. -Concretely: variable ids are written verbatim, so no sanitization or suffix -allocation can differ between two writes; keyword arguments are compared -against their constructor defaults, so the second write emits the same set as -the first; `_parse_number` keeps integers integral; `_escape_str` and -`_unescape_str` are exact inverses, so a label holding quotes, backslashes, or -newlines survives; `_QuotedStr` keeps a raw-string bus raw, so the second write -quotes it again rather than promoting it to a path; a schema is always written -structurally, so a preset and the dynamic schema it reloads as emit the same -text; fragment order and `require` order are recomputed deterministically; and -fragment-call arguments are always positional in parameter order, so a call -built with keywords reloads and rewrites identically. - -`tests/test_round_trip.py` asserts byte stability one feature surface at a -time: metadata, variable declarations, several schema presets, plain-string -buses, the core operations, inline waveforms, expressions, sweeps and parallel -loops, conditionals, measurement handles and `fields=`, vendor operations, and -the `rebind` and `with_waveforms` transforms. - -`test_round_trip_full_features` stacks many of them into one program: a -transmon schema, an `average`, a parallel pair of sweeps with a third sweep -nested inside it, math-function and comparison expressions, an `IQDrag` whose -amplitude is a variable, `set_gain`, `set_frequency`, `set_phase`, `set_offset`, -`play`, `sync`, `wait`, and a `measure` carrying `fields=("iq", "raw")`. It is -not exhaustive: it holds no vendor operation, no conditional, and no fragment, -which have their own tests (`test_round_trip_with_vendor`, -`test_round_trip_vendor_op_inside_conditional`, -`test_round_trip_conditional_full_chain`, and -`tests/test_fragments_serialization.py`). `test_round_trip_loaded_equals_original` -adds the structural half: a reloaded body compares equal to the original and -hashes the same. On top of that, `tests/test_round_trip_property.py` generates -programs with hypothesis and asserts both byte stability and structural -equality after a round trip, including one property that a path-shaped raw bus -string survives intact in a program that has a schema. +`dumps(loads(dumps(p))) == dumps(p)` holds because every choice the writer makes is a function of the AST rather than of history, and every choice the parser makes preserves the distinctions the writer's choices depend on. Concretely: variable ids are written verbatim, so no sanitization or suffix allocation can differ between two writes; keyword arguments are compared against their constructor defaults, so the second write emits the same set as the first; `_parse_number` keeps integers integral; `_escape_str` and `_unescape_str` are exact inverses, so a label holding quotes, backslashes, or newlines survives; `_QuotedStr` keeps a raw-string bus raw, so the second write quotes it again rather than promoting it to a path; a schema is always written structurally, so a preset and the dynamic schema it reloads as emit the same text; fragment order and `require` order are recomputed deterministically; and fragment-call arguments are always positional in parameter order, so a call built with keywords reloads and rewrites identically. + +`tests/test_round_trip.py` asserts byte stability one feature surface at a time: metadata, variable declarations, several schema presets, plain-string buses, the core operations, inline waveforms, expressions, sweeps and parallel loops, conditionals, measurement handles and `fields=`, vendor operations, and the `rebind` and `with_waveforms` transforms. + +`test_round_trip_full_features` stacks many of them into one program: a transmon schema, an `average`, a parallel pair of sweeps with a third sweep nested inside it, math-function and comparison expressions, an `IQDrag` whose amplitude is a variable, `set_gain`, `set_frequency`, `set_phase`, `set_offset`, `play`, `sync`, `wait`, and a `measure` carrying `fields=("iq", "raw")`. It is not exhaustive: it holds no vendor operation, no conditional, and no fragment, which have their own tests (`test_round_trip_with_vendor`, `test_round_trip_vendor_op_inside_conditional`, `test_round_trip_conditional_full_chain`, and `tests/test_fragments_serialization.py`). `test_round_trip_loaded_equals_original` adds the structural half: a reloaded body compares equal to the original and hashes the same. On top of that, `tests/test_round_trip_property.py` generates programs with hypothesis and asserts both byte stability and structural equality after a round trip, including one property that a path-shaped raw bus string survives intact in a program that has a schema. If you change anything in the writer or the parser, run those first: @@ -705,13 +305,7 @@ If you change anything in the writer or the parser, run those first: uv run pytest tests/test_round_trip.py tests/test_round_trip_property.py -v ``` -When stability breaks, the cause is almost always one of four things: a new -operation, waveform, or sweep source added without a registration; a -non-default keyword argument being emitted positionally, or the reverse; a -class attribute renamed while the registry still references the old name; or a -constructor default changed so that a stored value now compares equal to it. -The assertion prints both texts, so diffing the first `dumps` against the -second points at the line that moved. +When stability breaks, the cause is almost always one of four things: a new operation, waveform, or sweep source added without a registration; a non-default keyword argument being emitted positionally, or the reverse; a class attribute renamed while the registry still references the old name; or a constructor default changed so that a stored value now compares equal to it. The assertion prints both texts, so diffing the first `dumps` against the second points at the line that moved. ## Extension points @@ -723,18 +317,6 @@ second points at the line that moved. | `qp.register_vendor_block(vendor, name, cls)` | a vendor control-flow block, emitted as `.:` | | `qp.register_vendor_version(vendor, version)` | the vendor's protocol version, once per package | -`qprogram.serialization.register_operation(name, cls, *, vendor=None, -serialize=None, parse=None)` and `register_block(name, cls, *, vendor=None, -serialize_header=None, parse_header=None)` are the underlying calls. They are -what core registration uses, and what to reach for when the default reflection -cannot express the syntax. Neither is re-exported at the top level; both return -`cls` unchanged so the call can stand in for the class at the point of -registration, though a bare `@register_operation` decoration does not work, -since `cls` is the second positional parameter rather than the first. - -A vendor block that repeats its body should also set `REPEATS = True` on the -class, so it counts toward the `max_loop_nesting` limit a -[profile](../guide/capabilities.md#profile-bundles) declares. The whole -vendor-package story, entry point included, is in [building a vendor -extension](vendor-extensions.md); the wire format itself is specified in the -[`.qp` file format reference](../reference/qp-format.md). +`qprogram.serialization.register_operation(name, cls, *, vendor=None, serialize=None, parse=None)` and `register_block(name, cls, *, vendor=None, serialize_header=None, parse_header=None)` are the underlying calls. They are what core registration uses, and what to reach for when the default reflection cannot express the syntax. Neither is re-exported at the top level; both return `cls` unchanged so the call can stand in for the class at the point of registration, though a bare `@register_operation` decoration does not work, since `cls` is the second positional parameter rather than the first. + +A vendor block that repeats its body should also set `REPEATS = True` on the class, so it counts toward the `max_loop_nesting` limit a [profile](../guide/capabilities.md#profile-bundles) declares. The whole vendor-package story, entry point included, is in [building a vendor extension](vendor-extensions.md); the wire format itself is specified in the [`.qp` file format reference](../reference/qp-format.md). diff --git a/docs/developer/testing.md b/docs/developer/testing.md index f8939f0..f2efdf8 100644 --- a/docs/developer/testing.md +++ b/docs/developer/testing.md @@ -1,11 +1,6 @@ # Testing -`tests/` holds 1582 tests in 34 files. They cover 95.5% of the 5214 statements -and 1584 branches under `src/qprogram`, run in about four seconds without -coverage and six with it, and need no hardware, no network, and no files on -disk beyond what they write to `tmp_path`. The suite is also the specification -for the parts of the library that have no other one: the serialization -round-trip, the capability protocol, and the reference executor. +`tests/` holds 1582 tests in 34 files. They cover 95.5% of the 5214 statements and 1584 branches under `src/qprogram`, run in about four seconds without coverage and six with it, and need no hardware, no network, and no files on disk beyond what they write to `tmp_path`. The suite is also the specification for the parts of the library that have no other one: the serialization round-trip, the capability protocol, and the reference executor. ## Running tests @@ -18,23 +13,13 @@ uv run pytest -k "set_phase" # by keyword uv run pytest --durations=10 # the ten slowest tests ``` -`uv sync --all-extras` is enough on its own, because the `dev` dependency group -installs by default and carries pytest, `pytest-cov`, `pytest-mock`, -`hypothesis`, and `lark`. `testpaths = ["tests"]` means a bare `pytest` -collects that directory and nothing else, and -`addopts = ["-ra", "--strict-markers", "--strict-config"]` applies on every -run. `minversion = "9.0"` makes an older pytest refuse to start rather than -fail somewhere confusing. +`uv sync --all-extras` is enough on its own, because the `dev` dependency group installs by default and carries pytest, `pytest-cov`, `pytest-mock`, `hypothesis`, and `lark`. `testpaths = ["tests"]` means a bare `pytest` collects that directory and nothing else, and `addopts = ["-ra", "--strict-markers", "--strict-config"]` applies on every run. `minversion = "9.0"` makes an older pytest refuse to start rather than fail somewhere confusing. -`tests/` has no `__init__.py`, so pytest prepends the directory to `sys.path`. -That is what lets `tests/test_grammar.py` write -`from test_round_trip_property import fragment_programs, programs` and lets -every file that needs the in-tree vendor write `import _dummy_vendor`. +`tests/` has no `__init__.py`, so pytest prepends the directory to `sys.path`. That is what lets `tests/test_grammar.py` write `from test_round_trip_property import fragment_programs, programs` and lets every file that needs the in-tree vendor write `import _dummy_vendor`. ## What is covered -The suite is function-style, with no test classes anywhere, and its file layout -mirrors the source layout. +The suite is function-style, with no test classes anywhere, and its file layout mirrors the source layout. | Files | Tests | What they pin | |---|---|---| @@ -54,28 +39,9 @@ mirrors the source layout. | `test_lsp.py` | 14 | `check_text()`, the `check` and `explain` CLI modes, and `create_server()`, which builds the server the `serve` mode starts; the stdio loop itself is marked `pragma: no cover`. | | `test_docstring_style.py` | 3 | That no module under `src/` carries a reStructuredText cross-reference role, and that no Markdown cross-reference lost its target. | -Two files are property-based. `tests/test_round_trip_property.py` builds -programs with hypothesis and asserts both structural equality and byte -stability after a round trip: `test_round_trip_structural_equality` and -`test_round_trip_byte_stability` run 60 examples each, -`test_fragment_round_trip_property` runs 40, and -`test_round_trip_long_sweeps_property` runs 25 over `Values` arrays of 51 to -200 floats, comparing the reloaded array element by element rather than through -structural equality. The strategies are adversarial about the fragile spots: -quotes, backslashes, `#`, and newlines in metadata text, path-shaped raw bus -strings such as `q[0].drive` that must not be promoted to bus references on -reload, ints against floats, and deep block nesting. Every generator sets -`deadline=None`, since a slow first example otherwise fails the test on a -loaded machine. - -`tests/test_grammar.py` imports `programs()` and `fragment_programs()` from -that file and reruns them, 40 and 25 examples, through the reference Lark -parser. It also keeps a hand-built corpus covering each feature family on the -positive side and a curated corpus of syntactically malformed inputs on the -negative side, which must be rejected by both the grammar and the production -parser. Semantic errors are out of the grammar's scope: it over-approximates -unknown operations, duplicate variables, and unresolvable bus paths as valid -syntax, and the parser rejects them after parsing. +Two files are property-based. `tests/test_round_trip_property.py` builds programs with hypothesis and asserts both structural equality and byte stability after a round trip: `test_round_trip_structural_equality` and `test_round_trip_byte_stability` run 60 examples each, `test_fragment_round_trip_property` runs 40, and `test_round_trip_long_sweeps_property` runs 25 over `Values` arrays of 51 to 200 floats, comparing the reloaded array element by element rather than through structural equality. The strategies are adversarial about the fragile spots: quotes, backslashes, `#`, and newlines in metadata text, path-shaped raw bus strings such as `q[0].drive` that must not be promoted to bus references on reload, ints against floats, and deep block nesting. Every generator sets `deadline=None`, since a slow first example otherwise fails the test on a loaded machine. + +`tests/test_grammar.py` imports `programs()` and `fragment_programs()` from that file and reruns them, 40 and 25 examples, through the reference Lark parser. It also keeps a hand-built corpus covering each feature family on the positive side and a curated corpus of syntactically malformed inputs on the negative side, which must be rejected by both the grammar and the production parser. Semantic errors are out of the grammar's scope: it over-approximates unknown operations, duplicate variables, and unresolvable bus paths as valid syntax, and the parser rejects them after parsing. ## Fixtures @@ -97,10 +63,7 @@ Shared fixtures live in `tests/conftest.py`. | `array_values` | `np.array([0.0, 0.1, 0.3, 0.5, 0.7, 1.0])`, for a `Values` sweep. | | `dummy_vendor` | Activates the in-tree vendor extension for one test, then tears it down. | -`rabi_program` is the one to reach for when a test needs a program with more -than one feature in it; `tests/test_writer.py` and `tests/test_parser.py` use -it as the payload for the file-level `save` and `load` cases. A test takes a -fixture as a parameter and needs no import beyond the package itself: +`rabi_program` is the one to reach for when a test needs a program with more than one feature in it; `tests/test_writer.py` and `tests/test_parser.py` use it as the payload for the file-level `save` and `load` cases. A test takes a fixture as a parameter and needs no import beyond the package itself: ```python import qprogram as qp @@ -111,30 +74,13 @@ def test_round_trip_is_byte_stable(rabi_program): assert qp.dumps(qp.loads(text)) == text ``` -`tests/_dummy_vendor.py` is a complete vendor extension with no external -dependency: a namespace, a typed mixin, a pre-combined `DummyQProgram`, six -operations, and a profile bundle. Its `activate()` registers the namespace, the -vendor version, the operations, and the profile; its `deactivate()` pops every -one of those back out of the global registries. Because those registries are -process-wide, a test that installed the vendor through an import side effect -would leak it into every test that ran afterwards, so use the `dummy_vendor` -fixture and let the teardown run. - -Four fixtures are local to the file that needs them rather than shared. -`isolated_registry` in `tests/test_protocol.py` monkeypatches -`qprogram.protocol.PROFILE_REGISTRY` to an empty dict so profile registration -tests do not pollute the real one. `vendor_source` in -`tests/test_sweep_builder.py` defines and registers an out-of-core -`SweepSource` subclass, the way a vendor extension would. `dummy_inactive` in -`tests/test_vendor_discovery.py` guarantees the dummy vendor is not registered -and clears the discovery cache, so entry-point discovery has to do the work -itself. `toy_program` in `tests/test_vendor.py` registers a throwaway `toy` -namespace on `QProgram` for one test and pops it again afterwards. +`tests/_dummy_vendor.py` is a complete vendor extension with no external dependency: a namespace, a typed mixin, a pre-combined `DummyQProgram`, six operations, and a profile bundle. Its `activate()` registers the namespace, the vendor version, the operations, and the profile; its `deactivate()` pops every one of those back out of the global registries. Because those registries are process-wide, a test that installed the vendor through an import side effect would leak it into every test that ran afterwards, so use the `dummy_vendor` fixture and let the teardown run. + +Four fixtures are local to the file that needs them rather than shared. `isolated_registry` in `tests/test_protocol.py` monkeypatches `qprogram.protocol.PROFILE_REGISTRY` to an empty dict so profile registration tests do not pollute the real one. `vendor_source` in `tests/test_sweep_builder.py` defines and registers an out-of-core `SweepSource` subclass, the way a vendor extension would. `dummy_inactive` in `tests/test_vendor_discovery.py` guarantees the dummy vendor is not registered and clears the discovery cache, so entry-point discovery has to do the work itself. `toy_program` in `tests/test_vendor.py` registers a throwaway `toy` namespace on `QProgram` for one test and pops it again afterwards. ## Coverage configuration -Coverage measures the `qprogram` package with branch tracking on, prints -missing lines, and does not skip fully covered files: +Coverage measures the `qprogram` package with branch tracking on, prints missing lines, and does not skip fully covered files: ```toml [tool.coverage.run] @@ -154,59 +100,20 @@ skip_covered = false precision = 1 ``` -There is no `omit` list and no `fail_under`, so every module appears in the -report and a local `pytest --cov` never fails on the number alone. Regressions -show up in Codecov instead, which the 3.13 job in `tests.yml` uploads to after -running: +There is no `omit` list and no `fail_under`, so every module appears in the report and a local `pytest --cov` never fails on the number alone. Regressions show up in Codecov instead, which the 3.13 job in `tests.yml` uploads to after running: ```bash pytest --cov=qprogram --cov-report=xml --cov-report=term-missing \ --junitxml=junit.xml -o junit_family=legacy ``` -What is left uncovered is worth reading as a list of gaps rather than as proof -there is nothing there. `src/qprogram/operations/call.py` sits at 52.4%, -because a `Call`'s `variables()`, `buses()`, `required_capabilities()`, and -`__repr__` are never reached: validation expands fragments before it walks, so -nothing in the normal path introspects a call site. -`src/qprogram/platform.py` sits at 58.8%; `ReferencePlatform` inherits the -default `validate`, `plan`, and `explain`, and `tests/test_executor.py` -exercises the inherited `explain`, but nothing calls the `validate` or `plan` -defaults or the `stream` method that raises `NotImplementedError`. -`src/qprogram/optimization.py` sits at 78.9%, missing the early return for a -program with no `Average` in its body and the recursion into conditional arms -and parallel loop headers. `src/qprogram/grammar/__init__.py` sits at 77.3% -only because `parse_text()` is never called: `tests/test_grammar.py` builds -`parser()` once and calls `.parse()` on it directly. -`src/qprogram/lsp.py` sits at 80.0% for two separate reasons: the `explain` CLI -path and the read from standard input run only inside the subprocess the CLI -tests spawn, which the coverage run does not follow, and the document-handler -bodies need a live editor client. The rest is single guards that the public API -makes unreachable. `tests/test_coverage_gaps.py` is where the narrow cases worth -pinning down collect, and it is the right home for any of the above. +What is left uncovered is worth reading as a list of gaps rather than as proof there is nothing there. `src/qprogram/operations/call.py` sits at 52.4%, because a `Call`'s `variables()`, `buses()`, `required_capabilities()`, and `__repr__` are never reached: validation expands fragments before it walks, so nothing in the normal path introspects a call site. `src/qprogram/platform.py` sits at 58.8%; `ReferencePlatform` inherits the default `validate`, `plan`, and `explain`, and `tests/test_executor.py` exercises the inherited `explain`, but nothing calls the `validate` or `plan` defaults or the `stream` method that raises `NotImplementedError`. `src/qprogram/optimization.py` sits at 78.9%, missing the early return for a program with no `Average` in its body and the recursion into conditional arms and parallel loop headers. `src/qprogram/grammar/__init__.py` sits at 77.3% only because `parse_text()` is never called: `tests/test_grammar.py` builds `parser()` once and calls `.parse()` on it directly. `src/qprogram/lsp.py` sits at 80.0% for two separate reasons: the `explain` CLI path and the read from standard input run only inside the subprocess the CLI tests spawn, which the coverage run does not follow, and the document-handler bodies need a live editor client. The rest is single guards that the public API makes unreachable. `tests/test_coverage_gaps.py` is where the narrow cases worth pinning down collect, and it is the right home for any of the above. ## Test style -Tests are functions, not methods on a `TestX` class, even when several of them -share setup; a fixture carries shared setup further than a class does, since it -can be reused across files. Parametrization is the normal way to run one -assertion over a list of inputs; pass `pytest.param(..., id="...")` when the -generated id would be unreadable. Those are also the only marks in the suite: -40 `@pytest.mark.parametrize` decorators and ten `@pytest.mark.usefixtures`. -No custom marker exists, and `--strict-markers` with no `markers` table in -`pyproject.toml` means the first one has to be registered before it can be -used. - -`pytest-mock` is installed but no test uses `mocker`; the suite builds real -objects and asserts on them, and reaches for pytest's own `monkeypatch` in the -two files that need to replace a module-level name: the profile registry in -`tests/test_protocol.py` and the entry-point lookup in -`tests/test_vendor_discovery.py`. -`filterwarnings = ["error"]` turns any warning a test emits into a failure of -that test, which is why a deprecation in a dependency shows up here first. -`xfail_strict = true` fails a test that was marked `xfail` and then started -passing. `--strict-config` makes an unknown key in -`[tool.pytest.ini_options]` an error rather than a silent no-op. +Tests are functions, not methods on a `TestX` class, even when several of them share setup; a fixture carries shared setup further than a class does, since it can be reused across files. Parametrization is the normal way to run one assertion over a list of inputs; pass `pytest.param(..., id="...")` when the generated id would be unreadable. Those are also the only marks in the suite: 40 `@pytest.mark.parametrize` decorators and ten `@pytest.mark.usefixtures`. No custom marker exists, and `--strict-markers` with no `markers` table in `pyproject.toml` means the first one has to be registered before it can be used. + +`pytest-mock` is installed but no test uses `mocker`; the suite builds real objects and asserts on them, and reaches for pytest's own `monkeypatch` in the two files that need to replace a module-level name: the profile registry in `tests/test_protocol.py` and the entry-point lookup in `tests/test_vendor_discovery.py`. `filterwarnings = ["error"]` turns any warning a test emits into a failure of that test, which is why a deprecation in a dependency shows up here first. `xfail_strict = true` fails a test that was marked `xfail` and then started passing. `--strict-config` makes an unknown key in `[tool.pytest.ini_options]` an error rather than a silent no-op. ## What to write a test for @@ -222,45 +129,19 @@ passing. `--strict-config` makes an unknown key in ## Speed -A full run takes 4.3 seconds and a coverage run 5.9 on a developer machine, and -the whole suite is worth running on every save. The slowest tests are the -`tests/test_lsp.py` CLI cases, which spawn a subprocess and take about 0.4 -seconds each, followed at around 0.2 by the hypothesis properties and by -`test_create_server_builds_with_handlers`, which pays for the `pygls` import. -Nothing else reaches 0.1. `pytest --durations=10` prints the ranking, and it is -the first thing to look at if a change pushes the total past a few seconds. +A full run takes 4.3 seconds and a coverage run 5.9 on a developer machine, and the whole suite is worth running on every save. The slowest tests are the `tests/test_lsp.py` CLI cases, which spawn a subprocess and take about 0.4 seconds each, followed at around 0.2 by the hypothesis properties and by `test_create_server_builds_with_handlers`, which pays for the `pygls` import. Nothing else reaches 0.1. `pytest --durations=10` prints the ranking, and it is the first thing to look at if a change pushes the total past a few seconds. ## CI -Three workflows run on a pull request. All of them skip while the pull request -is a draft, and all of them cancel an in-progress run when a new commit -arrives on the same branch. - -`tests.yml` runs the suite on a matrix of Python versions. A pull request gets -3.11 and 3.14, the oldest and newest supported, on the reasoning that nothing -under `src/` branches on the Python version; a push to `main` fills in 3.12 and -3.13. The 3.13 job is the one that carries coverage, and it also uploads -JUnit-format test results to Codecov. - -`code_quality.yml` splits into two independent jobs. `lint` runs -`ruff check --output-format=github .` and `ruff format --diff .` on Python 3.13, -in an environment installed with -`uv sync --frozen --only-group dev --no-install-project`: ruff takes its whole -configuration from `pyproject.toml` and never imports the code, so the project -itself does not need to be there. `types` installs the project with all extras and loops -`ty check --python-version ` over 3.11, 3.12, 3.13, and 3.14, which works -from one environment because `ty` resolves the standard library for the version -it is told to assume. - -`docs.yml` installs the project with the `docs` group and all extras, builds -the site with `zensical build --strict`, and uploads the result. The deploy job -runs only on a push to `main`. `--strict` turns a warning into a failure, and -the warning that matters is an unresolved mkdocstrings cross-reference, which -would otherwise ship as a dead link. - -Both `tests.yml` and `code_quality.yml` end in an aggregating job that fails if -any job it depends on failed or was cancelled, so a single required check -covers the whole matrix. +Three workflows run on a pull request. All of them skip while the pull request is a draft, and all of them cancel an in-progress run when a new commit arrives on the same branch. + +`tests.yml` runs the suite on a matrix of Python versions. A pull request gets 3.11 and 3.14, the oldest and newest supported, on the reasoning that nothing under `src/` branches on the Python version; a push to `main` fills in 3.12 and 3.13. The 3.13 job is the one that carries coverage, and it also uploads JUnit-format test results to Codecov. + +`code_quality.yml` splits into two independent jobs. `lint` runs `ruff check --output-format=github .` and `ruff format --diff .` on Python 3.13, in an environment installed with `uv sync --frozen --only-group dev --no-install-project`: ruff takes its whole configuration from `pyproject.toml` and never imports the code, so the project itself does not need to be there. `types` installs the project with all extras and loops `ty check --python-version ` over 3.11, 3.12, 3.13, and 3.14, which works from one environment because `ty` resolves the standard library for the version it is told to assume. + +`docs.yml` installs the project with the `docs` group and all extras, builds the site with `zensical build --strict`, and uploads the result. The deploy job runs only on a push to `main`. `--strict` turns a warning into a failure, and the warning that matters is an unresolved mkdocstrings cross-reference, which would otherwise ship as a dead link. + +Both `tests.yml` and `code_quality.yml` end in an aggregating job that fails if any job it depends on failed or was cancelled, so a single required check covers the whole matrix. Locally the same contract is four commands: @@ -273,8 +154,4 @@ uv run pytest --cov=qprogram ## Related pages -[Contributing](contributing.md) has the full pull-request workflow, including -the changelog fragment. -[Adding operations](adding-operations.md) and -[Adding waveforms](adding-waveforms.md) list the tests each of those changes -needs. +[Contributing](contributing.md) has the full pull-request workflow, including the changelog fragment. [Adding operations](adding-operations.md) and [Adding waveforms](adding-waveforms.md) list the tests each of those changes needs. diff --git a/docs/developer/vendor-extensions.md b/docs/developer/vendor-extensions.md index 1181660..cd73019 100644 --- a/docs/developer/vendor-extensions.md +++ b/docs/developer/vendor-extensions.md @@ -1,32 +1,14 @@ # Building a vendor extension -This guide builds a new vendor extension from scratch. The example vendor is -`fake_inst`, with two operations: a real-time `fake_inst.beep(bus, duration)` -and a host-side-only `fake_inst.set_threshold(bus, value)`. The same template -applies to any vendor package. - -Every Python snippet below is a source file inside the vendor package, apart -from the end-user script under -[Combining with other vendors](#combining-with-other-vendors). A vendor -package is ordinary external code, so it reaches QProgram through -`import qprogram as qp` and refers to its own modules by their real dotted -paths. The in-tree extension guides ([Adding an operation](adding-operations.md), -[Adding a waveform](adding-waveforms.md)) look different because a module inside -`src/qprogram/` cannot `import qprogram` without closing an import cycle. - -For a working reference, the test suite's `tests/_dummy_vendor.py` implements -steps 1 through 5 in a single module: operations (one of them a measurement -op), a namespace, a mixin, a pre-combined `QProgram`, capability tokens, and a -profile. Two things on this page it does not cover. It is not an installed -distribution, so it declares no `qprogram.vendors` entry point; -`tests/test_vendor_discovery.py` exercises step 6 against a stub one instead. -And it registers no vendor block; `tests/test_registry.py` and -`tests/test_writer.py` cover that. +This guide builds a new vendor extension from scratch. The example vendor is `fake_inst`, with two operations: a real-time `fake_inst.beep(bus, duration)` and a host-side-only `fake_inst.set_threshold(bus, value)`. The same template applies to any vendor package. + +Every Python snippet below is a source file inside the vendor package, apart from the end-user script under [Combining with other vendors](#combining-with-other-vendors). A vendor package is ordinary external code, so it reaches QProgram through `import qprogram as qp` and refers to its own modules by their real dotted paths. The in-tree extension guides ([Adding an operation](adding-operations.md), [Adding a waveform](adding-waveforms.md)) look different because a module inside `src/qprogram/` cannot `import qprogram` without closing an import cycle. + +For a working reference, the test suite's `tests/_dummy_vendor.py` implements steps 1 through 5 in a single module: operations (one of them a measurement op), a namespace, a mixin, a pre-combined `QProgram`, capability tokens, and a profile. Two things on this page it does not cover. It is not an installed distribution, so it declares no `qprogram.vendors` entry point; `tests/test_vendor_discovery.py` exercises step 6 against a stub one instead. And it registers no vendor block; `tests/test_registry.py` and `tests/test_writer.py` cover that. ## Package layout -A vendor extension is its own package, in its own repository, depending on -`qprogram`: +A vendor extension is its own package, in its own repository, depending on `qprogram`: ``` qprogram-fakeinst/ @@ -52,8 +34,7 @@ Five source files, in increasing order of glue: 1. `operations.py` defines the AST node classes. 2. `namespace.py` defines the typed methods. 3. `mixin.py` defines the typed `@property`. -4. `profiles.py` declares the vendor's capability tokens and ships one or more - `Profile` bundles. +4. `profiles.py` declares the vendor's capability tokens and ships one or more `Profile` bundles. 5. `__init__.py` registers everything and ships a pre-combined `QProgram`. ## Step 1: the operation classes @@ -97,8 +78,7 @@ class SetThreshold(qp.operations.Operation): ) ``` -Four class attributes control how the validator sees an operation, and all four -have defaults that suit the common case: +Four class attributes control how the validator sees an operation, and all four have defaults that suit the common case: | Attribute | Default | What it controls | |---|---|---| @@ -107,43 +87,13 @@ have defaults that suit the common case: | `BROADCASTS_WHEN_NO_BUS` | `False` | When `True` and `BUS_ATTRS` resolve to no buses, the op is routed across every bus in the program rather than the default-bus slot. Core `Sync(targets=None)` is the case this exists for. | | `AFFECTS_AVERAGING` | `False` | Whether the op gates the execution domain of an enclosing `average` block. Only ops producing the results an average accumulates should set it; `MeasurementOperation` sets it `True`. | -The base `Operation` class supplies `variables()`, `buses()`, `waveforms()`, -`walk()`, and structural equality and hashing, so a subclass normally overrides -nothing but `required_capabilities()`. Because equality and hashing are -structural, an instance must not be mutated after it has been used as a `set` -member or `dict` key; `QProgram.rebind` respects this by rewriting a fresh -`deepcopy`. - -The constructor signature is part of the wire format, not an implementation -detail. The default serializer walks `inspect.signature(cls.__init__)`: a -parameter with no default is emitted positionally in declaration order, a -parameter with a default is emitted as `name=value` only when the stored value -differs from that default, and a parameter with no matching attribute on the -instance is skipped. The parser binds the tokens back by the same names and -constructs the class with keyword arguments only. Three consequences follow. -Each parameter name must match the attribute that holds its value, or the value -is dropped on write. Reordering required parameters silently changes what older -files mean. Renaming a parameter breaks every file that used it as a keyword, -which is a major version bump. - -Only the value types the writer knows how to render can appear in an operation -attribute: expressions, waveforms, bus references, measurement handles, sweep -sources, strings, booleans, `None`, numbers including numpy scalars, lists and -tuples and 1-D arrays, and string-keyed dicts. Anything else raises -`SerializationError` rather than being coerced into a token the parser would -mis-type on reload. - -If the operation produces a measurement, subclass `MeasurementOperation` -(reachable as `qp.operations.operation.MeasurementOperation`) instead. Such a -class must set `self.handle` to the `MeasurementHandle` it is given and -`self.fields` to the result of `normalize_fields(...)`, which canonically sorts -and deduplicates the requested field names and rejects a name that has no -`measure.fields.` token registered. The base -`MeasurementOperation.required_capabilities()` returns one -`measure.fields.` token per requested field, so a subclass unions that -with its own identity token via `super()`. The namespace method calls -`_append_measurement` rather than `_append`, and registration needs the -measurement-aware serializer and parser described in step 5. +The base `Operation` class supplies `variables()`, `buses()`, `waveforms()`, `walk()`, and structural equality and hashing, so a subclass normally overrides nothing but `required_capabilities()`. Because equality and hashing are structural, an instance must not be mutated after it has been used as a `set` member or `dict` key; `QProgram.rebind` respects this by rewriting a fresh `deepcopy`. + +The constructor signature is part of the wire format, not an implementation detail. The default serializer walks `inspect.signature(cls.__init__)`: a parameter with no default is emitted positionally in declaration order, a parameter with a default is emitted as `name=value` only when the stored value differs from that default, and a parameter with no matching attribute on the instance is skipped. The parser binds the tokens back by the same names and constructs the class with keyword arguments only. Three consequences follow. Each parameter name must match the attribute that holds its value, or the value is dropped on write. Reordering required parameters silently changes what older files mean. Renaming a parameter breaks every file that used it as a keyword, which is a major version bump. + +Only the value types the writer knows how to render can appear in an operation attribute: expressions, waveforms, bus references, measurement handles, sweep sources, strings, booleans, `None`, numbers including numpy scalars, lists and tuples and 1-D arrays, and string-keyed dicts. Anything else raises `SerializationError` rather than being coerced into a token the parser would mis-type on reload. + +If the operation produces a measurement, subclass `MeasurementOperation` (reachable as `qp.operations.operation.MeasurementOperation`) instead. Such a class must set `self.handle` to the `MeasurementHandle` it is given and `self.fields` to the result of `normalize_fields(...)`, which canonically sorts and deduplicates the requested field names and rejects a name that has no `measure.fields.` token registered. The base `MeasurementOperation.required_capabilities()` returns one `measure.fields.` token per requested field, so a subclass unions that with its own identity token via `super()`. The namespace method calls `_append_measurement` rather than `_append`, and registration needs the measurement-aware serializer and parser described in step 5. ## Step 2: the typed namespace @@ -168,23 +118,9 @@ class FakeInstNamespace(qp.VendorNamespace): self._append(SetThreshold(bus=bus, value=value)) ``` -`VendorNamespace._append` does two things. It walks -`vars(operation).values()`, runs every `BusRef` it finds through the program's -`_validate_bus`, and does the same for `BusRef` items one level inside a list, -so a vendor op cannot smuggle in a bus from a different `BusSchema`. Then it -appends the operation to the program's active block. The walk is shallow: plain -strings are never validated, and a `BusRef` hidden inside a dict or a tuple is -not reached, so an operation that needs a bus checked should hold it as a plain -attribute or a list. - -`_append_measurement(op_cls, *, bus, name=None, **kwargs)` is the measurement -counterpart. It allocates the handle name from the program's per-bus counter, -the same counter `QProgram.measure` uses, so vendor and core measurements on -one bus never collide; constructs `op_cls(bus=bus, handle=handle, **kwargs)`; -appends the result through `_append`; and returns the handle. The measurement -operation's `__init__` therefore has to accept `bus` and `handle` as keywords. -A `name` that is empty, not a string, or already taken raises `ValidationError` -at the call site. +`VendorNamespace._append` does two things. It walks `vars(operation).values()`, runs every `BusRef` it finds through the program's `_validate_bus`, and does the same for `BusRef` items one level inside a list, so a vendor op cannot smuggle in a bus from a different `BusSchema`. Then it appends the operation to the program's active block. The walk is shallow: plain strings are never validated, and a `BusRef` hidden inside a dict or a tuple is not reached, so an operation that needs a bus checked should hold it as a plain attribute or a list. + +`_append_measurement(op_cls, *, bus, name=None, **kwargs)` is the measurement counterpart. It allocates the handle name from the program's per-bus counter, the same counter `QProgram.measure` uses, so vendor and core measurements on one bus never collide; constructs `op_cls(bus=bus, handle=handle, **kwargs)`; appends the result through `_append`; and returns the handle. The measurement operation's `__init__` therefore has to accept `bus` and `handle` as keywords. A `name` that is empty, not a string, or already taken raises `ValidationError` at the call site. ## Step 3: the mixin @@ -207,27 +143,13 @@ class FakeInstMixin: return ns ``` -The mixin exists for static typing and IDE autocomplete. Without it, -`program.fake_inst` still resolves, because `QProgram.__getattr__` looks the -name up in the vendor registry and caches the namespace on the instance under -the vendor name itself. With the mixin, the property is found by normal -attribute lookup and `__getattr__` never runs, which is why the cache goes into -a separate `_fake_inst_ns` slot. - -That interaction decides where registration happens. `register_vendor` refuses -a name that `hasattr` finds on the class it is called on, so calling it on the -pre-combined subclass, whose mixin already defines the property, fails with -`vendor name 'fake_inst' collides with a QProgram attribute; the namespace -would be unreachable because normal attribute lookup wins over vendor -dispatch`. Register on the base `QProgram`; the registry is a class-level dict, -so the entry is visible from every subclass. +The mixin exists for static typing and IDE autocomplete. Without it, `program.fake_inst` still resolves, because `QProgram.__getattr__` looks the name up in the vendor registry and caches the namespace on the instance under the vendor name itself. With the mixin, the property is found by normal attribute lookup and `__getattr__` never runs, which is why the cache goes into a separate `_fake_inst_ns` slot. + +That interaction decides where registration happens. `register_vendor` refuses a name that `hasattr` finds on the class it is called on, so calling it on the pre-combined subclass, whose mixin already defines the property, fails with `vendor name 'fake_inst' collides with a QProgram attribute; the namespace would be unreachable because normal attribute lookup wins over vendor dispatch`. Register on the base `QProgram`; the registry is a class-level dict, so the entry is visible from every subclass. ## Step 4: capability tokens and a profile -A vendor extension ships a capability profile: a named bundle listing which DSL -features the backend supports, the numeric limits the hardware imposes, and the -predicates that check context-sensitive constraints. The validator consumes the -profile to answer whether a given program can run on this platform. +A vendor extension ships a capability profile: a named bundle listing which DSL features the backend supports, the numeric limits the hardware imposes, and the predicates that check context-sensitive constraints. The validator consumes the profile to answer whether a given program can run on this platform. ```python # qprogram-fakeinst/src/qprogram_fakeinst/profiles.py @@ -305,63 +227,17 @@ def _register() -> None: qp.register_profile(FAKE_INST_DEFAULT_V1) ``` -`Profile` is a frozen dataclass with four required fields, `name`, `version` -as a `(major, minor, patch)` tuple, `extends`, and `capabilities`, plus three -fields that default to empty: `limits`, `predicates`, and -`vendor_versions`. Its `__post_init__` validates every capability token against -the global registry, so a typo fails at construction with -`Unknown capability token(s): ['vendor.fake_inst.bep']. Register via -qprogram.protocol.register_capability_tokens before use.` rather than surfacing -as a mysterious validation result later. `register_profile` is idempotent for an -equal profile, so an import-time side effect that runs twice is safe whether the -bundle is a module constant or built fresh each time, and raises -`Profile 'fake_inst-default-v1' is already registered with different content` -only when a profile with *different* content claims the name. Of an equal pair -the registry keeps the first object, so treat the profile you just registered as -possibly not the one `resolve_profile` returns. - -Bus-touching ops, waveform tokens, and `measure.fields.*` all belong on a bus -profile, because the nodes that carry them route to a `(bus, domain)` slot. -Block, expression, and sweep tokens belong on the platform-level slot, which a -platform materializes from core `qprogram-base-v1`; see -[Building `CompilerCapabilities` from a profile](capability-protocol.md#building-compilercapabilities-from-a-profile). -Core `op.set_parameter` and `op.get_parameter` are bus-touching but host-side -only, and their tokens are not in `qprogram-base-v1`, so a platform that -supports them opts them into a bus slot's `host` half explicitly. - -Per-class waveform tokens (`waveform.square`, `waveform.iq_drag`, ...) refine -the channel-kind tokens `waveform.single` and `waveform.iq`. List a token for -every waveform class the compiler can lower. Omitting a token your backend can -actually run makes any program using it fail validation with one -`missing-capability` diagnostic per node, reading -`'Play' requires capability 'waveform.iq_drag' which is not supported by -'fake_inst-default-v1' (rt)`. - -The validator understands four limit keys: `max_loop_nesting`, -`max_parallel_loops`, and `max_measurements` are read from the platform slot, -and `min_wait_duration_ns` from the bus slot the `Wait` routes to. Other keys -are ignored, which lets a profile declare a limit an older validator has no -check for. Platform-level limits are applied through `limit_overrides=` when -the platform materializes its platform slot, not by listing them on a bus -profile where nothing reads them. - -A predicate is a callable `(node, ctx) -> Iterable[Diagnostic | -DomainConstraint]`, run against every visited node. Use one for a check that -depends on more than the node in front of it; the canonical example is "this -op's variable argument must be bound by a linear loop", which `ctx` can answer -and the node alone cannot. A predicate carried by both halves of a slot runs -once per `(domain, bus)` pair, so twice for a single-bus node and twice more -for each extra bus a multi-bus op touches. The validator discards duplicate -outputs, which is why a predicate has to be cheap and free of side effects. -[Capability protocol internals](capability-protocol.md) has the full predicate -and `ValidationContext` reference. - -For a tiered family of profiles (`-base-v1`, `-adaptive-v1`, ...) set -`extends=""`. Capabilities and predicates accumulate parent to -child; limits inherit and may be overridden. Start with a single -`-default-v1` and split only when a real device demands it, because a -proliferation of near-identical profiles is the classic way this kind of -protocol becomes unusable. +`Profile` is a frozen dataclass with four required fields, `name`, `version` as a `(major, minor, patch)` tuple, `extends`, and `capabilities`, plus three fields that default to empty: `limits`, `predicates`, and `vendor_versions`. Its `__post_init__` validates every capability token against the global registry, so a typo fails at construction with `Unknown capability token(s): ['vendor.fake_inst.bep']. Register via qprogram.protocol.register_capability_tokens before use.` rather than surfacing as a mysterious validation result later. `register_profile` is idempotent for an equal profile, so an import-time side effect that runs twice is safe whether the bundle is a module constant or built fresh each time, and raises `Profile 'fake_inst-default-v1' is already registered with different content` only when a profile with *different* content claims the name. Of an equal pair the registry keeps the first object, so treat the profile you just registered as possibly not the one `resolve_profile` returns. + +Bus-touching ops, waveform tokens, and `measure.fields.*` all belong on a bus profile, because the nodes that carry them route to a `(bus, domain)` slot. Block, expression, and sweep tokens belong on the platform-level slot, which a platform materializes from core `qprogram-base-v1`; see [Building `CompilerCapabilities` from a profile](capability-protocol.md#building-compilercapabilities-from-a-profile). Core `op.set_parameter` and `op.get_parameter` are bus-touching but host-side only, and their tokens are not in `qprogram-base-v1`, so a platform that supports them opts them into a bus slot's `host` half explicitly. + +Per-class waveform tokens (`waveform.square`, `waveform.iq_drag`, ...) refine the channel-kind tokens `waveform.single` and `waveform.iq`. List a token for every waveform class the compiler can lower. Omitting a token your backend can actually run makes any program using it fail validation with one `missing-capability` diagnostic per node, reading `'Play' requires capability 'waveform.iq_drag' which is not supported by 'fake_inst-default-v1' (rt)`. + +The validator understands four limit keys: `max_loop_nesting`, `max_parallel_loops`, and `max_measurements` are read from the platform slot, and `min_wait_duration_ns` from the bus slot the `Wait` routes to. Other keys are ignored, which lets a profile declare a limit an older validator has no check for. Platform-level limits are applied through `limit_overrides=` when the platform materializes its platform slot, not by listing them on a bus profile where nothing reads them. + +A predicate is a callable `(node, ctx) -> Iterable[Diagnostic | DomainConstraint]`, run against every visited node. Use one for a check that depends on more than the node in front of it; the canonical example is "this op's variable argument must be bound by a linear loop", which `ctx` can answer and the node alone cannot. A predicate carried by both halves of a slot runs once per `(domain, bus)` pair, so twice for a single-bus node and twice more for each extra bus a multi-bus op touches. The validator discards duplicate outputs, which is why a predicate has to be cheap and free of side effects. [Capability protocol internals](capability-protocol.md) has the full predicate and `ValidationContext` reference. + +For a tiered family of profiles (`-base-v1`, `-adaptive-v1`, ...) set `extends=""`. Capabilities and predicates accumulate parent to child; limits inherit and may be overridden. Start with a single `-default-v1` and split only when a real device demands it, because a proliferation of near-identical profiles is the classic way this kind of protocol becomes unusable. ## Step 5: the `__init__.py` glue @@ -413,61 +289,19 @@ __all__ = [ ] ``` -Four registration steps run on import, five calls since each operation is -registered on its own line, plus the capability-token registration that happens -as a side effect of importing `profiles.py`. Profile registration is a separate -call so the order stays explicit. The last piece, the `qprogram.vendors` entry -point in [`pyproject.toml`](#step-6-pyprojecttoml), is what lets `qp.loads()` -trigger this whole import on demand, so a `.qp` file requiring the vendor loads -without an explicit `import`. - -`register_vendor(name, namespace_cls)` rejects three kinds of name. A reserved -one, meaning any of the [reserved keywords](../reference/reserved.md) or the -`"core"` sentinel, raises `vendor name 'core' is reserved (see -qprogram.RESERVED_KEYWORDS plus the 'core' sentinel); pick a different -namespace for this vendor extension`. A name that collides with a `QProgram` -attribute, whether a method such as `play`, a public instance attribute -(`label`, `description`), or a mixin property already on the class, raises the -collision message from step 3. A name already held by a different namespace -class raises rather than replacing it, since silently taking over another -vendor's namespace would be a supply-chain hazard. Re-registering the same -class under the same name is a no-op. - -`register_vendor_version(vendor, version)` takes a semver string with at least -integer `major.minor`; `"0.1"` and `"0.1.0"` are both accepted and the patch -component is informational, since what an extension registers is a package -version. A version with fewer components raises `vendor version '1' must have at -least major.minor components`, and a non-integer component raises `vendor -version '0.x' has non-integer major/minor components`. Reading the value from -`importlib.metadata` keeps a single source of truth in `pyproject.toml`, but note -what the fallback does: when the package is not installed as a distribution, -`__version__` becomes `"0.0.0"` and the extension advertises major 0, minor 0, so -a file written as `require fake_inst 0.1` asks for more than it provides and is -refused. Registering the version is also what marks the vendor as active, which -is the check `try_activate_vendor` makes. - -`register_vendor_operation(vendor, name, cls, *, serialize=None, parse=None)` -keys on `(vendor, name)`. Re-registering the same class refreshes its -callbacks; a different class under a taken pair raises `operation -'fake_inst.beep' is already registered to pkg.Beep; refusing to replace it with -other.Beep`. A measurement operation passes the two callbacks from -`qprogram.serialization._specs`, `measurement_op_serialize` and -`make_measurement_op_parse(cls)`, so the parser reconstructs the one canonical -`MeasurementHandle` instance that every `MeasurementRef` naming it shares. - -`Profile.vendor_versions` records `(major, minor, patch)` tuples and is -informational. Only the string registered with `register_vendor_version` -decides whether a `.qp` file loads. +Four registration steps run on import, five calls since each operation is registered on its own line, plus the capability-token registration that happens as a side effect of importing `profiles.py`. Profile registration is a separate call so the order stays explicit. The last piece, the `qprogram.vendors` entry point in [`pyproject.toml`](#step-6-pyprojecttoml), is what lets `qp.loads()` trigger this whole import on demand, so a `.qp` file requiring the vendor loads without an explicit `import`. + +`register_vendor(name, namespace_cls)` rejects three kinds of name. A reserved one, meaning any of the [reserved keywords](../reference/reserved.md) or the `"core"` sentinel, raises `vendor name 'core' is reserved (see qprogram.RESERVED_KEYWORDS plus the 'core' sentinel); pick a different namespace for this vendor extension`. A name that collides with a `QProgram` attribute, whether a method such as `play`, a public instance attribute (`label`, `description`), or a mixin property already on the class, raises the collision message from step 3. A name already held by a different namespace class raises rather than replacing it, since silently taking over another vendor's namespace would be a supply-chain hazard. Re-registering the same class under the same name is a no-op. + +`register_vendor_version(vendor, version)` takes a semver string with at least integer `major.minor`; `"0.1"` and `"0.1.0"` are both accepted and the patch component is informational, since what an extension registers is a package version. A version with fewer components raises `vendor version '1' must have at least major.minor components`, and a non-integer component raises `vendor version '0.x' has non-integer major/minor components`. Reading the value from `importlib.metadata` keeps a single source of truth in `pyproject.toml`, but note what the fallback does: when the package is not installed as a distribution, `__version__` becomes `"0.0.0"` and the extension advertises major 0, minor 0, so a file written as `require fake_inst 0.1` asks for more than it provides and is refused. Registering the version is also what marks the vendor as active, which is the check `try_activate_vendor` makes. + +`register_vendor_operation(vendor, name, cls, *, serialize=None, parse=None)` keys on `(vendor, name)`. Re-registering the same class refreshes its callbacks; a different class under a taken pair raises `operation 'fake_inst.beep' is already registered to pkg.Beep; refusing to replace it with other.Beep`. A measurement operation passes the two callbacks from `qprogram.serialization._specs`, `measurement_op_serialize` and `make_measurement_op_parse(cls)`, so the parser reconstructs the one canonical `MeasurementHandle` instance that every `MeasurementRef` naming it shares. + +`Profile.vendor_versions` records `(major, minor, patch)` tuples and is informational. Only the string registered with `register_vendor_version` decides whether a `.qp` file loads. ## Step 6: `pyproject.toml` -The `[project.entry-points."qprogram.vendors"]` table is what makes the -extension discoverable without an import. When a `.qp` file declares -`require fake_inst ` and the package is installed but not yet imported, -`qp.loads(...)` imports the module named here on demand, and its import-time -side effects run the registration steps above. The entry-point *name* is the -vendor namespace; the *value* is the module that self-registers. The group name -is exactly `qprogram.vendors`, and nothing else is scanned. +The `[project.entry-points."qprogram.vendors"]` table is what makes the extension discoverable without an import. When a `.qp` file declares `require fake_inst ` and the package is installed but not yet imported, `qp.loads(...)` imports the module named here on demand, and its import-time side effects run the registration steps above. The entry-point *name* is the vendor namespace; the *value* is the module that self-registers. The group name is exactly `qprogram.vendors`, and nothing else is scanned. ```toml [project] @@ -504,86 +338,37 @@ source = ["qprogram_fakeinst"] branch = true ``` -`try_activate_vendor(vendor)` is what performs the activation. It returns -`True` immediately when a protocol version is already registered, without -scanning anything. Otherwise it looks the vendor up in the entry-point map and -returns `False` when no installed distribution claims it, leaving the caller to -decide whether that is an error. When an entry point is found it calls -`ep.load()` and raises `VendorActivationError` in two cases: the import itself -failing, reported as `vendor extension for 'fake_inst' is installed (entry -point 'qprogram_fakeinst') but failed to import: ...`, and an import that -succeeds without registering a version, reported as `... imported from entry -point 'qprogram_fakeinst' but did not register a protocol version; the package -must call register_vendor_version('fake_inst', '') on import`. The -second is the failure mode of a package that ships the entry point but forgets -step 5. - -The entry-point scan is memoized for the life of the process, so a -distribution installed after the first lookup stays invisible until -`qp.serialization.registry.clear_vendor_discovery_cache()` is called; tests -that inject entry points need that call in teardown. If two distributions -declare the same vendor name, the first one discovered wins, which makes the -outcome deterministic rather than correct. - -The dependency on `qprogram` is a normal version constraint against the -published package. To develop against a local checkout of the core instead, -point `uv` at it for the duration: +`try_activate_vendor(vendor)` is what performs the activation. It returns `True` immediately when a protocol version is already registered, without scanning anything. Otherwise it looks the vendor up in the entry-point map and returns `False` when no installed distribution claims it, leaving the caller to decide whether that is an error. When an entry point is found it calls `ep.load()` and raises `VendorActivationError` in two cases: the import itself failing, reported as `vendor extension for 'fake_inst' is installed (entry point 'qprogram_fakeinst') but failed to import: ...`, and an import that succeeds without registering a version, reported as `... imported from entry point 'qprogram_fakeinst' but did not register a protocol version; the package must call register_vendor_version('fake_inst', '') on import`. The second is the failure mode of a package that ships the entry point but forgets step 5. + +The entry-point scan is memoized for the life of the process, so a distribution installed after the first lookup stays invisible until `qp.serialization.registry.clear_vendor_discovery_cache()` is called; tests that inject entry points need that call in teardown. If two distributions declare the same vendor name, the first one discovered wins, which makes the outcome deterministic rather than correct. + +The dependency on `qprogram` is a normal version constraint against the published package. To develop against a local checkout of the core instead, point `uv` at it for the duration: ```toml [tool.uv.sources] qprogram = { path = "/path/to/qprogram", editable = true } ``` -That table only redirects local resolution with `uv`; the dependency the -package publishes stays the `qprogram>=0.1.0` constraint above, so an -installing user always resolves the core from the index. +That table only redirects local resolution with `uv`; the dependency the package publishes stays the `qprogram>=0.1.0` constraint above, so an installing user always resolves the core from the index. ## Step 7: tests A vendor package's `tests/` folder typically mirrors this layout: -- `test_operations.py` covers each Operation class: construction, - introspection (`buses()`, `waveforms()`, `variables()`), structural - equality, and `required_capabilities()` (instance-aware). -- `test_namespace.py` covers each method on the namespace: it appends the - right op, validates buses, and uses the right naming scheme for - measurement ops. -- `test_mixin.py` covers the mixin: returns a `FakeInstNamespace`, caches - per instance, composes with multiple vendors. +- `test_operations.py` covers each Operation class: construction, introspection (`buses()`, `waveforms()`, `variables()`), structural equality, and `required_capabilities()` (instance-aware). +- `test_namespace.py` covers each method on the namespace: it appends the right op, validates buses, and uses the right naming scheme for measurement ops. +- `test_mixin.py` covers the mixin: returns a `FakeInstNamespace`, caches per instance, composes with multiple vendors. - `test_registration.py` confirms the registration calls succeed. -- `test_serialization.py` exercises every operation through `dumps` and - `loads`, including the `require` line. The default serializer walks - `__init__`'s parameters and reads each value off the instance under the - parameter's own name, skipping any parameter the instance has no attribute - for, so a parameter renamed without renaming the attribute drops out of the - written line and the reload fails with `cannot construct 'Beep' from the - given arguments`. -- `test_profile.py` confirms the profile is registered, that representative - programs validate clean, and that each predicate fires on the cases it - should and stays quiet on the cases it should not. - -Registration mutates process-global registries, so tests need an activate and -deactivate pair behind a fixture rather than an import side effect; -`tests/_dummy_vendor.py` in this repository shows the shape, popping each -registry entry it added. +- `test_serialization.py` exercises every operation through `dumps` and `loads`, including the `require` line. The default serializer walks `__init__`'s parameters and reads each value off the instance under the parameter's own name, skipping any parameter the instance has no attribute for, so a parameter renamed without renaming the attribute drops out of the written line and the reload fails with `cannot construct 'Beep' from the given arguments`. +- `test_profile.py` confirms the profile is registered, that representative programs validate clean, and that each predicate fires on the cases it should and stays quiet on the cases it should not. + +Registration mutates process-global registries, so tests need an activate and deactivate pair behind a fixture rather than an import side effect; `tests/_dummy_vendor.py` in this repository shows the shape, popping each registry entry it added. ## How serialization works for vendor ops -The writer looks up each operation instance in the registry, by exact class -rather than by inheritance, to find its `(vendor, name)` pair, and emits -`.` followed by the arguments the constructor signature dictates. -`Beep` registered under `("fake_inst", "beep")` writes as -`fake_inst.beep "drive_q0" 100`. A class that was never registered raises -`Cannot serialize operation class 'Beep': it is not registered with the .qp -serializer.` before anything is written. - -The `require` lines come from the same lookup. The writer collects the vendors -referenced anywhere in the program body and in every fragment body, so an -operation reachable only through a `Call` still gets its line, then emits one -`require ` per vendor with the patch component truncated, -since compatibility is defined at major.minor. A vendor used in the program -with no registered version raises `Cannot serialize: vendor 'fake_inst' is used -in the program but no version is registered.` +The writer looks up each operation instance in the registry, by exact class rather than by inheritance, to find its `(vendor, name)` pair, and emits `.` followed by the arguments the constructor signature dictates. `Beep` registered under `("fake_inst", "beep")` writes as `fake_inst.beep "drive_q0" 100`. A class that was never registered raises `Cannot serialize operation class 'Beep': it is not registered with the .qp serializer.` before anything is written. + +The `require` lines come from the same lookup. The writer collects the vendors referenced anywhere in the program body and in every fragment body, so an operation reachable only through a `Call` still gets its line, then emits one `require ` per vendor with the patch component truncated, since compatibility is defined at major.minor. A vendor used in the program with no registered version raises `Cannot serialize: vendor 'fake_inst' is used in the program but no version is registered.` A complete file for a two-operation program looks like this: @@ -597,19 +382,11 @@ body: fake_inst.set_threshold "readout_q0" 0.5 ``` -The parser reverses the lookup. It reads the `require` lines that immediately -follow the header, checks each against the installed extension, and then -resolves every `fake_inst.` in the body through `("fake_inst", op)`. A -`require` line further down the file is not treated as a declaration, because -the scan skips blank lines and stops at the first other line that is not one. -No writer or parser changes are needed for a new vendor operation; both sides -drive themselves from the registry. +The parser reverses the lookup. It reads the `require` lines that immediately follow the header, checks each against the installed extension, and then resolves every `fake_inst.` in the body through `("fake_inst", op)`. A `require` line further down the file is not treated as a declaration, because the scan skips blank lines and stops at the first other line that is not one. No writer or parser changes are needed for a new vendor operation; both sides drive themselves from the registry. ## Adding a control-flow block -An extension is not limited to operations. It can add a block, a container with -its own header keyword, by subclassing `Block` and registering it with -`register_vendor_block`, in a sixth module: +An extension is not limited to operations. It can add a block, a container with its own header keyword, by subclassing `Block` and registering it with `register_vendor_block`, in a sixth module: ```python # qprogram-fakeinst/src/qprogram_fakeinst/blocks.py @@ -638,48 +415,20 @@ body: wait "drive_q0" 100 ``` -The block registry keys on the qualified keyword, so a vendor block can reuse a -core keyword (`fake_inst.block` and core `block` coexist) and can never collide -with one. Four things to get right: - -1. **`REPEATS`.** Set it `True` if the block re-runs its body. That is what - makes it count toward `max_loop_nesting`: the validator reads the marker - rather than testing for the concrete core loop classes, so a vendor block - counts toward the limit without a change to the core. Leave it `False`, the - default, for a block that merely groups. -2. **Capability slot.** Blocks route to the platform slot, not a per-bus one, - so the token goes on your platform-slot profile. A block whose token is in - neither half of that slot draws an `empty-domain` diagnostic saying the - platform slot supports none of the domains for the block's required tokens. - A token present only in `host` makes the block host-only, so an op-child - that can run only in real time leaves it with no executable domain: - `own slot supports ['host'] but op-children consensus is ['rt']`. -3. **Opening it.** Add a namespace method returning a context manager that - appends the block and pushes it onto the program's block stack, mirroring - core's `block()` and `average()`: `program._append_to_active(blk)` on entry, - `program._block_stack.append(blk)`, and a matching `pop()` on exit. - `VendorNamespace._append` covers operations only; there is no block - equivalent. -4. **`register_vendor_block`, not `register_block`.** The vendor wrapper - records the vendor on the spec, which is what puts a `require fake_inst 0.1` - line in any file containing the block, even one with no vendor - *operations*. Without it the file would not auto-activate your package on - load. - -A vendor block is deliberately not a `Sweep`: it binds no variable, reports no -`num_iterations()`, cannot compose under `|`, and adds no result dimension. -Those are sweep properties, not repetition properties. +The block registry keys on the qualified keyword, so a vendor block can reuse a core keyword (`fake_inst.block` and core `block` coexist) and can never collide with one. Four things to get right: + +1. **`REPEATS`.** Set it `True` if the block re-runs its body. That is what makes it count toward `max_loop_nesting`: the validator reads the marker rather than testing for the concrete core loop classes, so a vendor block counts toward the limit without a change to the core. Leave it `False`, the default, for a block that merely groups. +2. **Capability slot.** Blocks route to the platform slot, not a per-bus one, so the token goes on your platform-slot profile. A block whose token is in neither half of that slot draws an `empty-domain` diagnostic saying the platform slot supports none of the domains for the block's required tokens. A token present only in `host` makes the block host-only, so an op-child that can run only in real time leaves it with no executable domain: `own slot supports ['host'] but op-children consensus is ['rt']`. +3. **Opening it.** Add a namespace method returning a context manager that appends the block and pushes it onto the program's block stack, mirroring core's `block()` and `average()`: `program._append_to_active(blk)` on entry, `program._block_stack.append(blk)`, and a matching `pop()` on exit. `VendorNamespace._append` covers operations only; there is no block equivalent. +4. **`register_vendor_block`, not `register_block`.** The vendor wrapper records the vendor on the spec, which is what puts a `require fake_inst 0.1` line in any file containing the block, even one with no vendor *operations*. Without it the file would not auto-activate your package on load. + +A vendor block is deliberately not a `Sweep`: it binds no variable, reports no `num_iterations()`, cannot compose under `|`, and adds no result dimension. Those are sweep properties, not repetition properties. ## Versioning: major and minor -The protocol version (`require fake_inst 0.1`) describes the operation set, not -the package release. Bump the minor when you add operations or add -backwards-compatible keyword arguments. Bump the major when you remove or -rename an operation, rename or reorder a constructor parameter, or change -semantics in a way that would break older files. +The protocol version (`require fake_inst 0.1`) describes the operation set, not the package release. Bump the minor when you add operations or add backwards-compatible keyword arguments. Bump the major when you remove or rename an operation, rename or reorder a constructor parameter, or change semantics in a way that would break older files. -The parser enforces one condition, on the `major.minor` the line spells out: -the installed extension must be able to provide what the file asks for. +The parser enforces one condition, on the `major.minor` the line spells out: the installed extension must be able to provide what the file asks for. ``` Line 3: file requires fake_inst 1.0, newer than the installed fake_inst 0.1.0 — install fake_inst 1.0 or newer @@ -688,13 +437,9 @@ Line 3: file version '0.9.1' must be exactly major.minor ### Keeping older files loading -An older line always loads. When the release that broke the wire form also -registers a migration for it, the body is rewritten on the way in and the older -file parses as if it had been written today. +An older line always loads. When the release that broke the wire form also registers a migration for it, the body is rewritten on the way in and the older file parses as if it had been written today. -Say 1.0 gives `beep` a second required argument, `volume`, which by the rule -above is a major bump. Every file written against 0.3 or earlier is one token -short of what the constructor now takes: +Say 1.0 gives `beep` a second required argument, `volume`, which by the rule above is a major bump. Every file written against 0.3 or earlier is one token short of what the constructor now takes: ``` #!QProgram 0.2 @@ -705,8 +450,7 @@ body: fake_inst.beep "drive_q0" 100 ``` -The rewrite goes in `__init__.py` beside the `register_vendor_operation` calls, -so that the import a `require` line triggers is what registers it: +The rewrite goes in `__init__.py` beside the `register_vendor_operation` calls, so that the import a `require` line triggers is what registers it: ```python # qprogram-fakeinst/src/qprogram_fakeinst/__init__.py @@ -719,24 +463,11 @@ def _beep_took_a_volume(lines: list[str]) -> list[str]: return [_BEEP.sub(r"\g<0> volume=0.5", line) for line in lines] ``` -The parser binds a keyword token by name, so the rewritten line arrives at -`Beep.__init__` carrying a value the file never held. Match the operation -keyword and append, rather than matching the argument list: a hand-written line -whose duration is an expression, `fake_inst.beep "drive_q0" (100 - t)`, has -spaces where the writer's own output has none. - -Key it to the version that shipped the change, one per breaking change; a -release that only adds operations needs none, since an older file never mentions -what it does not have. The version is your extension's rather than the format's: -the chain is bounded by the installed extension, so what runs is decided by the -`require fake_inst ` line against the version your package -registered. The rewrite is handed every line of the file, header and `require` -lines included, and has to hand back as many as it received, which is what keeps -a diagnostic's line number pointing at the file the user wrote. A `.wfl` -library declares no vendor, so it never sees a vendor rewrite at all. - -What proves it is a load rather than a direct call to the function, since the -registration and the version check are half of what is being tested: +The parser binds a keyword token by name, so the rewritten line arrives at `Beep.__init__` carrying a value the file never held. Match the operation keyword and append, rather than matching the argument list: a hand-written line whose duration is an expression, `fake_inst.beep "drive_q0" (100 - t)`, has spaces where the writer's own output has none. + +Key it to the version that shipped the change, one per breaking change; a release that only adds operations needs none, since an older file never mentions what it does not have. The version is your extension's rather than the format's: the chain is bounded by the installed extension, so what runs is decided by the `require fake_inst ` line against the version your package registered. The rewrite is handed every line of the file, header and `require` lines included, and has to hand back as many as it received, which is what keeps a diagnostic's line number pointing at the file the user wrote. A `.wfl` library declares no vendor, so it never sees a vendor rewrite at all. + +What proves it is a load rather than a direct call to the function, since the registration and the version check are half of what is being tested: ```python def test_a_0_3_file_gets_the_volume_1_0_made_required(): @@ -744,15 +475,9 @@ def test_a_0_3_file_gets_the_volume_1_0_made_required(): assert qp.loads(text).body.elements[0].volume == 0.5 ``` -Without a migration nothing is lost either: an older file still loads, on the -assumption that nothing between the two versions broke. The migration is what -turns that assumption into a guarantee. +Without a migration nothing is lost either: an older file still loads, on the assumption that nothing between the two versions broke. The migration is what turns that assumption into a guarantee. -When the vendor is not registered at all, the message depends on whether -auto-activation is on. The default suggests installing the package that -declares the entry point; under `qp.loads(..., auto_activate=False)` it says -auto-activation is disabled and names the import to add. Both are `ParseError` -carrying the line number of the `require` line. +When the vendor is not registered at all, the message depends on whether auto-activation is on. The default suggests installing the package that declares the entry point; under `qp.loads(..., auto_activate=False)` it says auto-activation is disabled and names the import to add. Both are `ParseError` carrying the line number of the `require` line. ## Combining with other vendors @@ -768,15 +493,11 @@ class QProgram(OtherVendorMixin, FakeInstMixin, qp.QProgram): pass ``` -A platform library that already depends on several vendor extensions usually -provides this combined class so end users do not write the inheritance -themselves. The dynamic `program.fake_inst.*` resolution works either way; the -mixin exists for IDE autocomplete. +A platform library that already depends on several vendor extensions usually provides this combined class so end users do not write the inheritance themselves. The dynamic `program.fake_inst.*` resolution works either way; the mixin exists for IDE autocomplete. ## Failure modes -The registries are global and populated at import time, which puts most of the -mistakes in a vendor package on the path between installed and usable. +The registries are global and populated at import time, which puts most of the mistakes in a vendor package on the path between installed and usable. | Mistake | What you see | |---|---| @@ -791,19 +512,10 @@ mistakes in a vendor package on the path between installed and usable. | `REPEATS` left `False` on a repeating block | The block does not count toward `max_loop_nesting`, so a program that exceeds the hardware's loop depth validates clean | | `register_block` used instead of `register_vendor_block` | No `require` line for a file whose only vendor content is the block, so it does not auto-activate the package | -Two more, which the registries cannot catch for you. Do not re-export -`qp.QProgram` unchanged as your package's `QProgram`: the pre-combined class -should subclass the mixin, or users lose the static typing that is the mixin's -only purpose. And keep bus attributes plain: `QProgram.rebind` re-resolves an -operation's buses by rewriting the attributes named in `BUS_ATTRS`, and it can -only do that when they hold `str` or `BusRef` values, or a list of those, -directly. +Two more, which the registries cannot catch for you. Do not re-export `qp.QProgram` unchanged as your package's `QProgram`: the pre-combined class should subclass the mixin, or users lose the static typing that is the mixin's only purpose. And keep bus attributes plain: `QProgram.rebind` re-resolves an operation's buses by rewriting the attributes named in `BUS_ATTRS`, and it can only do that when they hold `str` or `BusRef` values, or a list of those, directly. -Name the distribution to install in your README. The parser's error names the -vendor namespace, `fake_inst`, which is not necessarily the package name a -reader has to type into `pip install`. +Name the distribution to install in your README. The parser's error names the vendor namespace, `fake_inst`, which is not necessarily the package name a reader has to type into `pip install`. ## Related pages -[The `.qp` format](../reference/qp-format.md) is the grammar the writer and -parser implement on both sides of the registry lookup above. +[The `.qp` format](../reference/qp-format.md) is the grammar the writer and parser implement on both sides of the registry lookup above. diff --git a/docs/examples/active-reset.md b/docs/examples/active-reset.md index d55600c..16ea500 100644 --- a/docs/examples/active-reset.md +++ b/docs/examples/active-reset.md @@ -1,18 +1,8 @@ # Active reset -A shot cannot start until the qubit is back in its ground state, and waiting -for it to get there on its own costs several relaxation times per shot. Active -reset measures the qubit instead and plays a pi pulse only if the measurement -says it is excited, which puts the repetition rate under the readout time -rather than under T1. The decision is made inside the shot, between two -operations, from a value that does not exist until the program is running. - -That is the feature this page is about. Every program on the earlier pages is a -fixed sequence whose shape is known when the file is written; this one branches -on a measurement, so the `.qp` text describes two possible sequences and the -instrument picks one per shot. The reset is put in front of a small -[Rabi](rabi.md) sweep rather than left on its own, because a conditional is -something you add to an experiment rather than something you run. +A shot cannot start until the qubit is back in its ground state, and waiting for it to get there on its own costs several relaxation times per shot. Active reset measures the qubit instead and plays a pi pulse only if the measurement says it is excited, which puts the repetition rate under the readout time rather than under T1. The decision is made inside the shot, between two operations, from a value that does not exist until the program is running. + +That is the feature this page is about. Every program on the earlier pages is a fixed sequence whose shape is known when the file is written; this one branches on a measurement, so the `.qp` text describes two possible sequences and the instrument picks one per shot. The reset is put in front of a small [Rabi](rabi.md) sweep rather than left on its own, because a conditional is something you add to an experiment rather than something you run. ## The program @@ -57,33 +47,15 @@ with program.average(shots=1000): ### Why each piece is where it is -`check` is the herald. It asks only for `MeasurementField.STATE`, because the -program needs the classified outcome and nothing else from it, and requesting -`IQ` as well would record an array per sweep point that no one reads. The -handle it returns is what the conditional refers to. - -`check.state` is a proxy whose `==` and `!=` operators build the comparison the -conditional needs. It is not a variable and it holds no value at build time; -it names a measurement and a field, and the instrument resolves it when the -shot reaches the branch. The comparison is against `1` because the classifier -reports an integer state, so the arm runs on the shots that came back excited. - -Each arm is a separate `with` statement, and the chain is held open by -adjacency: `elif_()` and `else_()` must follow the arm they extend with nothing -appended in between. Appending any other operation between two arms closes the -chain, and the `elif_()` that follows then raises rather than silently starting -a new one. - -The `else_` arm waits 40 ns, the length of the pi pulse, so both paths through -the branch take the same time. Nothing in the language requires that and the -reference executor does not measure it, but a branch whose arms have different -durations leaves the buses at different times on hardware, which the `sync()` -after the chain is what resolves. - -The rest is the Rabi program: set the gain from the swept variable, play the -pulse, sync, and measure. That second measurement asks for both fields, since -the IQ point is the Rabi curve and the state is what a fidelity is computed -from. +`check` is the herald. It asks only for `MeasurementField.STATE`, because the program needs the classified outcome and nothing else from it, and requesting `IQ` as well would record an array per sweep point that no one reads. The handle it returns is what the conditional refers to. + +`check.state` is a proxy whose `==` and `!=` operators build the comparison the conditional needs. It is not a variable and it holds no value at build time; it names a measurement and a field, and the instrument resolves it when the shot reaches the branch. The comparison is against `1` because the classifier reports an integer state, so the arm runs on the shots that came back excited. + +Each arm is a separate `with` statement, and the chain is held open by adjacency: `elif_()` and `else_()` must follow the arm they extend with nothing appended in between. Appending any other operation between two arms closes the chain, and the `elif_()` that follows then raises rather than silently starting a new one. + +The `else_` arm waits 40 ns, the length of the pi pulse, so both paths through the branch take the same time. Nothing in the language requires that and the reference executor does not measure it, but a branch whose arms have different durations leaves the buses at different times on hardware, which the `sync()` after the chain is what resolves. + +The rest is the Rabi program: set the gain from the swept variable, play the pulse, sync, and measure. That second measurement asks for both fields, since the IQ point is the Rabi curve and the state is what a fidelity is computed from. ## What it produces @@ -116,23 +88,13 @@ body: measure q[0].readout "readout" "weights" name="q0/readout/m1" fields=["state", "iq"] ``` -The condition is written as the measurement's name followed by the field, which -is why every measurement carries `name=` in the file even when the name was -allocated for it: the conditional is a reference by name, and a reload that -reallocated names would point the branch somewhere else. Both measurements are -on `q0/readout` and the per-bus counter distinguishes them, giving `m0` and -`m1`. A name you supply yourself does not consume a counter slot, so passing -`name="herald"` to the first one leaves the other two as `m0` and `m1`. +The condition is written as the measurement's name followed by the field, which is why every measurement carries `name=` in the file even when the name was allocated for it: the conditional is a reference by name, and a reload that reallocated names would point the branch somewhere else. Both measurements are on `q0/readout` and the per-bus counter distinguishes them, giving `m0` and `m1`. A name you supply yourself does not consume a counter slot, so passing `name="herald"` to the first one leaves the other two as `m0` and `m1`. ## What the condition can be -The accepted shape is one comparison between a measurement state and an integer -literal. That is narrower than the expression language everywhere else in a -program, and deliberately so: this is the one condition a sequencer has to -evaluate in real time, between two pulses, without a host round trip. +The accepted shape is one comparison between a measurement state and an integer literal. That is narrower than the expression language everywhere else in a program, and deliberately so: this is the one condition a sequencer has to evaluate in real time, between two pulses, without a host round trip. -Everything else is rejected where it is written. Comparing against a -non-integer raises at the operator, before `if_` is reached: +Everything else is rejected where it is written. Comparing against a non-integer raises at the operator, before `if_` is reached: ```python check.state == 1.0 # TypeError: handle.state can only be compared to int, ... @@ -140,8 +102,7 @@ check.state == True # TypeError: handle.state cannot be compared to a bool; use check.state > 0 # TypeError: '>' not supported between instances of ... ``` -A condition of the wrong shape gets past the operator and is caught by `if_` -itself, as a `ValidationError`: +A condition of the wrong shape gets past the operator and is caught by `if_` itself, as a `ValidationError`: ```python program.if_(qp.and_(check.state == 1, other.state == 0)) @@ -153,9 +114,7 @@ program.if_(qp.eq(amp, 1)) # (e.g. `handle.state`); got a comparison of Variable and Constant ``` -The one failure that survives to validation is a herald that never asked to be -classified. Drop `fields=` from the first measurement and the program still -builds, because `measure` does not know a later conditional will refer to it: +The one failure that survives to validation is a herald that never asked to be classified. Drop `fields=` from the first measurement and the program still builds, because `measure` does not know a later conditional will refer to it: ```python diagnostics, plan = qp.validate(program, qp.reference_capabilities()) @@ -164,14 +123,7 @@ diagnostics, plan = qp.validate(program, qp.reference_capabilities()) # fields=) (at body[0][0][1]) ``` -This is enforced rather than advisory. `qp.simulate` validates before it -executes, so the same program raises -`UnsupportedOperationError: program is not executable on the reference -platform` with that diagnostic attached, which is what a hardware platform -does with it too. The path `body[0][0][1]` locates the conditional as the -second child of the sweep, itself the first child of the average block; see -[Capabilities, diagnostics, and profiles](../guide/capabilities.md) for -resolving a path back to a line of the file. +This is enforced rather than advisory. `qp.simulate` validates before it executes, so the same program raises `UnsupportedOperationError: program is not executable on the reference platform` with that diagnostic attached, which is what a hardware platform does with it too. The path `body[0][0][1]` locates the conditional as the second child of the sweep, itself the first child of the average block; see [Capabilities, diagnostics, and profiles](../guide/capabilities.md) for resolving a path back to a line of the file. `qp.explain` renders the chain as a node of its own with the arms beneath it: @@ -193,15 +145,11 @@ body └─ measure q[0].readout "readout" "weights" name="q0/readout/m1" fields=["state", "iq"] [rt|host] ``` -Every row here is `[rt|host]`, meaning the reference platform can run it in -either domain. A platform whose readout chain cannot classify a state inside a -sequence is the one that reports otherwise, and the chain is where it says so. +Every row here is `[rt|host]`, meaning the reference platform can run it in either domain. A platform whose readout chain cannot classify a state inside a sequence is the one that reports otherwise, and the chain is where it says so. ## Running it -The herald needs a population to classify, which is what `p_excited` supplies. -A tenth of the shots arriving excited is a reasonable stand-in for a qubit that -has not fully relaxed since the previous shot: +The herald needs a population to classify, which is what `p_excited` supplies. A tenth of the shots arriving excited is a reasonable stand-in for a qubit that has not fully relaxed since the previous shot: ```python library = { @@ -226,32 +174,20 @@ heralds.dims # ("amp",) heralds.mean() # about 0.1, the fraction of shots that needed the reset pulse ``` -The herald is a result record like any other, so how often the reset fired is -data you already have rather than something to instrument for. Reading it is -worth doing: a herald rate that climbs over a run is the readout heating the -qubit or the previous shot's pulse leaking, and neither shows up in the Rabi -curve until it has already distorted it. +The herald is a result record like any other, so how often the reset fired is data you already have rather than something to instrument for. Reading it is worth doing: a herald rate that climbs over a run is the readout heating the qubit or the previous shot's pulse leaking, and neither shows up in the Rabi curve until it has already distorted it. -Both records plotted against the same sweep, the Rabi curve above and the -herald rate below, is the shape worth watching: the reset should hold flat -while the experiment underneath it moves. +Both records plotted against the same sweep, the Rabi curve above and the herald rate below, is the shape worth watching: the reset should hold flat while the experiment underneath it moves. -![Two stacked panels sharing a drive-amplitude axis: a Rabi curve peaking at 0.5 V and falling back to 0, and a herald rate scattered around 0.1.](../assets/plots/active-reset-light.png#only-light) -![Two stacked panels sharing a drive-amplitude axis: a Rabi curve peaking at 0.5 V and falling back to 0, and a herald rate scattered around 0.1.](../assets/plots/active-reset-dark.png#only-dark) +![Two stacked panels sharing a drive-amplitude axis: a Rabi curve peaking at 0.5 V and falling back to 0, and a herald rate scattered around 0.1.](../assets/plots/active-reset-light.png#only-light) ![Two stacked panels sharing a drive-amplitude axis: a Rabi curve peaking at 0.5 V and falling back to 0, and a herald rate scattered around 0.1.](../assets/plots/active-reset-dark.png#only-dark) -Asking `check` for a field it never requested raises rather than substituting -one: +Asking `check` for a field it never requested raises rather than substituting one: ```python result.get(check) # KeyError: "Measurement 'q0/readout/m0' has no field 'iq'; available: state" ``` -The branch is real in the reference executor even though the pulses are not. -The interpreter classifies each herald shot from `p_excited`, evaluates the -comparison, and walks one arm, so a measurement placed inside an arm is -recorded only on the shots that took it. A grid point where an arm was never -selected holds `NaN`, since the executor divides by a shot count of zero there: +The branch is real in the reference executor even though the pulses are not. The interpreter classifies each herald shot from `p_excited`, evaluates the comparison, and walks one arm, so a measurement placed inside an arm is recorded only on the shots that took it. A grid point where an arm was never selected holds `NaN`, since the executor divides by a shot count of zero there: ```python with program.if_(check.state == 1): @@ -260,21 +196,13 @@ with program.if_(check.state == 1): # result.get(reset_check) is all NaN when p_excited is 0.0 everywhere ``` -What the executor does not do is time anything. The pi pulse in the taken arm -and the `wait` in the other are both no-ops, so the run says nothing about -whether the reset would fit inside a real repetition period. See -[Running programs](../guide/execution.md). +What the executor does not do is time anything. The pi pulse in the taken arm and the `wait` in the other are both no-ops, so the run says nothing about whether the reset would fit inside a real repetition period. See [Running programs](../guide/execution.md). ## Adapting it -To verify the reset rather than assume it, measure again inside the arm that -fired and read that handle back. The fraction of second heralds still reporting -1 is the reset infidelity, and it is the number that decides how many rounds -are needed. +To verify the reset rather than assume it, measure again inside the arm that fired and read that handle back. The fraction of second heralds still reporting 1 is the reset infidelity, and it is the number that decides how many rounds are needed. -To make it more than one round, repeat the measure-and-branch pair. A Python -`for` loop around it writes the rounds into the program at build time, each -with its own handle, and the counter names them `m0` through `m3`: +To make it more than one round, repeat the measure-and-branch pair. A Python `for` loop around it writes the rounds into the program at build time, each with its own handle, and the counter names them `m0` through `m3`: ```python for _ in range(4): @@ -285,8 +213,7 @@ for _ in range(4): program.wait(q[0].drive, 40) ``` -Naming the repeated pair once with a [fragment](../guide/fragments.md) is the -alternative, and it keeps the four rounds from being four copies in the file. +Naming the repeated pair once with a [fragment](../guide/fragments.md) is the alternative, and it keeps the four rounds from being four copies in the file. To branch on a three-level classifier, extend the chain with `elif_`: @@ -300,14 +227,6 @@ with program.else_(): program.wait(q[0].drive, 40) ``` -Only the first matching arm runs, and an `else_` is optional; without one, a -shot matching no arm does nothing. At most one `else_` per chain, and it has to -be last. - -To condition on a measurement of a different qubit, pass that qubit's handle. -The comparison names a measurement, not a bus, so nothing requires the branch -to act on the qubit that was measured, which is what a parity check or a -teleported correction needs. Whether a platform can route a classification from -one readout chain to another sequencer in time is a capability question, and -[Capabilities, diagnostics, and profiles](../guide/capabilities.md) covers how -it is declared. +Only the first matching arm runs, and an `else_` is optional; without one, a shot matching no arm does nothing. At most one `else_` per chain, and it has to be last. + +To condition on a measurement of a different qubit, pass that qubit's handle. The comparison names a measurement, not a bus, so nothing requires the branch to act on the qubit that was measured, which is what a parity check or a teleported correction needs. Whether a platform can route a classification from one readout chain to another sequencer in time is a capability question, and [Capabilities, diagnostics, and profiles](../guide/capabilities.md) covers how it is declared. diff --git a/docs/examples/checking-a-program.md b/docs/examples/checking-a-program.md index 5e9f4b2..707597b 100644 --- a/docs/examples/checking-a-program.md +++ b/docs/examples/checking-a-program.md @@ -1,28 +1,14 @@ # Checking a program before it runs -Every other page here ends by running something. This one does not run -anything, because the question it answers comes earlier: given a program and a -particular instrument, what will that instrument refuse, and where in the file -is the offending line? Answering it costs a fraction of a millisecond and no -hardware, which is the point of asking before a fridge is booked. - -The program is the Ramsey sequence from [T1 and Ramsey](t1-and-ramsey.md), -unchanged. What changes is the platform it is pointed at: an instrument that -can play, wait, sync and measure, but cannot set an oscillator phase, cannot -classify a state, and can only step a loop register by a constant. Three -different things are wrong with the program on that box, and a fourth is wrong -with the program as a whole. All four come back from one call. - -The pieces used here are covered individually in -[Capabilities, diagnostics, and profiles](../guide/capabilities.md), which is -the reference for what each field means. This page puts them on one program. +Every other page here ends by running something. This one does not run anything, because the question it answers comes earlier: given a program and a particular instrument, what will that instrument refuse, and where in the file is the offending line? Answering it costs a fraction of a millisecond and no hardware, which is the point of asking before a fridge is booked. + +The program is the Ramsey sequence from [T1 and Ramsey](t1-and-ramsey.md), unchanged. What changes is the platform it is pointed at: an instrument that can play, wait, sync and measure, but cannot set an oscillator phase, cannot classify a state, and can only step a loop register by a constant. Three different things are wrong with the program on that box, and a fourth is wrong with the program as a whole. All four come back from one call. + +The pieces used here are covered individually in [Capabilities, diagnostics, and profiles](../guide/capabilities.md), which is the reference for what each field means. This page puts them on one program. ## Describing the instrument -A `qp.PlatformCapabilities` is a lookup from slot to what that slot can do, -where a slot is a bus and a domain. Each entry is a `qp.CompilerCapabilities`: -a set of capability tokens, some numeric limits, and any predicates that -inspect nodes the tokens alone cannot judge. +A `qp.PlatformCapabilities` is a lookup from slot to what that slot can do, where a slot is a bus and a domain. Each entry is a `qp.CompilerCapabilities`: a set of capability tokens, some numeric limits, and any predicates that inspect nodes the tokens alone cannot judge. ```python import numpy as np @@ -67,24 +53,11 @@ drive = qp.CompilerCapabilities( ) ``` -The absences are the interesting part. `op.set_phase` is not in the set, and -neither is `measure.fields.state`, so the two things this instrument cannot do -are expressed by not saying it can. A capability token has to be registered -before it can be named; an unregistered one raises `ValueError: ... Register -via qprogram.protocol.register_capability_tokens before use.` rather than being -treated as a capability nobody has. - -A predicate is a plain generator taking the node and a `ValidationContext`, and -yielding a `qp.Diagnostic` for each thing it objects to. Yielding nothing means -it has no objection. It exists because "can this instrument step a wait" is not -a property of the `Wait` node alone: it depends on the sweep that binds the -duration, which is what `ctx.sweep_kind_of` reaches. Codes from a predicate are -conventionally prefixed with the vendor's name so they cannot collide with the -validator's own. - -Whole-program limits live on the platform slot rather than on a bus, so they go -in a separate descriptor. A `qp.Profile` is the reusable bundle, and -`from_profile` turns a registered one into capabilities: +The absences are the interesting part. `op.set_phase` is not in the set, and neither is `measure.fields.state`, so the two things this instrument cannot do are expressed by not saying it can. A capability token has to be registered before it can be named; an unregistered one raises `ValueError: ... Register via qprogram.protocol.register_capability_tokens before use.` rather than being treated as a capability nobody has. + +A predicate is a plain generator taking the node and a `ValidationContext`, and yielding a `qp.Diagnostic` for each thing it objects to. Yielding nothing means it has no objection. It exists because "can this instrument step a wait" is not a property of the `Wait` node alone: it depends on the sweep that binds the duration, which is what `ctx.sweep_kind_of` reaches. Codes from a predicate are conventionally prefixed with the vendor's name so they cannot collide with the validator's own. + +Whole-program limits live on the platform slot rather than on a bus, so they go in a separate descriptor. A `qp.Profile` is the reusable bundle, and `from_profile` turns a registered one into capabilities: ```python qp.register_profile( @@ -119,14 +92,9 @@ caps = qp.PlatformCapabilities( ) ``` -`register_profile` is global and keyed by name, so run it once at module scope. -The registry compares by object identity rather than by value, so re-running the -same cell in a notebook builds a second `Profile` with identical fields and -raises `ValueError: Profile 'oneloop-v1' is already registered with different -content`. Passing the very same object twice is accepted. +`register_profile` is global and keyed by name, so run it once at module scope. The registry compares by object identity rather than by value, so re-running the same cell in a notebook builds a second `Profile` with identical fields and raises `ValueError: Profile 'oneloop-v1' is already registered with different content`. Passing the very same object twice is accepted. -`max_loop_nesting=1` says the sequencer has one loop register. The Ramsey -program has an `average` and a `sweep`, which is two. +`max_loop_nesting=1` says the sequencer has one loop register. The Ramsey program has an `average` and a `sweep`, which is two. ## The program, unchanged @@ -157,10 +125,7 @@ with program.average(shots=1000): ) ``` -The delays are given as `qp.Values` rather than the `qp.Linspace` the Ramsey -page uses, because a hand-picked list is `KIND` `"arbitrary"` and the predicate -above only objects to that kind. On a `Linspace` the same program loses one of -its four problems. +The delays are given as `qp.Values` rather than the `qp.Linspace` the Ramsey page uses, because a hand-picked list is `KIND` `"arbitrary"` and the predicate above only objects to that kind. On a `Linspace` the same program loses one of its four problems. ## What comes back @@ -177,24 +142,13 @@ for d in diagnostics: [error] limit-exceeded: Program nests loops 2 deep; limit max_loop_nesting=1 ``` -Three codes from three different mechanisms. The first came from the predicate, -which had to look past the node at the sweep binding it. The next two came from -token lookup, and each names the token it wanted and both slots it looked in. -The last came from a whole-program limit check, which is why it has no path: it -is a statement about the program's shape rather than about any one node. +Three codes from three different mechanisms. The first came from the predicate, which had to look past the node at the sweep binding it. The next two came from token lookup, and each names the token it wanted and both slots it looked in. The last came from a whole-program limit check, which is why it has no path: it is a statement about the program's shape rather than about any one node. -Nothing here is a warning. Every one of these stops execution, and a platform's -`execute` raises `UnsupportedOperationError` rather than running a program that -would produce the wrong data. The `forced-host` case on the -[resonator spectroscopy](resonator-spectroscopy.md) page is the other kind: the -program runs, differently from how it was written. +Nothing here is a warning. Every one of these stops execution, and a platform's `execute` raises `UnsupportedOperationError` rather than running a program that would produce the wrong data. The `forced-host` case on the [resonator spectroscopy](resonator-spectroscopy.md) page is the other kind: the program runs, differently from how it was written. ## From a diagnostic to a line of the file -`Diagnostic.path` is a structural address into the program body, and -`qp.format_path` renders it the way the messages above print it. Turning one -into a line number needs the file, which means the program has to have come -from one: +`Diagnostic.path` is a structural address into the program body, and `qp.format_path` renders it the way the messages above print it. Turning one into a line number needs the file, which means the program has to have come from one: ```python text = qp.dumps(program) @@ -213,20 +167,13 @@ body[0][0][3] -> line 20: set_phase q[0].drive (0.012566370614359173 * delay) body[0][0][6] -> line 23: measure q[0].readout "readout" "weights" name="q0/readout/m0" fields=["state", "iq"] ``` -The reload is not incidental. `program.source_map` on the program built above -is `{}`, because a program assembled in Python has no source to map to; the -parser is what records which line each node came from, so the map is populated -only on a program that came through `qp.loads`. `expand()` returns a copy with -an empty map for the same reason, since inlining a fragment call produces nodes -no line of the file ever held. +The reload is not incidental. `program.source_map` on the program built above is `{}`, because a program assembled in Python has no source to map to; the parser is what records which line each node came from, so the map is populated only on a program that came through `qp.loads`. `expand()` returns a copy with an empty map for the same reason, since inlining a fragment call produces nodes no line of the file ever held. -The path itself resolves in both directions without a file. `qp.resolve_path` -takes a path to its node, and `qp.node_path` takes a node back to its path. +The path itself resolves in both directions without a file. `qp.resolve_path` takes a path to its node, and `qp.node_path` takes a node back to its path. ## Reading the plan -`qp.explain` renders the same information as a tree, with what each node's -domain came out as in the right-hand column: +`qp.explain` renders the same information as a tree, with what each node's domain came out as in the right-hand column: ``` plan for 'ramsey_on_oneloop' — errors: 4 · warnings: 0 · info: 0 @@ -245,42 +192,18 @@ body ``` -`[--]` is a node with no domain left: not real-time, not host-side, nowhere. -Three operations are marked that way, each with its own diagnostic, and the -whole-program error is printed under the tree because it belongs to no row. +`[--]` is a node with no domain left: not real-time, not host-side, nowhere. Three operations are marked that way, each with its own diagnostic, and the whole-program error is printed under the tree because it belongs to no row. -Two things about that tree are worth reading carefully. The sweep is `[--]` and -carries no annotation of its own, because its emptiness is a consequence rather -than a finding: an operation that can run nowhere empties its parent's domain -too, and the child's diagnostic already says why. Scanning for a reason on the -loop's own line will not find one. +Two things about that tree are worth reading carefully. The sweep is `[--]` and carries no annotation of its own, because its emptiness is a consequence rather than a finding: an operation that can run nowhere empties its parent's domain too, and the child's diagnostic already says why. Scanning for a reason on the loop's own line will not find one. -The `average` above it, meanwhile, still reads `[rt|host]` even though its only -child can run nowhere. A block's children are treated as units and do not -constrain their parent's domain, so the average is reporting what it could do -rather than what this body lets it do. It is not a contradiction, but it does -mean the tree is read from the leaves up. +The `average` above it, meanwhile, still reads `[rt|host]` even though its only child can run nowhere. A block's children are treated as units and do not constrain their parent's domain, so the average is reporting what it could do rather than what this body lets it do. It is not a contradiction, but it does mean the tree is read from the leaves up. ## Adapting it -Making the program run on this instrument is four edits, one per diagnostic: -give the delays as a `qp.Linspace` so the sweep is `"linear"`, drop -`MeasurementField.STATE` and read the fringe as an IQ trajectory, drop the -`set_phase` and accept a Ramsey at the real detuning rather than an artificial -one, and lift the sweep out of the `average` so only one loop is nested. Each -of those is a real experimental compromise, which is the useful thing about -seeing them together: the diagnostics are a list of what the instrument costs -you. - -To describe a bus kind that differs from the rest, add an entry to the `bus` -mapping keyed by the `(element, kind)` pair. Anything with no entry falls back -to `default_bus_profile`, which is why the readout bus above is checked against -the same descriptor as the drive. - -To check a program that is already on disk without building it in Python, -`qp.loads` it and validate that. It arrives with its `source_map` populated, so -every diagnostic can be reported against a line without the round trip this -page had to do. - -`qprogram.lsp` runs this same machinery over `.qp` text and reports the -findings as editor diagnostics, which is the same check moved earlier still. +Making the program run on this instrument is four edits, one per diagnostic: give the delays as a `qp.Linspace` so the sweep is `"linear"`, drop `MeasurementField.STATE` and read the fringe as an IQ trajectory, drop the `set_phase` and accept a Ramsey at the real detuning rather than an artificial one, and lift the sweep out of the `average` so only one loop is nested. Each of those is a real experimental compromise, which is the useful thing about seeing them together: the diagnostics are a list of what the instrument costs you. + +To describe a bus kind that differs from the rest, add an entry to the `bus` mapping keyed by the `(element, kind)` pair. Anything with no entry falls back to `default_bus_profile`, which is why the readout bus above is checked against the same descriptor as the drive. + +To check a program that is already on disk without building it in Python, `qp.loads` it and validate that. It arrives with its `source_map` populated, so every diagnostic can be reported against a line without the round trip this page had to do. + +`qprogram.lsp` runs this same machinery over `.qp` text and reports the findings as editor diagnostics, which is the same check moved earlier still. diff --git a/docs/examples/cpmg-fragments.md b/docs/examples/cpmg-fragments.md index 1f78df2..2360710 100644 --- a/docs/examples/cpmg-fragments.md +++ b/docs/examples/cpmg-fragments.md @@ -1,18 +1,8 @@ # CPMG on two qubits -A CPMG sequence puts a train of refocusing pi pulses between two pi/2 pulses. -Each pi pulse reverses the phase the qubit has accumulated since the last one, -so noise slower than the pulse spacing cancels and noise faster than it does -not, which makes the measured coherence time a function of the spacing and the -train a filter you can tune. Sweeping the spacing and reading the surviving -coherence is how the noise spectrum of a qubit is measured. - -Written out, the sequence is the same three lines repeated as many times as the -train is long, once per qubit. This page writes them once. A `Fragment` is a -named, parameterized sub-program, and `program.call` appends a reference to it -rather than a copy, so the file holds one definition and one line per call site. -[Fragments](../guide/fragments.md) covers the mechanism in full; what follows is -an experiment that needs one. +A CPMG sequence puts a train of refocusing pi pulses between two pi/2 pulses. Each pi pulse reverses the phase the qubit has accumulated since the last one, so noise slower than the pulse spacing cancels and noise faster than it does not, which makes the measured coherence time a function of the spacing and the train a filter you can tune. Sweeping the spacing and reading the surviving coherence is how the noise spectrum of a qubit is measured. + +Written out, the sequence is the same three lines repeated as many times as the train is long, once per qubit. This page writes them once. A `Fragment` is a named, parameterized sub-program, and `program.call` appends a reference to it rather than a copy, so the file holds one definition and one line per call site. [Fragments](../guide/fragments.md) covers the mechanism in full; what follows is an experiment that needs one. ## The program @@ -52,28 +42,13 @@ with program.average(shots=1000): ### Why each piece is where it is -The decorated name is the fragment. After `@qp.fragment` runs, `cpmg` is a -`qp.Fragment` instance rather than a function, and calling it directly is not -how it is used; `program.call(cpmg, ...)` is. The first parameter receives the -fragment's own builder, which is why the body writes `f.play` rather than -`program.play`, and every parameter after it becomes a `qp.Parameter` bound at -the call site. The fragment's name comes from `__name__`. - -The three bindings are of three different kinds. `drive` and `readout` are -bound to buses, `tau` to an expression. Parameters are untyped and it is the -binding that decides, checked when the call is expanded, so a bus passed where -an expression is expected is caught then rather than at the call site. - -`tau / 2` works because `qp.Parameter` subclasses `qp.Variable`, so a parameter -participates in expressions exactly as a swept variable does. Half the spacing -falls on each side of every pi pulse, which is what makes the refocusing -symmetric. - -`sync([drive, readout])` names its buses instead of taking the argument-free -form, which would align every bus the program has touched and couple the two -qubits to each other. Naming them keeps each call closing only its own train -before its own readout, so the two trains are independent and an instrument is -free to run them at the same time. +The decorated name is the fragment. After `@qp.fragment` runs, `cpmg` is a `qp.Fragment` instance rather than a function, and calling it directly is not how it is used; `program.call(cpmg, ...)` is. The first parameter receives the fragment's own builder, which is why the body writes `f.play` rather than `program.play`, and every parameter after it becomes a `qp.Parameter` bound at the call site. The fragment's name comes from `__name__`. + +The three bindings are of three different kinds. `drive` and `readout` are bound to buses, `tau` to an expression. Parameters are untyped and it is the binding that decides, checked when the call is expanded, so a bus passed where an expression is expected is caught then rather than at the call site. + +`tau / 2` works because `qp.Parameter` subclasses `qp.Variable`, so a parameter participates in expressions exactly as a swept variable does. Half the spacing falls on each side of every pi pulse, which is what makes the refocusing symmetric. + +`sync([drive, readout])` names its buses instead of taking the argument-free form, which would align every bus the program has touched and couple the two qubits to each other. Naming them keeps each call closing only its own train before its own readout, so the two trains are independent and an instrument is free to run them at the same time. ## What it produces @@ -116,36 +91,21 @@ body: cpmg(q[1].drive, q[1].readout, tau) ``` -The Python `for _ in range(4)` is nowhere in that file. A fragment body runs -once, at decoration time, to record its AST, so the loop executed then and -wrote four copies of its three statements into the fragment. Python control -flow inside a fragment body is a code generator, not a runtime construct, and -the twelve lines above are what it generated. +The Python `for _ in range(4)` is nowhere in that file. A fragment body runs once, at decoration time, to record its AST, so the loop executed then and wrote four copies of its three statements into the fragment. Python control flow inside a fragment body is a code generator, not a runtime construct, and the twelve lines above are what it generated. -That is also the limit of it. Because the count is resolved at decoration, -`n` cannot be a parameter: a train of eight needs a second fragment or a -`range(n)` closed over at definition time, and either way each length is a -distinct definition in the file. The parameters are the things that vary per -call site, and the structure is not one of them. +That is also the limit of it. Because the count is resolved at decoration, `n` cannot be a parameter: a train of eight needs a second fragment or a `range(n)` closed over at definition time, and either way each length is a distinct definition in the file. The parameters are the things that vary per call site, and the structure is not one of them. -Bus arguments write as bare identifiers inside the definition (`play drive`, -not a bus path) because they are parameters there, and the call statements -carry the real buses. The measurement's auto-allocated name is `m0` with no bus -prefix for the same reason: at definition time there is no bus to derive a -prefix from. +Bus arguments write as bare identifiers inside the definition (`play drive`, not a bus path) because they are parameters there, and the call statements carry the real buses. The measurement's auto-allocated name is `m0` with no bus prefix for the same reason: at definition time there is no bus to derive a prefix from. ## Getting the handles back -`program.call` returns `None`. The measurement is inside the fragment, so -nothing at the call site hands back a `qp.MeasurementHandle`, and the program -does not yet know how many measurements it has: +`program.call` returns `None`. The measurement is inside the fragment, so nothing at the call site hands back a `qp.MeasurementHandle`, and the program does not yet know how many measurements it has: ```python program.measurement_handles() # [] ``` -`expand()` returns a copy with every `Call` inlined, and the handles appear on -it: +`expand()` returns a copy with every `Call` inlined, and the handles appear on it: ```python flat = program.expand() @@ -153,24 +113,15 @@ flat = program.expand() flat.fragments # {} — the registry is empty once the calls are gone ``` -Two call sites of a fragment that names its measurement `m0` would collide, so -the second is renamed `m0_2`. The rename is positional, following call order, -which is what ties `m0` to `q[0]` and `m0_2` to `q[1]` here. +Two call sites of a fragment that names its measurement `m0` would collide, so the second is renamed `m0_2`. The rename is positional, following call order, which is what ties `m0` to `q[0]` and `m0_2` to `q[1]` here. -Expanding is not a step the program needs before it runs. `qp.validate` and -every platform's `execute` inline the calls themselves, so `qp.simulate` takes -the program above unchanged, and the records come back under those same names. -`expand()` is how you get handles for the reading, and reaching for the names -directly skips it: +Expanding is not a step the program needs before it runs. `qp.validate` and every platform's `execute` inline the calls themselves, so `qp.simulate` takes the program above unchanged, and the records come back under those same names. `expand()` is how you get handles for the reading, and reaching for the names directly skips it: ```python result.get("m0", field=qp.MeasurementField.STATE) ``` -A handle from `flat.measurement_handles()` and the string `"m0"` select the -same record, since handles compare by name. Keeping the unexpanded program is -worth doing anyway: it is the one that still says the two qubits ran the same -sequence, which is exactly what the flattened copy has thrown away. +A handle from `flat.measurement_handles()` and the string `"m0"` select the same record, since handles compare by name. Keeping the unexpanded program is worth doing anyway: it is the one that still says the two qubits ran the same sequence, which is exactly what the flattened copy has thrown away. ## Reading the results @@ -200,58 +151,34 @@ q0_data.dims # ("tau",) q0_data.shape # (100,) ``` -Both records carry the sweep dimension and nothing else, since `average` -contributes none and a classified state has no `"IQ"` axis. The model branches -on `bus`, which is what makes the two curves differ: `p_excited` and `response` -both receive the bus string, so one model can describe a whole chip. Without -that branch the two qubits would return the same numbers and the second -measurement would be teaching nothing. +Both records carry the sweep dimension and nothing else, since `average` contributes none and a classified state has no `"IQ"` axis. The model branches on `bus`, which is what makes the two curves differ: `p_excited` and `response` both receive the bus string, so one model can describe a whole chip. Without that branch the two qubits would return the same numbers and the second measurement would be teaching nothing. One fragment, two call sites, two curves that separate because the chip does: -![Excited-state population against pulse spacing for two qubits, both rising toward 0.5, with q1 losing coherence faster than q0.](../assets/plots/cpmg-light.png#only-light) -![Excited-state population against pulse spacing for two qubits, both rising toward 0.5, with q1 losing coherence faster than q0.](../assets/plots/cpmg-dark.png#only-dark) +![Excited-state population against pulse spacing for two qubits, both rising toward 0.5, with q1 losing coherence faster than q0.](../assets/plots/cpmg-light.png#only-light) ![Excited-state population against pulse spacing for two qubits, both rising toward 0.5, with q1 losing coherence faster than q0.](../assets/plots/cpmg-dark.png#only-dark) -The total free evolution is four times the spacing, which is why the model -reads `4 * env["tau"]` rather than `env["tau"]`. The reference executor times -nothing, so the `wait` operations contribute no duration of their own and that -factor of four has to be written into the model by hand. On an instrument it -would come out of the sequence. +The total free evolution is four times the spacing, which is why the model reads `4 * env["tau"]` rather than `env["tau"]`. The reference executor times nothing, so the `wait` operations contribute no duration of their own and that factor of four has to be written into the model by hand. On an instrument it would come out of the sequence. ## Adapting it -To compare filter shapes rather than qubits, define a second fragment with a -different train length and call both on the same qubit: +To compare filter shapes rather than qubits, define a second fragment with a different train length and call both on the same qubit: ```python @qp.fragment def cpmg_8(f, drive, readout, tau): ... ``` -Because the length is structural, the two definitions both appear in the file -and each call site names the one it wants. That is the honest form: a `.qp` -reader can see how long each train is without evaluating anything. +Because the length is structural, the two definitions both appear in the file and each call site names the one it wants. That is the honest form: a `.qp` reader can see how long each train is without evaluating anything. -A Hahn echo is this fragment with a train of one, and the -[T1 and Ramsey](t1-and-ramsey.md) page introduces the pieces it is built from. -Ramsey is the train of none. +A Hahn echo is this fragment with a train of one, and the [T1 and Ramsey](t1-and-ramsey.md) page introduces the pieces it is built from. Ramsey is the train of none. -To keep the IQ point alongside the population, add `qp.MeasurementField.IQ` to -the `fields=` tuple inside the fragment. Everything the measurement records is -decided at the definition, so a call site cannot ask for more, and a second -kind of readout means a second fragment or a parameter that reaches the -measurement. +To keep the IQ point alongside the population, add `qp.MeasurementField.IQ` to the `fields=` tuple inside the fragment. Everything the measurement records is decided at the definition, so a call site cannot ask for more, and a second kind of readout means a second fragment or a parameter that reaches the measurement. -To call the same fragment across a whole register, loop over the qubits at -build time: +To call the same fragment across a whole register, loop over the qubits at build time: ```python for i in range(8): program.call(cpmg, q[i].drive, q[i].readout, tau) ``` -That writes eight call statements and one definition, and the handles come back -as `m0`, `m0_2` through `m0_8` in call order after `expand()`. Reading eight -records on one bus each is [multiplexed -readout](../guide/measurements.md) territory, and the naming is what keeps them -apart. +That writes eight call statements and one definition, and the handles come back as `m0`, `m0_2` through `m0_8` in call order after `expand()`. Reading eight records on one bus each is [multiplexed readout](../guide/measurements.md) territory, and the naming is what keeps them apart. diff --git a/docs/examples/cz-chevron.md b/docs/examples/cz-chevron.md index 55ede2e..09f6054 100644 --- a/docs/examples/cz-chevron.md +++ b/docs/examples/cz-chevron.md @@ -1,14 +1,8 @@ # CZ chevron -A CZ chevron is a two-axis sweep used to calibrate a two-qubit gate. You -prepare a state, fire a flux pulse whose amplitude and duration both vary, -and read the qubits out. The interference fringes in the resulting heatmap -converge on the amplitude where the two qubits are resonant, which is the -chevron's tip and the point the gate is tuned to. +A CZ chevron is a two-axis sweep used to calibrate a two-qubit gate. You prepare a state, fire a flux pulse whose amplitude and duration both vary, and read the qubits out. The interference fringes in the resulting heatmap converge on the amplitude where the two qubits are resonant, which is the chevron's tip and the point the gate is tuned to. -Three things appear here that the [Rabi example](rabi.md) does not have: a -schema with a single-channel flux bus, two nested sweeps, and an inline -waveform whose parameters are the sweep variables. +Three things appear here that the [Rabi example](rabi.md) does not have: a schema with a single-channel flux bus, two nested sweeps, and an inline waveform whose parameters are the sweep variables. ## The program @@ -47,45 +41,17 @@ with program.average(shots=1000): ### Why each piece is where it is -`qp.BusSchema.flux_tunable_transmon()` gives element `q` three bus kinds: -`drive` (IQ), `readout` (IQ, with an ADC), and `flux` (single channel). The -channel type is enforced at the call site, so a `Play` of an IQ waveform on -`q[0].flux` raises `ValidationError: Bus 'q0/flux' is a single channel but -received an IQWaveform (IQDrag)` when the program is built rather than when a -compiler runs. - -The two sweeps are nested with `amp` outside and `dur` inside, so the flux -pulse visits every `(amp, dur)` pair, which is the grid a chevron pattern -lives on. Nesting order decides two things at once: the inner variable moves -fastest during execution, and the result dimensions come back in nesting -order, outermost first. Each `qp.Range` holds -`round((stop - start) / step) + 1` points, so `Range(0.0, 1.0, 0.01)` is 101 -amplitudes and `Range(10, 210, 2)` is 101 durations, for 10201 grid points. - -`FlatTop(amplitude=amp, duration=dur, smooth_duration=5)` stores the two -variables as parameters of the waveform node. Nothing materializes the sweep -at build time; the platform decides whether to update an amplitude register, -regenerate the envelope per point, or refuse the program because its compiler -cannot do either. The reference executor renders no envelope at all, so it -accepts the program either way and tells you nothing about whether real -hardware would. - -`smooth_duration=5` sets the length of each erf-shaped edge, and `duration` -counts the edges in rather than adding them. The rise crosses half amplitude -5 ns in and is flat to within a part in 10⁵ by 10 ns, so the shortest few -durations on the axis, where `dur` is not comfortably above -`2 * smooth_duration`, never reach full amplitude at all. That is a property -of the pulse shape rather than of the sweep, and it is what makes the near -edge of the duration axis worth reading with care. - -The pi pulse plays on `q[1].drive` while the flux pulse plays on `q[0].flux`, -and the `sync()` between them is what stops the flux pulse from starting -before the preparation is over. The second `sync()` does the same for the -readout. - -`m0` and `m1` are two handles on the same shot. Auto-allocated names carry a -per-bus counter, so both come out as `m0` under different bus prefixes: -`q0/readout/m0` and `q1/readout/m0`. +`qp.BusSchema.flux_tunable_transmon()` gives element `q` three bus kinds: `drive` (IQ), `readout` (IQ, with an ADC), and `flux` (single channel). The channel type is enforced at the call site, so a `Play` of an IQ waveform on `q[0].flux` raises `ValidationError: Bus 'q0/flux' is a single channel but received an IQWaveform (IQDrag)` when the program is built rather than when a compiler runs. + +The two sweeps are nested with `amp` outside and `dur` inside, so the flux pulse visits every `(amp, dur)` pair, which is the grid a chevron pattern lives on. Nesting order decides two things at once: the inner variable moves fastest during execution, and the result dimensions come back in nesting order, outermost first. Each `qp.Range` holds `round((stop - start) / step) + 1` points, so `Range(0.0, 1.0, 0.01)` is 101 amplitudes and `Range(10, 210, 2)` is 101 durations, for 10201 grid points. + +`FlatTop(amplitude=amp, duration=dur, smooth_duration=5)` stores the two variables as parameters of the waveform node. Nothing materializes the sweep at build time; the platform decides whether to update an amplitude register, regenerate the envelope per point, or refuse the program because its compiler cannot do either. The reference executor renders no envelope at all, so it accepts the program either way and tells you nothing about whether real hardware would. + +`smooth_duration=5` sets the length of each erf-shaped edge, and `duration` counts the edges in rather than adding them. The rise crosses half amplitude 5 ns in and is flat to within a part in 10⁵ by 10 ns, so the shortest few durations on the axis, where `dur` is not comfortably above `2 * smooth_duration`, never reach full amplitude at all. That is a property of the pulse shape rather than of the sweep, and it is what makes the near edge of the duration axis worth reading with care. + +The pi pulse plays on `q[1].drive` while the flux pulse plays on `q[0].flux`, and the `sync()` between them is what stops the flux pulse from starting before the preparation is over. The second `sync()` does the same for the readout. + +`m0` and `m1` are two handles on the same shot. Auto-allocated names carry a per-bus counter, so both come out as `m0` under different bus prefixes: `q0/readout/m0` and `q1/readout/m0`. ## What it looks like on disk @@ -117,23 +83,11 @@ body: measure q[1].readout "readout" "weights" name="q1/readout/m0" ``` -`FlatTop`'s `buffer` argument defaults to `0` and was not passed, but the -writer spells out every constructor argument so that reading the file back -rebuilds the same waveform without depending on today's defaults. `Range`'s -integer bounds come back as `10.0` and `210.0` because a source stores its -bounds as floats. Sweep variables inside a waveform are written as bare -identifiers, and the parser resolves them against the `var` declarations -above. +`FlatTop`'s `buffer` argument defaults to `0` and was not passed, but the writer spells out every constructor argument so that reading the file back rebuilds the same waveform without depending on today's defaults. `Range`'s integer bounds come back as `10.0` and `210.0` because a source stores its bounds as floats. Sweep variables inside a waveform are written as bare identifiers, and the parser resolves them against the `var` declarations above. ## Reading the results -The reference executor is an interpreter that walks every shot of every grid -point, so its cost is the product of the axes. At the sizes above that is 101 -amplitudes by 101 durations by 1000 shots by 2 measurements, which is 20.4 -million model samples and minutes of wall clock on one core; an 11 by 11 grid -at the same shot count is 84 times less work and returns in seconds. Coarsen -the steps or lower `average(shots=...)` while you are iterating: the dimensions -and coordinates below come back the same either way, only shorter. +The reference executor is an interpreter that walks every shot of every grid point, so its cost is the product of the axes. At the sizes above that is 101 amplitudes by 101 durations by 1000 shots by 2 measurements, which is 20.4 million model samples and minutes of wall clock on one core; an 11 by 11 grid at the same shot count is 84 times less work and returns in seconds. Coarsen the steps or lower `average(shots=...)` while you are iterating: the dimensions and coordinates below come back the same either way, only shorter. ```python import numpy as np @@ -168,48 +122,25 @@ data0.coords["amp"] # 0.00, 0.01, ..., 1.00 data0.coords["dur"] # 10.0, 12.0, ..., 210.0 ``` -`data1` is the same grid measured on `q[1].readout` and has identical dims and -shape, since both measurements sit under the same two sweeps. Reading both is -what separates population moving from `q1` to `q0` from population being lost, -which a single readout cannot tell apart. - -The `response` function receives the currently bound loop variables by id, so -`env["amp"]` and `env["dur"]` are the coordinates of the grid point being -measured. It is called once per shot per measurement, and `bus` is the bus -string, so a model can respond differently on `q0/readout` and `q1/readout`. -Without a `model=` argument, `qp.simulate` uses a -`qp.MockMeasurementModel` that responds `0j` everywhere and the heatmap is -flat zero. The model above is a stand-in for physics, not a simulation of the -chip: it exists so the plot below has a chevron in it. - -`average` contributes no dimension, so the shots are already averaged out of -`data0` when you get it: the executor sums per grid point and divides by the -shot count it recorded there. A grid point holds `NaN` when that count is -zero, which happens only for a measurement inside a conditional arm the -program never selected at that point. - -Two swept dimensions besides `IQ` give a heatmap, and the array is already on -the grid, so no reshaping is needed. A heatmap colours one surface, so name the -quadrature you want; leaving `channels` out takes the magnitude instead: +`data1` is the same grid measured on `q[1].readout` and has identical dims and shape, since both measurements sit under the same two sweeps. Reading both is what separates population moving from `q1` to `q0` from population being lost, which a single readout cannot tell apart. + +The `response` function receives the currently bound loop variables by id, so `env["amp"]` and `env["dur"]` are the coordinates of the grid point being measured. It is called once per shot per measurement, and `bus` is the bus string, so a model can respond differently on `q0/readout` and `q1/readout`. Without a `model=` argument, `qp.simulate` uses a `qp.MockMeasurementModel` that responds `0j` everywhere and the heatmap is flat zero. The model above is a stand-in for physics, not a simulation of the chip: it exists so the plot below has a chevron in it. + +`average` contributes no dimension, so the shots are already averaged out of `data0` when you get it: the executor sums per grid point and divides by the shot count it recorded there. A grid point holds `NaN` when that count is zero, which happens only for a measurement inside a conditional arm the program never selected at that point. + +Two swept dimensions besides `IQ` give a heatmap, and the array is already on the grid, so no reshaping is needed. A heatmap colours one surface, so name the quadrature you want; leaving `channels` out takes the magnitude instead: ```python result.plot(m0, channels="i", value=qp.plotting.Quantity("Population transferred")) ``` -![Heatmap of transferred population against flux duration and amplitude, with interference fringes converging to a chevron tip at 0.5 V.](../assets/plots/cz-chevron-light.png#only-light) -![Heatmap of transferred population against flux duration and amplitude, with interference fringes converging to a chevron tip at 0.5 V.](../assets/plots/cz-chevron-dark.png#only-dark) +![Heatmap of transferred population against flux duration and amplitude, with interference fringes converging to a chevron tip at 0.5 V.](../assets/plots/cz-chevron-light.png#only-light) ![Heatmap of transferred population against flux duration and amplitude, with interference fringes converging to a chevron tip at 0.5 V.](../assets/plots/cz-chevron-dark.png#only-dark) -The inner sweep runs along the x axis and the outer one up the y axis, matching -the loop nesting rather than the dimension order in `data0.dims`; `x=` and `y=` -say otherwise. matplotlib is not a runtime dependency; it comes with the `viz` -extra, installed with `pip install "qprogram[viz]"`. -[Plotting results](../guide/plotting.md) covers the rest. +The inner sweep runs along the x axis and the outer one up the y axis, matching the loop nesting rather than the dimension order in `data0.dims`; `x=` and `y=` say otherwise. matplotlib is not a runtime dependency; it comes with the `viz` extra, installed with `pip install "qprogram[viz]"`. [Plotting results](../guide/plotting.md) covers the rest. ## Adapting it -To move both parameters together, taking one diagonal cut through the grid -instead of the whole grid, compose the loops in parallel rather than nesting -them: +To move both parameters together, taking one diagonal cut through the grid instead of the whole grid, compose the loops in parallel rather than nesting them: ```python with program.sweep(amp, qp.Range(0.0, 1.0, 0.01)) | program.sweep(dur, qp.Range(10, 210, 2)): @@ -217,15 +148,9 @@ with program.sweep(amp, qp.Range(0.0, 1.0, 0.01)) | program.sweep(dur, qp.Range( ``` The two sources must then hold the same number of points, and both do here at -101. When they do not, the composition is rejected as it is built: -`ValidationError: parallel loops must have the same number of iterations to -advance in lockstep; got Sweep('amp'): 11, Sweep('dur'): 12`. The results come -back on one `"amp|dur"` dimension of 101 points carrying `amp` and `dur` as -coordinates along it, which `plot` draws as one axis with the other above it. -See [Control flow](../guide/control-flow.md). +101. When they do not, the composition is rejected as it is built: `ValidationError: parallel loops must have the same number of iterations to advance in lockstep; got Sweep('amp'): 11, Sweep('dur'): 12`. The results come back on one `"amp|dur"` dimension of 101 points carrying `amp` and `dur` as coordinates along it, which `plot` draws as one axis with the other above it. See [Control flow](../guide/control-flow.md). -For the SNZ flavor of CZ, swap the waveform and leave the rest of the program -alone: +For the SNZ flavor of CZ, swap the waveform and leave the rest of the program alone: ```python program.play( @@ -234,20 +159,8 @@ program.play( ) ``` -`SuddenNetZero` is a positive square segment, a zero hold of `t_phi` ns, then -a negative segment scaled by `b`, all inside `duration`. It is single channel -like `FlatTop`, so the bus check passes unchanged, and `b` is the parameter -you detune from 1 to null whatever residual flux the line adds. - -If the chip has dedicated couplers, use -`qp.BusSchema.flux_tunable_transmon_coupled()` and drive `schema.c[0, 1].flux` -instead of `q[0].flux`. A coupler index is a tuple, and the resolved bus name -joins it with an underscore, giving `c0_1/flux`. See -[Buses and schemas](../guide/buses.md). - -To keep the raw ADC trace alongside the IQ point, request it per measurement -with `fields=(qp.MeasurementField.IQ, qp.MeasurementField.RAW)` and read it -back with `result.get(m0, field=qp.MeasurementField.RAW)`, which adds a -`"time"` dimension between the sweeps and `"IQ"`. On a 101 by 101 grid that is -a large array, so it is worth coarsening the sweeps first. See -[Measurements and results](../guide/measurements.md). +`SuddenNetZero` is a positive square segment, a zero hold of `t_phi` ns, then a negative segment scaled by `b`, all inside `duration`. It is single channel like `FlatTop`, so the bus check passes unchanged, and `b` is the parameter you detune from 1 to null whatever residual flux the line adds. + +If the chip has dedicated couplers, use `qp.BusSchema.flux_tunable_transmon_coupled()` and drive `schema.c[0, 1].flux` instead of `q[0].flux`. A coupler index is a tuple, and the resolved bus name joins it with an underscore, giving `c0_1/flux`. See [Buses and schemas](../guide/buses.md). + +To keep the raw ADC trace alongside the IQ point, request it per measurement with `fields=(qp.MeasurementField.IQ, qp.MeasurementField.RAW)` and read it back with `result.get(m0, field=qp.MeasurementField.RAW)`, which adds a `"time"` dimension between the sweeps and `"IQ"`. On a 101 by 101 grid that is a large array, so it is worth coarsening the sweeps first. See [Measurements and results](../guide/measurements.md). diff --git a/docs/examples/index.md b/docs/examples/index.md index 50564d3..fd6a9f0 100644 --- a/docs/examples/index.md +++ b/docs/examples/index.md @@ -1,17 +1,8 @@ # Examples -Each page in this section is one program given in full: the Python that builds -it, the `.qp` text it serializes to, and the calls that run it and read the -results back. Nothing here needs an instrument, because `qp.simulate` runs on -the reference platform, the pure-Python interpreter that ships with the -package. The pages are ordered so that each one adds a few pieces to the ones -before it, and each names what those are in its opening paragraphs. +Each page in this section is one program given in full: the Python that builds it, the `.qp` text it serializes to, and the calls that run it and read the results back. Nothing here needs an instrument, because `qp.simulate` runs on the reference platform, the pure-Python interpreter that ships with the package. The pages are ordered so that each one adds a few pieces to the ones before it, and each names what those are in its opening paragraphs. -The figures come from running the programs. Each one is built from the page's -own program, executed on the reference platform, and written to -`docs/assets/plots/`, so a plot cannot drift from the code printed above it. -Every figure is written twice, once per site theme, and the page picks the one -built for the surface you are reading on. +The figures come from running the programs. Each one is built from the page's own program, executed on the reference platform, and written to `docs/assets/plots/`, so a plot cannot drift from the code printed above it. Every figure is written twice, once per site theme, and the page picks the one built for the surface you are reading on. | Example | The features it exercises | |---|---| @@ -26,27 +17,10 @@ built for the surface you are reading on. | [Single-shot readout](single-shot-readout.md) | A program with no averaging block, a shot index as its own result dimension, `qp.Values` as a two-point preparation axis, and a hand-written `qp.MeasurementModel` that classifies each shot. | | [Checking a program before it runs](checking-a-program.md) | A `qp.PlatformCapabilities` built by hand from a custom `qp.Profile` and predicate, three classes of `qp.validate` diagnostic from one program, and each diagnostic path resolved to a line of the `.qp` file. | -The alias split each program is built around is deliberate. The program is -written against string waveform aliases (`"pi_pulse"`, `"readout"` and -`"weights"` in the Rabi program) so that it describes the experiment rather -than one calibration of it. `program.with_waveforms(library)` then -returns a copy with those aliases replaced by concrete waveforms and leaves -the original alone, which is what lets one program text run against many -calibration sets. A platform consumes the resolved copy; the reference -executor accepts either, since it never renders an envelope. +The alias split each program is built around is deliberate. The program is written against string waveform aliases (`"pi_pulse"`, `"readout"` and `"weights"` in the Rabi program) so that it describes the experiment rather than one calibration of it. `program.with_waveforms(library)` then returns a copy with those aliases replaced by concrete waveforms and leaves the original alone, which is what lets one program text run against many calibration sets. A platform consumes the resolved copy; the reference executor accepts either, since it never renders an envelope. -The numbers a run produces come from a measurement model, not from physics. -`qp.simulate(program)` with no `model=` argument uses a -`qp.MockMeasurementModel` whose IQ response is `0j`, so every value comes back -as exactly `0.0`. The dimensions, coordinates, and shapes are the real -contract; the values are not, and both pages pass a response function where -the values are what the plot is about. +The numbers a run produces come from a measurement model, not from physics. `qp.simulate(program)` with no `model=` argument uses a `qp.MockMeasurementModel` whose IQ response is `0j`, so every value comes back as exactly `0.0`. The dimensions, coordinates, and shapes are the real contract; the values are not, and both pages pass a response function where the values are what the plot is about. ## Related pages -[Operations](../guide/operations.md) lists the operations these programs use, -[Control flow](../guide/control-flow.md) covers sweeps, averaging, and -parallel composition, and [Measurements and -results](../guide/measurements.md) covers handles, fields, and result shapes. -The generated [API reference](../reference/api-qprogram.md) carries the -signatures. +[Operations](../guide/operations.md) lists the operations these programs use, [Control flow](../guide/control-flow.md) covers sweeps, averaging, and parallel composition, and [Measurements and results](../guide/measurements.md) covers handles, fields, and result shapes. The generated [API reference](../reference/api-qprogram.md) carries the signatures. diff --git a/docs/examples/multiplexed-readout.md b/docs/examples/multiplexed-readout.md index 7b2a818..b59bd71 100644 --- a/docs/examples/multiplexed-readout.md +++ b/docs/examples/multiplexed-readout.md @@ -1,18 +1,8 @@ # Multiplexed readout -Readout resonators on a chip are spaced across one feedline so that a single -line can interrogate all of them at once. A program that reads four qubits per -shot is therefore not four experiments interleaved; it is one experiment with -four records, and the drive sweep in front of it calibrates four pi pulses in -the time one would take. - -Two things follow from that, and they are what this page is about. Four -measurements need four handles, which a Python loop produces along with the -program itself. And four resonators are four different pulses under one alias, -which is what `qp.WaveformLibrary` exists for: a plain dict maps a name to one -waveform, while a library maps a name to a waveform per bus. -[Saving and loading](../guide/serialization.md) documents the library and its -`.wfl` format; here it decides what each qubit is actually sent. +Readout resonators on a chip are spaced across one feedline so that a single line can interrogate all of them at once. A program that reads four qubits per shot is therefore not four experiments interleaved; it is one experiment with four records, and the drive sweep in front of it calibrates four pi pulses in the time one would take. + +Two things follow from that, and they are what this page is about. Four measurements need four handles, which a Python loop produces along with the program itself. And four resonators are four different pulses under one alias, which is what `qp.WaveformLibrary` exists for: a plain dict maps a name to one waveform, while a library maps a name to a waveform per bus. [Saving and loading](../guide/serialization.md) documents the library and its `.wfl` format; here it decides what each qubit is actually sent. ## The program @@ -49,27 +39,18 @@ with program.average(shots=1000): ### Why each piece is where it is -The Python loop runs while the program is built, so it writes four `set_gain` -and four `play` statements into the AST rather than a loop into the file. The -index is a Python value, and `q[i].drive` resolves it to a bus at that moment. +The Python loop runs while the program is built, so it writes four `set_gain` and four `play` statements into the AST rather than a loop into the file. The index is a Python value, and `q[i].drive` resolves it to a bus at that moment. -The drives all come before the single `sync`, which is what makes this one -shot rather than four. Each bus has its own timeline, so four pulses on four -drive buses are concurrent until something aligns them; the `sync` then holds -every readout until the last drive has finished. +The drives all come before the single `sync`, which is what makes this one shot rather than four. Each bus has its own timeline, so four pulses on four drive buses are concurrent until something aligns them; the `sync` then holds every readout until the last drive has finished. -`handles` is a dict rather than four names because the loop that builds it is -the loop that decides how many there are. Every one of the four is named -`m0`: +`handles` is a dict rather than four names because the loop that builds it is the loop that decides how many there are. Every one of the four is named `m0`: ```python {i: h.name for i, h in handles.items()} # {0: "q0/readout/m0", 1: "q1/readout/m0", 2: "q2/readout/m0", 3: "q3/readout/m0"} ``` -The counter is per bus, so the bus prefix is what distinguishes these, not the -number. Four measurements on four buses are all `m0`; three on one bus would be -`m0`, `m1`, `m2`. +The counter is per bus, so the bus prefix is what distinguishes these, not the number. Four measurements on four buses are all `m0`; three on one bus would be `m0`, `m1`, `m2`. ## What it produces @@ -105,16 +86,11 @@ body: measure q[3].readout "readout" "weights" name="q3/readout/m0" fields=["state", "iq"] ``` -The schema block still declares one element with two bus kinds. It records what -kinds exist, not which qubits a program touched, so four qubits in the body add -nothing to it. +The schema block still declares one element with two bus kinds. It records what kinds exist, not which qubits a program touched, so four qubits in the body add nothing to it. ## One alias, four pulses -Every example before this one resolved its aliases with a plain dict, which is -one global tier: `"readout"` means the same waveform everywhere. Four -resonators at four frequencies need four readout pulses, and a -`qp.WaveformLibrary` keys an entry by a bus coordinate as well as a name: +Every example before this one resolved its aliases with a plain dict, which is one global tier: `"readout"` means the same waveform everywhere. Four resonators at four frequencies need four readout pulses, and a `qp.WaveformLibrary` keys an entry by a bus coordinate as well as a name: ```python library = qp.WaveformLibrary() @@ -145,13 +121,9 @@ library.set( library.set("pi_pulse", qp.waveforms.IQDrag(0.5, 40, 8, 0.1)) ``` -`element`, `idx`, and `kind` are keyword-only and only three combinations are -legal, one per tier: all three is an exact entry for one bus, `element` and -`kind` is a family entry for every index of that kind, and none of them is a -global entry. Any other combination raises `ValidationError`. +`element`, `idx`, and `kind` are keyword-only and only three combinations are legal, one per tier: all three is an exact entry for one bus, `element` and `kind` is a family entry for every index of that kind, and none of them is a global entry. Any other combination raises `ValidationError`. -Lookup walks the three in order and takes the first hit, so the two qubits with -their own entry get it and the rest fall through to the family: +Lookup walks the three in order and takes the first hit, so the two qubits with their own entry get it and the rest fall through to the family: ```python for i in QUBITS: @@ -163,24 +135,18 @@ for i in QUBITS: # 3 0.5 2000 family ``` -The family tier is scoped by kind as well as element, so it covers -`q[*].readout` and not `q[*].drive`. A bus with no entry at any tier resolves -to `None` and the alias passes through the program as the string it was, which -is why a partial library is not an error: +The family tier is scoped by kind as well as element, so it covers `q[*].readout` and not `q[*].drive`. A bus with no entry at any tier resolves to `None` and the alias passes through the program as the string it was, which is why a partial library is not an error: ```python library.get(q[0].drive, "readout") # None library.get("legacy_readout", "readout") # None ``` -A raw-string bus reaches the global tier only, since a plain string carries no -element or kind to match on. That is the trap in mixing raw-string buses with a -tiered library: they silently get the global entry, or nothing. +A raw-string bus reaches the global tier only, since a plain string carries no element or kind to match on. That is the trap in mixing raw-string buses with a tiered library: they silently get the global entry, or nothing. ## What the library looks like on disk -A library is its own file with its own format and version, separate from the -program: +A library is its own file with its own format and version, separate from the program: ```python library.save("chip.wfl") @@ -195,14 +161,9 @@ library.save("chip.wfl") "pi_pulse" = IQDrag(amplitude=0.5, duration=40, sigma=8, beta=0.1) ``` -The coordinate before the `=` is the tier: a concrete index is an exact entry, -`[*]` is a family entry, and no coordinate at all is a global one. Entries are -written in insertion order, and `qp.WaveformLibrary.load("chip.wfl")` returns a -library that dumps byte-identically. +The coordinate before the `=` is the tier: a concrete index is an exact entry, `[*]` is a family entry, and no coordinate at all is a global one. Entries are written in insertion order, and `qp.WaveformLibrary.load("chip.wfl")` returns a library that dumps byte-identically. -Two files rather than one is the point of the split. `chip.qp` says what the -experiment is and changes when the experiment changes; `chip.wfl` says what -this chip's pulses are today and changes after every calibration run. +Two files rather than one is the point of the split. `chip.qp` says what the experiment is and changes when the experiment changes; `chip.wfl` says what this chip's pulses are today and changes after every calibration run. ## Reading four records @@ -213,8 +174,7 @@ result = qp.simulate(resolved, model=model) result.get(handles[2], field=qp.MeasurementField.STATE).dims # ("amp",) ``` -`result.get` takes a handle, a name, or a position, and `bus=` narrows the -search before any of them. All of these select the same record: +`result.get` takes a handle, a name, or a position, and `bus=` narrows the search before any of them. All of these select the same record: ```python result.get(handles[2]) # the handle the builder returned @@ -224,15 +184,9 @@ result.get(2) # third measurement in the program result.get(0, bus="q2/readout") # first measurement on that bus ``` -The position form is the one a loop wants, since `result.get(i, ...)` walks the -records in the order the program declared them. Note that `bus=` filters first, -so `get(0, bus="q2/readout")` is the first measurement on that bus rather than -the first overall. Asking for a record that is not there raises rather than -returning an empty array: `KeyError: "No measurement named 'q0/readout/m0' on -bus 'q1/readout'"`. +The position form is the one a loop wants, since `result.get(i, ...)` walks the records in the order the program declared them. Note that `bus=` filters first, so `get(0, bus="q2/readout")` is the first measurement on that bus rather than the first overall. Asking for a record that is not there raises rather than returning an empty array: `KeyError: "No measurement named 'q0/readout/m0' on bus 'q1/readout'"`. -The four qubits only differ if the model makes them differ. `sample` receives -the bus string, so one model describes the whole chip: +The four qubits only differ if the model makes them differ. `sample` receives the bus string, so one model describes the whole chip: ```python PERIOD = {"q0/readout": 1.0, "q1/readout": 2.0, "q2/readout": 3.0, "q3/readout": 4.0} @@ -254,51 +208,34 @@ model = qp.MockMeasurementModel( # [1.0, 0.514, 0.255, 0.135] ``` -Without the branch on `bus` all four records come back identical, which looks -like a working multiplexed readout and is not one. +Without the branch on `bus` all four records come back identical, which looks like a working multiplexed readout and is not one. -Four records off one shot, each qubit reaching its pi amplitude somewhere -different, which is the calibration this measurement exists to produce: +Four records off one shot, each qubit reaching its pi amplitude somewhere different, which is the calibration this measurement exists to produce: -![Excited-state population against drive amplitude for four qubits, each with a different Rabi period, labelled q0 through q3.](../assets/plots/multiplexed-readout-light.png#only-light) -![Excited-state population against drive amplitude for four qubits, each with a different Rabi period, labelled q0 through q3.](../assets/plots/multiplexed-readout-dark.png#only-dark) +![Excited-state population against drive amplitude for four qubits, each with a different Rabi period, labelled q0 through q3.](../assets/plots/multiplexed-readout-light.png#only-light) ![Excited-state population against drive amplitude for four qubits, each with a different Rabi period, labelled q0 through q3.](../assets/plots/multiplexed-readout-dark.png#only-dark) ## Adapting it -To move a program to different qubits, `rebind` maps element indices and -returns a copy: +To move a program to different qubits, `rebind` maps element indices and returns a copy: ```python ported = program.rebind(elements={("q", 0): ("q", 5)}) ``` -Auto-allocated measurement names are re-derived from the new bus, so -`q0/readout/m0` becomes `q5/readout/m0` and the `.qp` line becomes -`measure q[5].readout ... name="q5/readout/m0"`. A name you supplied yourself -is left alone, since rebind can tell the two apart. Qubits not in the mapping -pass through untouched and the original program is unchanged. +Auto-allocated measurement names are re-derived from the new bus, so `q0/readout/m0` becomes `q5/readout/m0` and the `.qp` line becomes `measure q[5].readout ... name="q5/readout/m0"`. A name you supplied yourself is left alone, since rebind can tell the two apart. Qubits not in the mapping pass through untouched and the original program is unchanged. -Porting also moves a bus out from under its library entry, which is the thing -to watch: +Porting also moves a bus out from under its library entry, which is the thing to watch: ```python program.with_waveforms(library) # q[0].readout gets the exact entry, amplitude 0.9 ported.with_waveforms(library) # q[5].readout gets the family entry, amplitude 0.5 ``` -The library is keyed on the coordinate, not on the program, so a program that -was calibrated on q0 quietly plays the family pulse on q5. That is the right -behavior, since the whole point of the coordinate is that q5 is a different -resonator, but it does mean porting and calibrating are one step rather than -two. +The library is keyed on the coordinate, not on the program, so a program that was calibrated on q0 quietly plays the family pulse on q5. That is the right behavior, since the whole point of the coordinate is that q5 is a different resonator, but it does mean porting and calibrating are one step rather than two. -Rebind before serializing, not after loading. Whether a name was -auto-allocated is in-memory state that `.qp` does not carry, so on a reloaded -program rebind treats every name as user-supplied and leaves it stale: the bus -becomes `q[5].readout` while the name stays `q0/readout/m0`. +Rebind before serializing, not after loading. Whether a name was auto-allocated is in-memory state that `.qp` does not carry, so on a reloaded program rebind treats every name as user-supplied and leaves it stale: the bus becomes `q[5].readout` while the name stays `q0/readout/m0`. -A raw-string bus cannot be rebound at all, and rebind says so rather than -leaving it behind: +A raw-string bus cannot be rebound at all, and rebind says so rather than leaving it behind: ``` ValidationError: rebind left raw-string bus(es) unported: 'legacy_readout'. @@ -307,10 +244,6 @@ Raw strings carry no schema metadata to re-resolve — map them via strings={... them in place. ``` -Mapping one with `strings={"legacy_readout": "new_readout"}` moves the bus, but -the value stays a plain string, so its measurement keeps the global `m0` prefix -and the name will not track the bus. A schema is what makes porting work. +Mapping one with `strings={"legacy_readout": "new_readout"}` moves the bus, but the value stays a plain string, so its measurement keeps the global `m0` prefix and the name will not track the bus. A schema is what makes porting work. -To scale past four, `QUBITS` is the only line that changes. Eight qubits give -eight records read the same way, and the cost of the run is linear in the -number of buses. +To scale past four, `QUBITS` is the only line that changes. Eight qubits give eight records read the same way, and the cost of the run is linear in the number of buses. diff --git a/docs/examples/qubit-spectroscopy.md b/docs/examples/qubit-spectroscopy.md index 21a2d06..779e789 100644 --- a/docs/examples/qubit-spectroscopy.md +++ b/docs/examples/qubit-spectroscopy.md @@ -1,19 +1,8 @@ # Qubit spectroscopy -The frequency a qubit answers to is the first number an experiment needs, and -finding it is a frequency sweep. Park a long, weak tone on the drive bus, step -its frequency across a window, and read the qubit out at every step. Where the -tone lands on the transition the qubit is driven out of its ground state and -the readout response moves with it, so the feature that appears in the sweep -locates `f01`. Everything the [Rabi example](rabi.md) does starts here, because -the `"pi_pulse"` alias it plays only means something once this measurement has -found the frequency to play it at. - -Two things appear here that the Rabi program does not have: the swept quantity -reaches the instrument through `set_frequency` rather than through a gain or a -waveform parameter, and the sweep is a `qp.Linspace` rather than a `qp.Range`. -The control flow is unchanged, so the whole difference between the two -experiments is which operation the loop variable feeds. +The frequency a qubit answers to is the first number an experiment needs, and finding it is a frequency sweep. Park a long, weak tone on the drive bus, step its frequency across a window, and read the qubit out at every step. Where the tone lands on the transition the qubit is driven out of its ground state and the readout response moves with it, so the feature that appears in the sweep locates `f01`. Everything the [Rabi example](rabi.md) does starts here, because the `"pi_pulse"` alias it plays only means something once this measurement has found the frequency to play it at. + +Two things appear here that the Rabi program does not have: the swept quantity reaches the instrument through `set_frequency` rather than through a gain or a waveform parameter, and the sweep is a `qp.Linspace` rather than a `qp.Range`. The control flow is unchanged, so the whole difference between the two experiments is which operation the loop variable feeds. ## The program @@ -40,44 +29,15 @@ with program.average(shots=1000): ### Why each piece is where it is -`set_frequency` retunes the oscillator on a bus and takes its argument in Hz. -It accepts a plain float or an `Expression`, which is what lets the loop -variable go straight in: the sweep binds `freq` to a new value at each -iteration and the operation writes that value to the drive NCO. The operation -is a leaf like any other, so it sits in the loop body next to the `play` it -affects rather than being configuration attached to the bus. - -`qp.Linspace(4.6e9, 5.4e9, num=201)` gives 201 points with both ends included. -A spectroscopy window is described by its edges and by how many points you can -afford across them, which is exactly the two things `Linspace` takes; it -derives the spacing, and `Linspace(4.6e9, 5.4e9, num=201).step()` reports the -4 MHz it arrived at. `qp.Range` takes the other pair, a start and a spacing, -and derives the count and the last point, which lands on `stop` only when the -step divides the span evenly. Either is usable here, and the choice is about -which two numbers you actually know. - -Both sources are `KIND` `"linear"`, so a platform is free to compile either as -a hardware ramp over one register rather than as a table of 201 values. Writing -the same 201 points as `qp.Values` gives up that option: `Values` is `KIND` -`"arbitrary"` even when its points are evenly spaced, because the source proves -nothing about their regularity to a compiler. See -[Capabilities, diagnostics, and profiles](../guide/capabilities.md) for how a -platform declares which kinds it can take. - -`"saturation"` is a long, weak pulse, and both halves of that matter on -hardware. Long, because a tone of 20 microseconds has a bandwidth far narrower -than the 4 MHz step, so it interrogates one point of the window at a time -rather than smearing across several. Weak, because a strong tone power-broadens -the transition and pulls it, which widens the feature and moves the center away -from the frequency you are trying to measure. The reference executor renders no -envelope, so neither effect appears in the run below; they are the reason the -alias resolves to the pulse it does on an instrument. - -The `average` block sits outside the sweep, so the whole window is scanned 1000 -times over rather than 1000 shots being taken at one frequency before moving -on. `sync()` with no arguments aligns every bus the program has touched, which -here keeps the readout from starting before the saturation tone has finished. -Both are the same decisions the [Rabi example](rabi.md) explains at length. +`set_frequency` retunes the oscillator on a bus and takes its argument in Hz. It accepts a plain float or an `Expression`, which is what lets the loop variable go straight in: the sweep binds `freq` to a new value at each iteration and the operation writes that value to the drive NCO. The operation is a leaf like any other, so it sits in the loop body next to the `play` it affects rather than being configuration attached to the bus. + +`qp.Linspace(4.6e9, 5.4e9, num=201)` gives 201 points with both ends included. A spectroscopy window is described by its edges and by how many points you can afford across them, which is exactly the two things `Linspace` takes; it derives the spacing, and `Linspace(4.6e9, 5.4e9, num=201).step()` reports the 4 MHz it arrived at. `qp.Range` takes the other pair, a start and a spacing, and derives the count and the last point, which lands on `stop` only when the step divides the span evenly. Either is usable here, and the choice is about which two numbers you actually know. + +Both sources are `KIND` `"linear"`, so a platform is free to compile either as a hardware ramp over one register rather than as a table of 201 values. Writing the same 201 points as `qp.Values` gives up that option: `Values` is `KIND` `"arbitrary"` even when its points are evenly spaced, because the source proves nothing about their regularity to a compiler. See [Capabilities, diagnostics, and profiles](../guide/capabilities.md) for how a platform declares which kinds it can take. + +`"saturation"` is a long, weak pulse, and both halves of that matter on hardware. Long, because a tone of 20 microseconds has a bandwidth far narrower than the 4 MHz step, so it interrogates one point of the window at a time rather than smearing across several. Weak, because a strong tone power-broadens the transition and pulls it, which widens the feature and moves the center away from the frequency you are trying to measure. The reference executor renders no envelope, so neither effect appears in the run below; they are the reason the alias resolves to the pulse it does on an instrument. + +The `average` block sits outside the sweep, so the whole window is scanned 1000 times over rather than 1000 shots being taken at one frequency before moving on. `sync()` with no arguments aligns every bus the program has touched, which here keeps the readout from starting before the saturation tone has finished. Both are the same decisions the [Rabi example](rabi.md) explains at length. ## What it produces @@ -104,16 +64,11 @@ body: measure q[0].readout "readout" "weights" name="q0/readout/m0" ``` -The sweep source is written with every argument named, so a reader of the file -does not have to know the constructor's positional order, and the bounds come -back as floats because a source stores them that way. `set_frequency` writes -its argument as the bare identifier `freq`, which the parser resolves against -the `var` declaration above it. +The sweep source is written with every argument named, so a reader of the file does not have to know the constructor's positional order, and the bounds come back as floats because a source stores them that way. `set_frequency` writes its argument as the bare identifier `freq`, which the parser resolves against the `var` declaration above it. ## Running it -The three aliases resolve against a calibration set, and `qp.simulate` runs the -resolved copy on the reference platform: +The three aliases resolve against a calibration set, and `qp.simulate` runs the resolved copy on the reference platform: ```python import numpy as np @@ -142,21 +97,13 @@ data.shape # (201, 2) data.coords["freq"] # 4.600e9, 4.604e9, ..., 5.400e9 ``` -One dimension for the sweep and a trailing `"IQ"` dimension of length 2, the -same shape the Rabi program returns, since the two have the same control flow. +One dimension for the sweep and a trailing `"IQ"` dimension of length 2, the same shape the Rabi program returns, since the two have the same control flow. -The peak is the model's, not the executor's. `set_frequency` evaluates its -expression and is otherwise a no-op, exactly as `play` and `sync` are, so -nothing in the run knows what a qubit is. What produces the curve is -`lorentzian` reading `env["freq"]`, which holds the loop variable bound at the -grid point being measured. Without a `model=` argument every value comes back -as `0.0`. +The peak is the model's, not the executor's. `set_frequency` evaluates its expression and is otherwise a no-op, exactly as `play` and `sync` are, so nothing in the run knows what a qubit is. What produces the curve is `lorentzian` reading `env["freq"]`, which holds the loop variable bound at the grid point being measured. Without a `model=` argument every value comes back as `0.0`. ## Plotting -Spectroscopy is usually read as a magnitude rather than as two quadratures, -since the phase of the transmitted signal depends on cable length and the -feature does not: +Spectroscopy is usually read as a magnitude rather than as two quadratures, since the phase of the transmitted signal depends on cable length and the feature does not: ```python result.plot( @@ -167,37 +114,22 @@ result.plot( ) ``` -![Readout magnitude against drive frequency, flat except for a sharp peak at 5.000 GHz.](../assets/plots/qubit-spectroscopy-light.png#only-light) -![Readout magnitude against drive frequency, flat except for a sharp peak at 5.000 GHz.](../assets/plots/qubit-spectroscopy-dark.png#only-dark) +![Readout magnitude against drive frequency, flat except for a sharp peak at 5.000 GHz.](../assets/plots/qubit-spectroscopy-light.png#only-light) ![Readout magnitude against drive frequency, flat except for a sharp peak at 5.000 GHz.](../assets/plots/qubit-spectroscopy-dark.png#only-dark) -`channels="magnitude"` is `np.hypot(I, Q)`, and the `qp.plotting.Quantity` on -`coords` restates the axis in gigahertz: the arithmetic and the unit it -produces travel as one object, so the axis cannot end up reading `(Hz)` over -numbers running 4.6 to 5.4. matplotlib is not a runtime dependency; it comes -with the `viz` extra, installed with `pip install "qprogram[viz]"`. +`channels="magnitude"` is `np.hypot(I, Q)`, and the `qp.plotting.Quantity` on `coords` restates the axis in gigahertz: the arithmetic and the unit it produces travel as one object, so the axis cannot end up reading `(Hz)` over numbers running 4.6 to 5.4. matplotlib is not a runtime dependency; it comes with the `viz` extra, installed with `pip install "qprogram[viz]"`. -The figure is restated; the result is not. Reading the peak back is arithmetic -on the array, and the array is still in hertz: +The figure is restated; the result is not. Reading the peak back is arithmetic on the array, and the array is still in hertz: ```python magnitude = np.hypot(data.sel(IQ="I"), data.sel(IQ="Q")) f01 = float(data.coords["freq"][int(np.argmax(magnitude.values))]) # 5.0e9 ``` -`np.hypot` over two `sel` results returns a `DataArray` with dims `("freq",)`, -so the coordinate survives the arithmetic and the peak comes back as a -frequency. `np.argmax` wants the underlying array rather than the `DataArray`, -which is what `.values` is for; handing it the labelled array raises -`ValueError: dimensions ('freq',) must have the same length as the number of -data dimensions, ndim=0`. The same split is worth remembering for anything you -draw on the axes `plot` returns: they are in the figure's units, so marking the -peak is `ax.axvline(f01 / 1e9)`. +`np.hypot` over two `sel` results returns a `DataArray` with dims `("freq",)`, so the coordinate survives the arithmetic and the peak comes back as a frequency. `np.argmax` wants the underlying array rather than the `DataArray`, which is what `.values` is for; handing it the labelled array raises `ValueError: dimensions ('freq',) must have the same length as the number of data dimensions, ndim=0`. The same split is worth remembering for anything you draw on the axes `plot` returns: they are in the figure's units, so marking the peak is `ax.axvline(f01 / 1e9)`. ## Adapting it -The scan above is the coarse one. Once it has put `f01` near 5 GHz, the next -measurement is a narrow window centered there, and the natural way to write it -is to sweep the detuning and add the center in the program: +The scan above is the coarse one. Once it has put `f01` near 5 GHz, the next measurement is a narrow window centered there, and the natural way to write it is to sweep the detuning and add the center in the program: ```python fine = qp.QProgram(label="qubit_spectroscopy_fine", schema=schema) @@ -211,31 +143,17 @@ with fine.average(shots=1000): m = fine.measure(q[0].readout, "readout", "weights") ``` -`5.0e9 + det` is the first expression on these pages that is not a bare -variable, and it serializes as one: +`5.0e9 + det` is the first expression on these pages that is not a bare variable, and it serializes as one: ``` set_frequency q[0].drive (5000000000.0 + det) ``` -The addition survives into the file because one of its operands is a variable -the program will not know a value for until it runs. Arithmetic between plain -numbers does not: Python evaluates `2 * np.pi * 2e-3` before the DSL is -handed the result, so only the part that touches a variable becomes a node. -The cost of the node is a wider capability requirement. `set_frequency` with a -bare variable asks a platform for `op.set_frequency` and `expr.variable`; with -the sum it also asks for `expr.binary_op` and `expr.constant`, which a -sequencer that can only load a swept register into a frequency word cannot -supply. See [Variables and expressions](../guide/variables.md). - -To measure how hard the tone is driving the transition, put an amplitude sweep -outside the frequency sweep and use `set_gain`. The peak broadens and shifts -with power, and the two-dimensional result that comes back is read the same way -the [CZ chevron](cz-chevron.md) reads its grid. - -For a window that is dense near the peak and coarse away from it, pass the -points explicitly with `qp.Values`, at the cost of the `"linear"` kind and -whatever a platform does with it: +The addition survives into the file because one of its operands is a variable the program will not know a value for until it runs. Arithmetic between plain numbers does not: Python evaluates `2 * np.pi * 2e-3` before the DSL is handed the result, so only the part that touches a variable becomes a node. The cost of the node is a wider capability requirement. `set_frequency` with a bare variable asks a platform for `op.set_frequency` and `expr.variable`; with the sum it also asks for `expr.binary_op` and `expr.constant`, which a sequencer that can only load a swept register into a frequency word cannot supply. See [Variables and expressions](../guide/variables.md). + +To measure how hard the tone is driving the transition, put an amplitude sweep outside the frequency sweep and use `set_gain`. The peak broadens and shifts with power, and the two-dimensional result that comes back is read the same way the [CZ chevron](cz-chevron.md) reads its grid. + +For a window that is dense near the peak and coarse away from it, pass the points explicitly with `qp.Values`, at the cost of the `"linear"` kind and whatever a platform does with it: ```python points = np.concatenate( @@ -245,7 +163,4 @@ with program.sweep(freq, qp.Values(points)): ... ``` -To find the readout resonator rather than the qubit, the swept knob is usually -the local oscillator feeding the readout line rather than an NCO the sequencer -owns, which makes it a `set_parameter` and moves the sweep host-side. See -[Running programs](../guide/execution.md). +To find the readout resonator rather than the qubit, the swept knob is usually the local oscillator feeding the readout line rather than an NCO the sequencer owns, which makes it a `set_parameter` and moves the sweep host-side. See [Running programs](../guide/execution.md). diff --git a/docs/examples/rabi.md b/docs/examples/rabi.md index 726dbc4..1519843 100644 --- a/docs/examples/rabi.md +++ b/docs/examples/rabi.md @@ -1,9 +1,6 @@ # Rabi oscillation -Sweeping the drive amplitude on a qubit and reading it out at every amplitude -gives the curve a pi-pulse amplitude is calibrated from. The program below is -the smallest one that uses the pieces almost every experiment needs: an -averaging block, one sweep, a pulse, a `sync`, and a measurement. +Sweeping the drive amplitude on a qubit and reading it out at every amplitude gives the curve a pi-pulse amplitude is calibrated from. The program below is the smallest one that uses the pieces almost every experiment needs: an averaging block, one sweep, a pulse, a `sync`, and a measurement. ## The program @@ -32,46 +29,17 @@ print(qp.dumps(program)) ### Why each piece is where it is -`qp.BusSchema.transmon()` declares one element, `q`, with two bus kinds: -`drive`, which is IQ, and `readout`, which is IQ and has an ADC. The schema -fixes no qubit count, so `q[0]` and `q[7]` both resolve, and the index appears -only in the resolved bus string `q0/drive`. Calling `measure` on `q[0].drive` -raises `ValidationError: Bus 'q0/drive' does not support acquisition -(acquires=False)`, because the check is against the `acquires` flag the schema -records per bus kind. - -`qp.Range(start=0.0, stop=1.0, step=0.01)` holds -`round((stop - start) / step) + 1` points, which is 101 here. A `Range` always -starts at `start` and lands on `stop` only when the step divides the span -evenly; `qp.Range(0.0, 1.0, 0.3)` ends at `0.9` instead. Use -`qp.Linspace(0.0, 1.0, num=101)` when the last point has to land on `stop` -whatever the arithmetic does. - -The averaging block is outside the sweep, so the whole amplitude ramp runs -1000 times over rather than 1000 shots being taken at one amplitude before -the next. Swapping the two `with` statements gives the other order and the -same result shape, because an `average` block never contributes a result -dimension; it decides only the order shots are taken in, which is what -averages out drift between the first and the last point of the ramp. - -`set_gain` scales the output of the drive bus, which is how the one -`"pi_pulse"` envelope gets reused at all 101 amplitudes. Putting the variable -inside the pulse instead is the other option, and the -[CZ chevron](cz-chevron.md) does that with its flux pulse: -`qp.waveforms.IQDrag(amplitude=gain, duration=40, sigma=8, beta=0.1)` -sweeps the envelope rather than the output stage. Which one a platform can -compile is a capability question, not a language one. - -`program.sync()` with no arguments aligns every bus the program has touched, -so readout does not start before the drive pulse has finished. Pass a list to -narrow it to those buses. Passing an empty list raises `ValidationError`, -since `sync([])` reads as either "sync nothing" or "sync everything". - -`measure` returns the `qp.MeasurementHandle` that `result.get` takes later. -Its name comes from the bus path plus a per-bus counter, so the first -measurement on `q0/readout` is named `q0/readout/m0`. -Handles compare by name rather than by identity, so a handle reconstructed -after a `.qp` round-trip still finds the right record. +`qp.BusSchema.transmon()` declares one element, `q`, with two bus kinds: `drive`, which is IQ, and `readout`, which is IQ and has an ADC. The schema fixes no qubit count, so `q[0]` and `q[7]` both resolve, and the index appears only in the resolved bus string `q0/drive`. Calling `measure` on `q[0].drive` raises `ValidationError: Bus 'q0/drive' does not support acquisition (acquires=False)`, because the check is against the `acquires` flag the schema records per bus kind. + +`qp.Range(start=0.0, stop=1.0, step=0.01)` holds `round((stop - start) / step) + 1` points, which is 101 here. A `Range` always starts at `start` and lands on `stop` only when the step divides the span evenly; `qp.Range(0.0, 1.0, 0.3)` ends at `0.9` instead. Use `qp.Linspace(0.0, 1.0, num=101)` when the last point has to land on `stop` whatever the arithmetic does. + +The averaging block is outside the sweep, so the whole amplitude ramp runs 1000 times over rather than 1000 shots being taken at one amplitude before the next. Swapping the two `with` statements gives the other order and the same result shape, because an `average` block never contributes a result dimension; it decides only the order shots are taken in, which is what averages out drift between the first and the last point of the ramp. + +`set_gain` scales the output of the drive bus, which is how the one `"pi_pulse"` envelope gets reused at all 101 amplitudes. Putting the variable inside the pulse instead is the other option, and the [CZ chevron](cz-chevron.md) does that with its flux pulse: `qp.waveforms.IQDrag(amplitude=gain, duration=40, sigma=8, beta=0.1)` sweeps the envelope rather than the output stage. Which one a platform can compile is a capability question, not a language one. + +`program.sync()` with no arguments aligns every bus the program has touched, so readout does not start before the drive pulse has finished. Pass a list to narrow it to those buses. Passing an empty list raises `ValidationError`, since `sync([])` reads as either "sync nothing" or "sync everything". + +`measure` returns the `qp.MeasurementHandle` that `result.get` takes later. Its name comes from the bus path plus a per-bus counter, so the first measurement on `q0/readout` is named `q0/readout/m0`. Handles compare by name rather than by identity, so a handle reconstructed after a `.qp` round-trip still finds the right record. ## What it produces @@ -100,18 +68,11 @@ body: measure q[0].readout "readout" "weights" name="q0/readout/m0" ``` -The `schema:` block records the element and its bus kinds, not the qubits the -program touched, which is why `q[0]` appears in the body but nowhere in the -schema. Bus references are written as the compact `q[0].drive` path and -resolve back through the schema on load. The writer spells out every sweep -source argument by keyword and emits `name=` on every measurement even when -the name was auto-allocated, so `qp.loads(qp.dumps(program))` gets the same -handle names back rather than reallocating them. +The `schema:` block records the element and its bus kinds, not the qubits the program touched, which is why `q[0]` appears in the body but nowhere in the schema. Bus references are written as the compact `q[0].drive` path and resolve back through the schema on load. The writer spells out every sweep source argument by keyword and emits `name=` on every measurement even when the name was auto-allocated, so `qp.loads(qp.dumps(program))` gets the same handle names back rather than reallocating them. ## Plugging in calibration data -`"pi_pulse"`, `"readout"`, and `"weights"` are aliases. Resolving them -produces a second program: +`"pi_pulse"`, `"readout"`, and `"weights"` are aliases. Resolving them produces a second program: ```python resolved = program.with_waveforms( @@ -123,23 +84,13 @@ resolved = program.with_waveforms( ) ``` -The original `program` keeps its aliases; `resolved` is a deep copy with each -matching name replaced. A name with no entry in the mapping passes through as -a string, so a partial library is not an error. Each replacement re-runs the -channel check, which is where an IQ pulse aimed at a single-channel bus is -caught: `ValidationError: Bus 'q0/flux' is a single channel but received an -IQWaveform (IQPair)`. +The original `program` keeps its aliases; `resolved` is a deep copy with each matching name replaced. A name with no entry in the mapping passes through as a string, so a partial library is not an error. Each replacement re-runs the channel check, which is where an IQ pulse aimed at a single-channel bus is caught: `ValidationError: Bus 'q0/flux' is a single channel but received an IQWaveform (IQPair)`. -A plain dict is one global tier, so `"pi_pulse"` resolves to the same pulse on -every bus. Pass a `qp.WaveformLibrary` instead when a name has to mean -different things on different qubits. +A plain dict is one global tier, so `"pi_pulse"` resolves to the same pulse on every bus. Pass a `qp.WaveformLibrary` instead when a name has to mean different things on different qubits. ## Running it -`qp.simulate` builds a throwaway `qp.ReferencePlatform` and executes on it. -The result shapes are the ones a hardware platform's `execute()` has to -produce as well, since vendor compilers are tested against what this executor -returns: +`qp.simulate` builds a throwaway `qp.ReferencePlatform` and executes on it. The result shapes are the ones a hardware platform's `execute()` has to produce as well, since vendor compilers are tested against what this executor returns: ```python result = qp.simulate(resolved) @@ -151,20 +102,11 @@ data.coords["gain"] # 0.00, 0.01, ..., 1.00 data.coords["IQ"] # ["I", "Q"] ``` -One dimension per enclosing sweep, named after the variable id and ordered -outermost first, then a trailing `"IQ"` dimension of length 2. `average` is -absent from the dims because the executor accumulates over shots and divides -by the per-point shot count. +One dimension per enclosing sweep, named after the variable id and ordered outermost first, then a trailing `"IQ"` dimension of length 2. `average` is absent from the dims because the executor accumulates over shots and divides by the per-point shot count. -The reference executor runs no timing simulation: `play`, `sync`, and the -bus-tuning operations (`set_gain`, `set_frequency`, `set_phase`, `set_offset`) -evaluate their expressions and are otherwise no-ops. That is why passing the -unresolved `program` here works too, and why `with_waveforms` is a requirement -of hardware platforms rather than of `qp.simulate`. +The reference executor runs no timing simulation: `play`, `sync`, and the bus-tuning operations (`set_gain`, `set_frequency`, `set_phase`, `set_offset`) evaluate their expressions and are otherwise no-ops. That is why passing the unresolved `program` here works too, and why `with_waveforms` is a requirement of hardware platforms rather than of `qp.simulate`. -It also means the IQ values are whatever the measurement model says. The -default model responds `0j` to everything, so `data` comes back as an array -of exactly `0.0`. Shape the response to see a curve: +It also means the IQ values are whatever the measurement model says. The default model responds `0j` to everything, so `data` comes back as an array of exactly `0.0`. Shape the response to see a curve: ```python import numpy as np @@ -176,14 +118,9 @@ model = qp.MockMeasurementModel( result = qp.simulate(resolved, model=model) ``` -`env` holds the currently bound loop variables by id, plus the platform -parameter store keyed `"bus.parameter"`, so a response can depend on any of -them. `noise` is the standard deviation of the gaussian added per quadrature -per shot; the model draws from one seeded generator, so a fresh model on the -same program gives the same numbers every time. +`env` holds the currently bound loop variables by id, plus the platform parameter store keyed `"bus.parameter"`, so a response can depend on any of them. `noise` is the standard deviation of the gaussian added per quadrature per shot; the model draws from one seeded generator, so a fresh model on the same program gives the same numbers every time. -To get the raw ADC trace as well, ask for it where the measurement is built, -by replacing the `measure` call in the program with this one: +To get the raw ADC trace as well, ask for it where the measurement is built, by replacing the `measure` call in the program with this one: ```python m0 = program.measure( @@ -202,67 +139,36 @@ raw.dims # ("gain", "time", "IQ") raw.shape # (101, 16, 2) ``` -`result.get` returns the `IQ` field unless another one is named, and asking -for a field the measurement did not request raises `KeyError: Measurement -'q0/readout/m0' has no field 'state'; available: iq, raw` rather than -substituting a different array. The 16 time samples are the default of -`MockMeasurementModel`'s `raw_samples` argument, which a real ADC record would -replace. +`result.get` returns the `IQ` field unless another one is named, and asking for a field the measurement did not request raises `KeyError: Measurement 'q0/readout/m0' has no field 'state'; available: iq, raw` rather than substituting a different array. The 16 time samples are the default of `MockMeasurementModel`'s `raw_samples` argument, which a real ADC record would replace. ## Plotting -Plotting needs matplotlib, which is not a runtime dependency of the package. -It comes with the `viz` extra: +Plotting needs matplotlib, which is not a runtime dependency of the package. It comes with the `viz` extra: ```bash pip install "qprogram[viz]" ``` -`result.plot` works the figure out from the array's shape. One swept dimension -besides `IQ` gives a line per quadrature: +`result.plot` works the figure out from the array's shape. One swept dimension besides `IQ` gives a line per quadrature: ```python result.plot(m0, value=qp.plotting.Quantity("Readout response")) ``` -![Readout response against drive amplitude. I rises to a maximum of 1 at 0.5 V and falls back to 0 by 1.0 V, while Q stays flat at 0.](../assets/plots/rabi-light.png#only-light) -![Readout response against drive amplitude. I rises to a maximum of 1 at 0.5 V and falls back to 0 by 1.0 V, while Q stays flat at 0.](../assets/plots/rabi-dark.png#only-dark) +![Readout response against drive amplitude. I rises to a maximum of 1 at 0.5 V and falls back to 0 by 1.0 V, while Q stays flat at 0.](../assets/plots/rabi-light.png#only-light) ![Readout response against drive amplitude. I rises to a maximum of 1 at 0.5 V and falls back to 0 by 1.0 V, while Q stays flat at 0.](../assets/plots/rabi-dark.png#only-dark) -Nothing about the x axis is typed out. The `label` and `units` given to -`program.variable` reach the coordinate as its `long_name` and `units` -attributes, and the axis reads them: +Nothing about the x axis is typed out. The `label` and `units` given to `program.variable` reach the coordinate as its `long_name` and `units` attributes, and the axis reads them: ```python data.coords["gain"].attrs # {"long_name": "Drive amplitude", "units": "V"} ``` -`value=` is there because the other axis has no such source: what a demodulated -point means is the readout chain's business, not the program's. A -`qp.plotting.Quantity` is also how a coordinate gets restated for the figure, -in the units you want to read it in. The call returns the matplotlib `Axes`, so -anything else the figure does not decide is a method away on it. -[Plotting results](../guide/plotting.md) has the rest: heatmaps and scatters, -the `channels` argument, themes, and registering a renderer of your own. +`value=` is there because the other axis has no such source: what a demodulated point means is the readout chain's business, not the program's. A `qp.plotting.Quantity` is also how a coordinate gets restated for the figure, in the units you want to read it in. The call returns the matplotlib `Axes`, so anything else the figure does not decide is a method away on it. [Plotting results](../guide/plotting.md) has the rest: heatmaps and scatters, the `channels` argument, themes, and registering a renderer of your own. ## Adapting it -For a chip that is not a fixed-frequency transmon, change the schema. The -presets are `qp.BusSchema.transmon`, `transmon_coupled`, -`flux_tunable_transmon`, `flux_tunable_transmon_coupled`, `fluxonium`, and -`fluxonium_coupled`; `BusSchema.add_element` builds one at runtime, and -subclassing `BusSchema` gives typed accessors. See -[Buses and schemas](../guide/buses.md). - -To sweep frequency as well, add `program.set_frequency(q[0].drive, freq)` and -a second sweep. Nesting the two gives the full grid and a two-dimensional -result; composing them with `|` advances them in lockstep and gives one -`"gain|freq"` dimension carrying both coordinates, which `plot` draws as one -axis and a twin axis above it. Both loops must then have the same length. See -[Control flow](../guide/control-flow.md). - -To read the classified state instead of the IQ point, request -`fields=(qp.MeasurementField.STATE,)` and read -`result.get(m0, field=qp.MeasurementField.STATE)`, which has the sweep dims -and no trailing `"IQ"`. Under averaging it holds the excited-state population -rather than a single 0 or 1. See -[Measurements and results](../guide/measurements.md). +For a chip that is not a fixed-frequency transmon, change the schema. The presets are `qp.BusSchema.transmon`, `transmon_coupled`, `flux_tunable_transmon`, `flux_tunable_transmon_coupled`, `fluxonium`, and `fluxonium_coupled`; `BusSchema.add_element` builds one at runtime, and subclassing `BusSchema` gives typed accessors. See [Buses and schemas](../guide/buses.md). + +To sweep frequency as well, add `program.set_frequency(q[0].drive, freq)` and a second sweep. Nesting the two gives the full grid and a two-dimensional result; composing them with `|` advances them in lockstep and gives one `"gain|freq"` dimension carrying both coordinates, which `plot` draws as one axis and a twin axis above it. Both loops must then have the same length. See [Control flow](../guide/control-flow.md). + +To read the classified state instead of the IQ point, request `fields=(qp.MeasurementField.STATE,)` and read `result.get(m0, field=qp.MeasurementField.STATE)`, which has the sweep dims and no trailing `"IQ"`. Under averaging it holds the excited-state population rather than a single 0 or 1. See [Measurements and results](../guide/measurements.md). diff --git a/docs/examples/resonator-spectroscopy.md b/docs/examples/resonator-spectroscopy.md index bfe0904..3b88c62 100644 --- a/docs/examples/resonator-spectroscopy.md +++ b/docs/examples/resonator-spectroscopy.md @@ -1,19 +1,8 @@ # Resonator spectroscopy -Finding the readout resonator is the first measurement made on a new chip: -sweep the frequency of the tone sent down the readout line and watch where the -transmitted amplitude dips. Until that dip is located there is no readout, and -without readout none of the other pages have a measurement to make. The program -is the shortest one in this section, two operations inside two blocks. - -What it is here to show is not its length. Every other example sweeps something -the sequencer owns, a gain register or an NCO word or a waveform parameter, and -the whole program runs in real time. A local oscillator is not that. It is a -synthesizer reprogrammed over a control interface, so retuning it is platform -configuration rather than an instruction in a pulse sequence, and a sweep that -retunes one per iteration cannot run on the sequencer at all. That fact -propagates outward through the program, and this page is about reading where it -went and rewriting the program so it costs less. +Finding the readout resonator is the first measurement made on a new chip: sweep the frequency of the tone sent down the readout line and watch where the transmitted amplitude dips. Until that dip is located there is no readout, and without readout none of the other pages have a measurement to make. The program is the shortest one in this section, two operations inside two blocks. + +What it is here to show is not its length. Every other example sweeps something the sequencer owns, a gain register or an NCO word or a waveform parameter, and the whole program runs in real time. A local oscillator is not that. It is a synthesizer reprogrammed over a control interface, so retuning it is platform configuration rather than an instruction in a pulse sequence, and a sweep that retunes one per iteration cannot run on the sequencer at all. That fact propagates outward through the program, and this page is about reading where it went and rewriting the program so it costs less. ## The program @@ -38,25 +27,11 @@ with program.average(shots=1000): ### Why each piece is where it is -`set_parameter(bus, parameter, value)` writes a bus-scoped parameter. It is the -operation for things a platform holds as configuration rather than as sequencer -state, and platforms expose it host-side only for that reason. The parameter -name is a free string that nothing validates at build time or at run time: -`"lo_frequency"` means whatever the platform decides it means, and a typo -becomes a parameter the platform has never heard of rather than an error. - -There is no `play` in the loop. `measure` plays the readout waveform and -acquires against the weights itself, so a resonator sweep needs nothing else, -and no `sync` either since only one bus is involved. This is a one-tone -measurement, which is what separates it from the two-tone -[qubit spectroscopy](qubit-spectroscopy.md) scan: there the drive tone -interrogates the qubit and a separate readout reports on it, while here the -readout tone is both the probe and the measurement. - -The window covers 400 MHz because a resonator's position is known only to -within the spread of the fabrication run, and 101 points put a sample every -4 MHz, comfortably finer than a linewidth of a few hundred kHz would need but -coarse enough to find the dip in one pass. The fine scan comes after. +`set_parameter(bus, parameter, value)` writes a bus-scoped parameter. It is the operation for things a platform holds as configuration rather than as sequencer state, and platforms expose it host-side only for that reason. The parameter name is a free string that nothing validates at build time or at run time: `"lo_frequency"` means whatever the platform decides it means, and a typo becomes a parameter the platform has never heard of rather than an error. + +There is no `play` in the loop. `measure` plays the readout waveform and acquires against the weights itself, so a resonator sweep needs nothing else, and no `sync` either since only one bus is involved. This is a one-tone measurement, which is what separates it from the two-tone [qubit spectroscopy](qubit-spectroscopy.md) scan: there the drive tone interrogates the qubit and a separate readout reports on it, while here the readout tone is both the probe and the measurement. + +The window covers 400 MHz because a resonator's position is known only to within the spread of the fabrication run, and 101 points put a sample every 4 MHz, comfortably finer than a linewidth of a few hundred kHz would need but coarse enough to find the dip in one pass. The fine scan comes after. ## What the platform makes of it @@ -81,9 +56,7 @@ body: measure q[0].readout "readout" "weights" name="q0/readout/m0" ``` -Nothing in that file says anything about domains. Where each node can run is a -question about a platform, so it is answered by `qp.validate` and rendered by -`qp.explain` against a capability descriptor: +Nothing in that file says anything about domains. Where each node can run is a question about a platform, so it is answered by `qp.validate` and rendered by `qp.explain` against a capability descriptor: ```python caps = qp.reference_capabilities() @@ -99,26 +72,13 @@ body └─ measure q[0].readout "readout" "weights" name="q0/readout/m0" [rt|host] ``` -The measurement is `[rt|host]`, so the sequencer could run it. Everything above -it is `[host]`, and the reason is a chain: the `set_parameter` is host-side -only, which makes the sweep containing it host-side only, which makes the -average containing that host-side only. The `forced-host` warning is emitted -once, on the outermost block of that chain, because reporting it on all three -would say the same thing three times. +The measurement is `[rt|host]`, so the sequencer could run it. Everything above it is `[host]`, and the reason is a chain: the `set_parameter` is host-side only, which makes the sweep containing it host-side only, which makes the average containing that host-side only. The `forced-host` warning is emitted once, on the outermost block of that chain, because reporting it on all three would say the same thing three times. -The cost is in the innermost block that got dragged. An averaging block that -runs host-side is a thousand separate acquisitions per sweep point, each with -its own round trip between the host and the instrument, where a real-time -average is one instruction the sequencer executes a thousand times. See -[Capabilities, diagnostics, and profiles](../guide/capabilities.md) for how the -domain of a block is decided and what the other diagnostic codes mean. +The cost is in the innermost block that got dragged. An averaging block that runs host-side is a thousand separate acquisitions per sweep point, each with its own round trip between the host and the instrument, where a real-time average is one instruction the sequencer executes a thousand times. See [Capabilities, diagnostics, and profiles](../guide/capabilities.md) for how the domain of a block is decided and what the other diagnostic codes mean. ## Rewriting it with qp.optimize -The `reorderable-averaging` hint on the same row names the fix. The sweep has -to stay host-side, since the LO is what it is, but it does not have to be -*inside* the average. Turn the nesting inside out and the averaging is back on -the sequencer: +The `reorderable-averaging` hint on the same row names the fix. The sweep has to stay host-side, since the LO is what it is, but it does not have to be *inside* the average. Turn the nesting inside out and the averaging is back on the sequencer: ```python optimized = qp.optimize(program, caps) @@ -134,26 +94,11 @@ body: measure q[0].readout "readout" "weights" name="q0/readout/m0" ``` -`qp.optimize(program, caps)` returns a new program and never touches the one it -was given. The rewrite lifts the sweep to the outside, hoists the leading run -of host-side-only operations along with it, and leaves the rest inside a fresh -average. Re-explaining the result reports `errors: 0 · warnings: 0 · info: 0`, -with the average now `[rt|host]` and only the sweep and the parameter write -still `[host]`. - -It is opt-in rather than automatic because it is not unconditionally -equivalent. It groups all thousand shots of one frequency together instead of -interleaving passes over the whole window, which averages out drift differently -and is identical only for a system that is not drifting. It also moves each -hoisted operation from once per shot to once per sweep point, which is -harmless for an idempotent parameter write and is not harmless in general. The -rewrite protects itself by only ever hoisting a leading contiguous run, never -an operation that sits after one it is keeping, since that would reorder the -two. - -The pattern it matches is narrow, and it is worth seeing it decline. Nest a -second sweep inside the first and the average is still forced host-side, but -the hint is gone and `qp.optimize` returns the program unchanged: +`qp.optimize(program, caps)` returns a new program and never touches the one it was given. The rewrite lifts the sweep to the outside, hoists the leading run of host-side-only operations along with it, and leaves the rest inside a fresh average. Re-explaining the result reports `errors: 0 · warnings: 0 · info: 0`, with the average now `[rt|host]` and only the sweep and the parameter write still `[host]`. + +It is opt-in rather than automatic because it is not unconditionally equivalent. It groups all thousand shots of one frequency together instead of interleaving passes over the whole window, which averages out drift differently and is identical only for a system that is not drifting. It also moves each hoisted operation from once per shot to once per sweep point, which is harmless for an idempotent parameter write and is not harmless in general. The rewrite protects itself by only ever hoisting a leading contiguous run, never an operation that sits after one it is keeping, since that would reorder the two. + +The pattern it matches is narrow, and it is worth seeing it decline. Nest a second sweep inside the first and the average is still forced host-side, but the hint is gone and `qp.optimize` returns the program unchanged: ```python with program.average(shots=1000): @@ -165,17 +110,11 @@ with program.average(shots=1000): # [warning] forced-host: ... and no reorderable-averaging hint ``` -The average's sole child must be one flat sweep whose body holds no nested -block. The hint and the rewrite share the same predicate, so the two can never -disagree: an absent hint means an absent rewrite, and reading the diagnostics -is how you find out that calling `optimize` did nothing. +The average's sole child must be one flat sweep whose body holds no nested block. The hint and the rewrite share the same predicate, so the two can never disagree: an absent hint means an absent rewrite, and reading the diagnostics is how you find out that calling `optimize` did nothing. ## Running it -The parameter store lives on the platform rather than in the program, so this -is the one example that builds a `qp.ReferencePlatform` directly instead of -going through `qp.simulate`. Keeping the platform is what lets the writes be -read back afterwards: +The parameter store lives on the platform rather than in the program, so this is the one example that builds a `qp.ReferencePlatform` directly instead of going through `qp.simulate`. Keeping the platform is what lets the writes be read back afterwards: ```python import warnings @@ -206,40 +145,19 @@ result.get(m0).shape # (101, 2) platform.parameters # {"q0/readout.lo_frequency": 7400000000.0} ``` -![Transmitted magnitude against readout LO frequency, flat near 1 except for a sharp dip to 0 at 7.200 GHz.](../assets/plots/resonator-spectroscopy-light.png#only-light) -![Transmitted magnitude against readout LO frequency, flat near 1 except for a sharp dip to 0 at 7.200 GHz.](../assets/plots/resonator-spectroscopy-dark.png#only-dark) - -The dip is the resonator, and its centre is the frequency the readout pulse -wants to be at. - -A swept parameter reaches the model differently from a swept variable. The -earlier pages read `env["freq"]` or `env["delay"]`, the id of the loop -variable; a parameter write puts its value in the same `env` under -`"bus.parameter"`, so the model reads `env["q0/readout.lo_frequency"]`. Both -are available at once, and here the loop variable `env["lo"]` holds the same -number, but the parameter key is what a model should use, because it reports -what the instrument was actually configured to rather than what a loop -happened to be carrying. - -`platform.parameters` survives the run and holds the last value written, which -is `7.4e9`, the end of the sweep. The store is copied at construction and then -read by `get_parameter`, written by `set_parameter`, and passed to the model, -so writes accumulate across successive `execute` calls on the same platform. -`qp.simulate` builds a throwaway platform and discards it, which is why it is -the wrong entry point when the parameter store is part of what you are looking -at. - -The `forced-host` warning is re-emitted at execution as a `qp.ExecutionWarning` -rather than being raised, since the program does run, just not the way it was -written. Filtering it, as above, is reasonable once you have read it; leaving -it unfiltered is better while the program is still changing. Errors are not -handled this way: an error diagnostic raises `UnsupportedOperationError` and -nothing executes. +![Transmitted magnitude against readout LO frequency, flat near 1 except for a sharp dip to 0 at 7.200 GHz.](../assets/plots/resonator-spectroscopy-light.png#only-light) ![Transmitted magnitude against readout LO frequency, flat near 1 except for a sharp dip to 0 at 7.200 GHz.](../assets/plots/resonator-spectroscopy-dark.png#only-dark) + +The dip is the resonator, and its centre is the frequency the readout pulse wants to be at. + +A swept parameter reaches the model differently from a swept variable. The earlier pages read `env["freq"]` or `env["delay"]`, the id of the loop variable; a parameter write puts its value in the same `env` under `"bus.parameter"`, so the model reads `env["q0/readout.lo_frequency"]`. Both are available at once, and here the loop variable `env["lo"]` holds the same number, but the parameter key is what a model should use, because it reports what the instrument was actually configured to rather than what a loop happened to be carrying. + +`platform.parameters` survives the run and holds the last value written, which is `7.4e9`, the end of the sweep. The store is copied at construction and then read by `get_parameter`, written by `set_parameter`, and passed to the model, so writes accumulate across successive `execute` calls on the same platform. `qp.simulate` builds a throwaway platform and discards it, which is why it is the wrong entry point when the parameter store is part of what you are looking at. + +The `forced-host` warning is re-emitted at execution as a `qp.ExecutionWarning` rather than being raised, since the program does run, just not the way it was written. Filtering it, as above, is reasonable once you have read it; leaving it unfiltered is better while the program is still changing. Errors are not handled this way: an error diagnostic raises `UnsupportedOperationError` and nothing executes. ## Adapting it -To read a parameter rather than write one, `get_parameter` returns a freshly -declared variable the runtime fills in: +To read a parameter rather than write one, `get_parameter` returns a freshly declared variable the runtime fills in: ```python current = program.get_parameter(q[0].readout, "lo_frequency") @@ -247,19 +165,8 @@ current = program.get_parameter(q[0].readout, "lo_frequency") # get_parameter q[0].readout "lo_frequency" -> q0_readout_lo_frequency ``` -The variable id is derived from the bus and parameter with the non-word -characters replaced, and the original dotted form is kept as the label. From -there it is an ordinary variable and can be used in any expression. - -A punchout measurement adds a power axis outside the frequency one and watches -the dip move as the resonator is driven past its critical photon number. That -is the nested shape `qp.optimize` declines, which is the honest trade: the -second axis costs the real-time averaging back. - -The same experiment is not host-side on every instrument. A platform whose -readout chain has its own NCO can sweep the tone with `set_frequency` instead -of retuning an LO, and then nothing here happens at all: the sweep is -`[rt|host]`, the average never falls back, and there is no rewrite to apply. -Which of the two a program should be written against is a capability question -you can ask before running anything, with -[`qp.explain`](../guide/capabilities.md) against that platform's descriptor. +The variable id is derived from the bus and parameter with the non-word characters replaced, and the original dotted form is kept as the label. From there it is an ordinary variable and can be used in any expression. + +A punchout measurement adds a power axis outside the frequency one and watches the dip move as the resonator is driven past its critical photon number. That is the nested shape `qp.optimize` declines, which is the honest trade: the second axis costs the real-time averaging back. + +The same experiment is not host-side on every instrument. A platform whose readout chain has its own NCO can sweep the tone with `set_frequency` instead of retuning an LO, and then nothing here happens at all: the sweep is `[rt|host]`, the average never falls back, and there is no rewrite to apply. Which of the two a program should be written against is a capability question you can ask before running anything, with [`qp.explain`](../guide/capabilities.md) against that platform's descriptor. diff --git a/docs/examples/single-shot-readout.md b/docs/examples/single-shot-readout.md index 3fe9242..8195154 100644 --- a/docs/examples/single-shot-readout.md +++ b/docs/examples/single-shot-readout.md @@ -1,17 +1,8 @@ # Single-shot readout -Averaging is what every other page here does, and it is what hides the question -this one asks. A Rabi curve at 0.5 excited population is the same curve whether -each shot lands cleanly in one of two blobs or whether the two blobs overlap so -badly that half the classifications are guesses. Telling those apart means -keeping the shots: prepare the ground state a few thousand times, prepare the -excited state a few thousand times, and look at where the individual points -land. - -This is the only program in this section with no `average` block. That one -absence changes how the result is shaped, forces the shot index to be something -the program says out loud, and makes the measurement model worth writing by -hand rather than reaching for `qp.MockMeasurementModel`. +Averaging is what every other page here does, and it is what hides the question this one asks. A Rabi curve at 0.5 excited population is the same curve whether each shot lands cleanly in one of two blobs or whether the two blobs overlap so badly that half the classifications are guesses. Telling those apart means keeping the shots: prepare the ground state a few thousand times, prepare the excited state a few thousand times, and look at where the individual points land. + +This is the only program in this section with no `average` block. That one absence changes how the result is shaped, forces the shot index to be something the program says out loud, and makes the measurement model worth writing by hand rather than reaching for `qp.MockMeasurementModel`. ## The program @@ -46,32 +37,15 @@ with program.sweep(prepared, qp.Values([0, 1])): ### Why each piece is where it is -Only a sweep contributes a result dimension. An `average` block deliberately -does not, since its whole job is to collapse the shots it encloses, and neither -does a plain `block` or a conditional. So a program that wants to keep its -shots cannot use `average` at all, and the shot index has to be a real sweep. - -That is the awkward part of this page and worth naming rather than hiding. -`shot` is a variable no operation ever reads. It exists to give the inner loop -something to bind so that the loop contributes an axis, and it lands in the -file as a `var` declaration and a 2000-point ramp that a compiler has to treat -as a real value table. Nothing objects to it: `qp.validate` returns no -diagnostics and the run emits no warning. - -`prepared` earns its keep, though. `qp.Values([0, 1])` is a two-point -categorical axis rather than a ramp, and `set_gain(q[0].drive, prepared)` turns -it into the preparation: at 0 the drive output is scaled to nothing and the -`"pi_pulse"` leaves the qubit in the ground state, at 1 it plays at full -amplitude and inverts it. One pulse, one gain, two preparations. - -The measurement is given an explicit `name="shots"` rather than taking the -auto-allocated `q0/readout/m0`. With one measurement in the program the name is -a convenience, but it is the name the result is read back by, and a name that -says what the record is survives refactoring better than a positional one. - -Nesting order matters here in a way it does not around an `average`. The outer -sweep becomes the first dimension, so swapping the two `with` statements -transposes the result from `(2, 2000)` to `(2000, 2)` rather than being free. +Only a sweep contributes a result dimension. An `average` block deliberately does not, since its whole job is to collapse the shots it encloses, and neither does a plain `block` or a conditional. So a program that wants to keep its shots cannot use `average` at all, and the shot index has to be a real sweep. + +That is the awkward part of this page and worth naming rather than hiding. `shot` is a variable no operation ever reads. It exists to give the inner loop something to bind so that the loop contributes an axis, and it lands in the file as a `var` declaration and a 2000-point ramp that a compiler has to treat as a real value table. Nothing objects to it: `qp.validate` returns no diagnostics and the run emits no warning. + +`prepared` earns its keep, though. `qp.Values([0, 1])` is a two-point categorical axis rather than a ramp, and `set_gain(q[0].drive, prepared)` turns it into the preparation: at 0 the drive output is scaled to nothing and the `"pi_pulse"` leaves the qubit in the ground state, at 1 it plays at full amplitude and inverts it. One pulse, one gain, two preparations. + +The measurement is given an explicit `name="shots"` rather than taking the auto-allocated `q0/readout/m0`. With one measurement in the program the name is a convenience, but it is the name the result is read back by, and a name that says what the record is survives refactoring better than a positional one. + +Nesting order matters here in a way it does not around an `average`. The outer sweep becomes the first dimension, so swapping the two `with` statements transposes the result from `(2, 2000)` to `(2000, 2)` rather than being free. ## What it produces @@ -99,18 +73,11 @@ body: measure q[0].readout "readout" "weights" name="shots" fields=["state", "iq"] ``` -`qp.Values` writes as the bare list `[0.0, 1.0]`, which is the format's sugar -for it, and the integers become floats because a sweep source stores its points -that way. The explicit name appears in `name=` exactly as the auto-allocated -ones do on every other page, since the writer emits the attribute either way. +`qp.Values` writes as the bare list `[0.0, 1.0]`, which is the format's sugar for it, and the integers become floats because a sweep source stores its points that way. The explicit name appears in `name=` exactly as the auto-allocated ones do on every other page, since the writer emits the attribute either way. ## Writing a measurement model -`qp.MockMeasurementModel` puts every shot at one point and adds noise around -it, which is the right model for a curve and the wrong one for a blob. What -this page needs is a model where the classified state is a function of where -the shot actually landed, so that misclassification is something the run -produces rather than something imposed on it. +`qp.MockMeasurementModel` puts every shot at one point and adds noise around it, which is the right model for a curve and the wrong one for a blob. What this page needs is a model where the classified state is a function of where the shot actually landed, so that misclassification is something the run produces rather than something imposed on it. `qp.MeasurementModel` is a protocol with one method: @@ -130,22 +97,11 @@ class ReadoutModel: return qp.MeasurementSample(i=i, q=qv, state=int(i > self.separation / 2)) ``` -`sample` receives the bus string and the environment, which holds the bound -loop variables by id plus any platform parameters. It does not receive the -measurement's name, so a model cannot answer two measurements on the same bus -differently; where that matters, the distinction has to come through `env`. +`sample` receives the bus string and the environment, which holds the bound loop variables by id plus any platform parameters. It does not receive the measurement's name, so a model cannot answer two measurements on the same bus differently; where that matters, the distinction has to come through `env`. -`qp.MeasurementSample` carries four fields, of which only `i`, `q`, and `state` -have to be given. `raw` defaults to an empty `(0, 2)` array, which is what a -model with no ADC to simulate wants, and it is read only by a measurement that -requests `MeasurementField.RAW`. Asking for that field from this model raises, -naming the shape it got and the shape it wanted, rather than broadcasting the -empty trace into the accumulator. Declaring `raw_samples` on the model and -returning a trace of shape `(raw_samples, 2)` is what makes the field available. +`qp.MeasurementSample` carries four fields, of which only `i`, `q`, and `state` have to be given. `raw` defaults to an empty `(0, 2)` array, which is what a model with no ADC to simulate wants, and it is read only by a measurement that requests `MeasurementField.RAW`. Asking for that field from this model raises, naming the shape it got and the shape it wanted, rather than broadcasting the empty trace into the accumulator. Declaring `raw_samples` on the model and returning a trace of shape `(raw_samples, 2)` is what makes the field available. -The threshold at half the separation is what makes the state a classification -rather than a label: a ground-state shot that scatters past it is recorded as -excited, which is exactly the error the experiment measures. +The threshold at half the separation is what makes the state a classification rather than a label: a ground-state shot that scatters past it is recorded as excited, which is exactly the error the experiment measures. ## Reading the shots @@ -167,9 +123,7 @@ state.dims # ("prepared", "shot") state.shape # (2, 2000) ``` -Three dimensions where the other pages have two, and the middle one is the -shot. Nothing was averaged: each entry is one measurement of one shot, because -the executor divides by a per-point shot count that is 1 everywhere here. +Three dimensions where the other pages have two, and the middle one is the shot. Nothing was averaged: each entry is one measurement of one shot, because the executor divides by a per-point shot count that is 1 everywhere here. The two error rates and the fidelity fall straight out of the state array: @@ -180,14 +134,9 @@ true_excited = v[1].mean() # 0.975 fidelity = 1 - (false_excited + (1 - true_excited)) / 2 # 0.9775 ``` -The state field is `float64`, not an integer, because it goes through the same -divide-by-shot-count path as everything else even when that count is 1. The -values are exactly 0.0 and 1.0 so comparisons work, but `v[1, 0] is 1` is -`False`, and anything that wants an index or a bin count needs -`.astype(int)` first. +The state field is `float64`, not an integer, because it goes through the same divide-by-shot-count path as everything else even when that count is 1. The values are exactly 0.0 and 1.0 so comparisons work, but `v[1, 0] is 1` is `False`, and anything that wants an index or a bin count needs `.astype(int)` first. -Plotting the two blobs is what the page is for, and the IQ array is already -laid out for it: +Plotting the two blobs is what the page is for, and the IQ array is already laid out for it: ```python import matplotlib.pyplot as plt @@ -198,44 +147,26 @@ plt.axvline(2.0, color="k", lw=0.5) # the classifier threshold plt.legend() ``` -![Scatter of single shots in the IQ plane: two well-separated gaussian blobs for the ground and excited preparations, split by a threshold at I = 2.](../assets/plots/single-shot-readout-light.png#only-light) -![Scatter of single shots in the IQ plane: two well-separated gaussian blobs for the ground and excited preparations, split by a threshold at I = 2.](../assets/plots/single-shot-readout-dark.png#only-dark) +![Scatter of single shots in the IQ plane: two well-separated gaussian blobs for the ground and excited preparations, split by a threshold at I = 2.](../assets/plots/single-shot-readout-light.png#only-light) ![Scatter of single shots in the IQ plane: two well-separated gaussian blobs for the ground and excited preparations, split by a threshold at I = 2.](../assets/plots/single-shot-readout-dark.png#only-dark) -`result.plot(shots, kind="scatter")` draws the same plane in one call, but as a -single cloud: two colours by prepared state and a line at the threshold are a -layout, and nothing on the result says those three things belong in one figure. -[Plotting results](../guide/plotting.md) draws the line between what `plot` -infers and what stays here. +`result.plot(shots, kind="scatter")` draws the same plane in one call, but as a single cloud: two colours by prepared state and a line at the threshold are a layout, and nothing on the result says those three things belong in one figure. [Plotting results](../guide/plotting.md) draws the line between what `plot` infers and what stays here. -Four thousand shots run in well under a tenth of a second, so this is the -cheapest program in the section despite having the most records. The -combination to be careful with is not the shot count but the shot count times -`MeasurementField.RAW`, which allocates a trace per shot rather than per -averaged point. +Four thousand shots run in well under a tenth of a second, so this is the cheapest program in the section despite having the most records. The combination to be careful with is not the shot count but the shot count times `MeasurementField.RAW`, which allocates a trace per shot rather than per averaged point. ## Adapting it -To find the threshold rather than assume it, drop `MeasurementField.STATE` and -classify the IQ array yourself. The state field is whatever the model or the -instrument decided; the I and Q values are the evidence, and fitting two -gaussians to them is what produces a threshold in the first place. On hardware -this is the ordering that matters, since the discriminator has to be calibrated -before it can be trusted. +To find the threshold rather than assume it, drop `MeasurementField.STATE` and classify the IQ array yourself. The state field is whatever the model or the instrument decided; the I and Q values are the evidence, and fitting two gaussians to them is what produces a threshold in the first place. On hardware this is the ordering that matters, since the discriminator has to be calibrated before it can be trusted. -To see how few dimensions a result can have, take the sweeps away. A -measurement with no enclosing loop at all gives a scalar: +To see how few dimensions a result can have, take the sweeps away. A measurement with no enclosing loop at all gives a scalar: ```python result.get(handle, field=qp.MeasurementField.STATE).dims # () result.get(handle, field=qp.MeasurementField.STATE).shape # () ``` -That is the floor: one number, no axes. Asking the same measurement for `IQ` -gives `("IQ",)` and shape `(2,)`, since the quadrature pair is a dimension the -field carries rather than one a loop produced. +That is the floor: one number, no axes. Asking the same measurement for `IQ` gives `("IQ",)` and shape `(2,)`, since the quadrature pair is a dimension the field carries rather than one a loop produced. -`qp.Repeat` looks like a way to get shots without the unused variable and is -not. It multiplies a source's points instead of adding an axis: +`qp.Repeat` looks like a way to get shots without the unused variable and is not. It multiplies a source's points instead of adding an axis: ```python with program.sweep(prepared, qp.Repeat(qp.Values([0, 1]), times=4)): @@ -243,12 +174,6 @@ with program.sweep(prepared, qp.Repeat(qp.Values([0, 1]), times=4)): # dims ("prepared",), shape (8,), coords [0. 1. 0. 1. 0. 1. 0. 1.] ``` -One flattened axis with repeated coordinates, which is a different thing from a -shot dimension and gives you no way to select a single repetition. If you want -the shots separated, the inner sweep is the way. +One flattened axis with repeated coordinates, which is a different thing from a shot dimension and gives you no way to select a single repetition. If you want the shots separated, the inner sweep is the way. -To measure a whole register single-shot, add the other qubits' measurements -inside the same loop the way [multiplexed -readout](multiplexed-readout.md) does. Correlations between qubits are visible -only in unaveraged data, so the two ideas belong together: averaging each qubit -separately destroys exactly the joint information a parity check needs. +To measure a whole register single-shot, add the other qubits' measurements inside the same loop the way [multiplexed readout](multiplexed-readout.md) does. Correlations between qubits are visible only in unaveraged data, so the two ideas belong together: averaging each qubit separately destroys exactly the joint information a parity check needs. diff --git a/docs/examples/t1-and-ramsey.md b/docs/examples/t1-and-ramsey.md index 54285f4..6220cd3 100644 --- a/docs/examples/t1-and-ramsey.md +++ b/docs/examples/t1-and-ramsey.md @@ -1,21 +1,8 @@ # T1 and Ramsey -Two coherence measurements that differ by two lines of program. T1 puts the -qubit in its excited state, idles for a delay, and reads out; repeating that -over a range of delays gives the exponential whose time constant is the energy -relaxation time. Ramsey replaces the single pi pulse with two pi/2 pulses -around the same idle and advances the phase of the second one in proportion to -the delay, so the population oscillates at a frequency you chose and decays -under an envelope you did not. Both are on the calibration ladder immediately -after [Rabi](rabi.md), because both need a pi pulse that Rabi is what -calibrates. - -These are the first programs on these pages with a time axis. Rabi sweeps an -amplitude and the [CZ chevron](cz-chevron.md) sweeps a waveform parameter; -neither sweeps a duration, which is where `wait` comes in. Along with it come -the two operations that move an oscillator's phase, `reset_phase` and -`set_phase`, and the measurement field that reports a classified outcome -instead of an IQ point. +Two coherence measurements that differ by two lines of program. T1 puts the qubit in its excited state, idles for a delay, and reads out; repeating that over a range of delays gives the exponential whose time constant is the energy relaxation time. Ramsey replaces the single pi pulse with two pi/2 pulses around the same idle and advances the phase of the second one in proportion to the delay, so the population oscillates at a frequency you chose and decays under an envelope you did not. Both are on the calibration ladder immediately after [Rabi](rabi.md), because both need a pi pulse that Rabi is what calibrates. + +These are the first programs on these pages with a time axis. Rabi sweeps an amplitude and the [CZ chevron](cz-chevron.md) sweeps a waveform parameter; neither sweeps a duration, which is where `wait` comes in. Along with it come the two operations that move an oscillator's phase, `reset_phase` and `set_phase`, and the measurement field that reports a classified outcome instead of an IQ point. ## The T1 program @@ -47,31 +34,13 @@ with t1.average(shots=1000): ### Why each piece is where it is -`wait(bus, duration)` idles one bus for a number of nanoseconds and takes -either an integer or an `Expression`, so the loop variable goes straight in. -It idles a bus rather than the program, which is why the `sync()` that follows -is what makes the delay reach the readout: the wait pushes the drive bus -forward in time, and the sync brings every other bus up to it before the -measurement starts. Waiting on `q[0].readout` instead and syncing would express -the same experiment; waiting on neither would measure at a fixed time after -the pulse no matter what `delay` held. - -Nothing checks the duration at the call site. `wait(q[0].drive, 17)` and -`wait(q[0].drive, 3.5)` both build, even though a sequencer with a 4 ns time -grid can run neither. Duration limits belong to the platform, and -`qp.validate` reports them against the bus slot's `min_wait_duration_ns`; see -[Capabilities, diagnostics, and profiles](../guide/capabilities.md). - -Asking for `MeasurementField.STATE` alongside `IQ` records the classified -outcome as well as the point it was classified from. It is the field a -relaxation curve is read off, because what T1 measures is a population rather -than a position in the IQ plane. Requesting a field is a decision made where -the measurement is built, not where the result is read, so a field left out -here cannot be recovered from the result later. - -The delays run to 40 microseconds in 41 points, which is a range set by the -device rather than by the language: it wants to cover roughly three relaxation -times so that the tail is flat enough to fit a baseline against. +`wait(bus, duration)` idles one bus for a number of nanoseconds and takes either an integer or an `Expression`, so the loop variable goes straight in. It idles a bus rather than the program, which is why the `sync()` that follows is what makes the delay reach the readout: the wait pushes the drive bus forward in time, and the sync brings every other bus up to it before the measurement starts. Waiting on `q[0].readout` instead and syncing would express the same experiment; waiting on neither would measure at a fixed time after the pulse no matter what `delay` held. + +Nothing checks the duration at the call site. `wait(q[0].drive, 17)` and `wait(q[0].drive, 3.5)` both build, even though a sequencer with a 4 ns time grid can run neither. Duration limits belong to the platform, and `qp.validate` reports them against the bus slot's `min_wait_duration_ns`; see [Capabilities, diagnostics, and profiles](../guide/capabilities.md). + +Asking for `MeasurementField.STATE` alongside `IQ` records the classified outcome as well as the point it was classified from. It is the field a relaxation curve is read off, because what T1 measures is a population rather than a position in the IQ plane. Requesting a field is a decision made where the measurement is built, not where the result is read, so a field left out here cannot be recovered from the result later. + +The delays run to 40 microseconds in 41 points, which is a range set by the device rather than by the language: it wants to cover roughly three relaxation times so that the tail is flat enough to fit a baseline against. ## What it produces @@ -98,15 +67,11 @@ body: measure q[0].readout "readout" "weights" name="q0/readout/m0" fields=["state", "iq"] ``` -The `fields=` list is written in the format's own order rather than the order -the call passed, so `(IQ, STATE)` and `(STATE, IQ)` produce the same file. A -measurement that asks only for the default `IQ` omits the attribute entirely, -which is why neither the Rabi program nor the chevron has one. +The `fields=` list is written in the format's own order rather than the order the call passed, so `(IQ, STATE)` and `(STATE, IQ)` produce the same file. A measurement that asks only for the default `IQ` omits the attribute entirely, which is why neither the Rabi program nor the chevron has one. ## The Ramsey program -The skeleton is the same. What changes is the pulse pair around the wait and -the phase written between them: +The skeleton is the same. What changes is the pulse pair around the wait and the phase written between them: ```python import numpy as np @@ -134,22 +99,9 @@ with ramsey.average(shots=1000): ) ``` -The first pi/2 pulse puts the qubit on the equator, the wait lets it precess, -and the second one turns that accumulated phase into a population the readout -can see. `set_phase` before the second pulse advances the reference frame by -2 pi times 2 MHz times the delay, which makes the fringe oscillate at 2 MHz -whether or not the drive is on resonance. That artificial detuning is the point -of the line: a Ramsey run at zero detuning decays without oscillating, and a -slow decay does not say how far off the drive frequency is or in which -direction, while a 2 MHz fringe does both and leaves the decay envelope -separable from the frequency error. - -`set_phase` is absolute and takes radians. There is no operation that shifts -the phase by an increment, so a sequence that wants to accumulate phase -computes the total itself, which is what the multiplication by `delay` does -here. `reset_phase` at the top of the body is what makes that total meaningful: -it zeroes the oscillator's phase so every iteration starts from the same -reference instead of from wherever the previous iteration left it. +The first pi/2 pulse puts the qubit on the equator, the wait lets it precess, and the second one turns that accumulated phase into a population the readout can see. `set_phase` before the second pulse advances the reference frame by 2 pi times 2 MHz times the delay, which makes the fringe oscillate at 2 MHz whether or not the drive is on resonance. That artificial detuning is the point of the line: a Ramsey run at zero detuning decays without oscillating, and a slow decay does not say how far off the drive frequency is or in which direction, while a 2 MHz fringe does both and leaves the decay envelope separable from the frequency error. + +`set_phase` is absolute and takes radians. There is no operation that shifts the phase by an increment, so a sequence that wants to accumulate phase computes the total itself, which is what the multiplication by `delay` does here. `reset_phase` at the top of the body is what makes that total meaningful: it zeroes the oscillator's phase so every iteration starts from the same reference instead of from wherever the previous iteration left it. The phase expression serializes as a single product: @@ -157,18 +109,11 @@ The phase expression serializes as a single product: set_phase q[0].drive (0.012566370614359173 * delay) ``` -Three of the four factors were plain Python floats, so Python multiplied them -before the DSL was handed the result. Only the factor that touches a variable -survives as a node, which is the same folding the fine scan on the -[Qubit spectroscopy](qubit-spectroscopy.md) page relies on. The cost is that -`set_phase` now asks a platform for `expr.binary_op` and `expr.constant` on top -of `op.set_phase` and `expr.variable`. +Three of the four factors were plain Python floats, so Python multiplied them before the DSL was handed the result. Only the factor that touches a variable survives as a node, which is the same folding the fine scan on the [Qubit spectroscopy](qubit-spectroscopy.md) page relies on. The cost is that `set_phase` now asks a platform for `expr.binary_op` and `expr.constant` on top of `op.set_phase` and `expr.variable`. ## Reading the results -Both programs are resolved against the same calibration set and run the same -way. What differs is the model, since the executor simulates no timing at all -and therefore no relaxation: +Both programs are resolved against the same calibration set and run the same way. What differs is the model, since the executor simulates no timing at all and therefore no relaxation: ```python library = { @@ -192,17 +137,9 @@ population.shape # (41,) population.coords["delay"] # 0.0, 1000.0, ..., 40000.0 ``` -`p_excited` is the second callback `qp.MockMeasurementModel` takes. Where -`response` returns the noiseless IQ point, `p_excited` returns the probability -that a shot classifies as excited, and the executor draws a Bernoulli sample -from it per shot. Both receive the same `(bus, env)` pair, so a decay written -against `env["delay"]` is all it takes to give the curve a shape. Without a -`p_excited` argument every shot classifies as 0. +`p_excited` is the second callback `qp.MockMeasurementModel` takes. Where `response` returns the noiseless IQ point, `p_excited` returns the probability that a shot classifies as excited, and the executor draws a Bernoulli sample from it per shot. Both receive the same `(bus, env)` pair, so a decay written against `env["delay"]` is all it takes to give the curve a shape. Without a `p_excited` argument every shot classifies as 0. -The curve is the exponential the model was given, sampled a thousand shots per -point, and the scatter around it is the Bernoulli noise of that count. `delay` -was declared in nanoseconds and runs to 40000, which is not how anyone reads a -T1, so the figure restates it: +The curve is the exponential the model was given, sampled a thousand shots per point, and the scatter around it is the Bernoulli noise of that count. `delay` was declared in nanoseconds and runs to 40000, which is not how anyone reads a T1, so the figure restates it: ```python result.plot( @@ -214,25 +151,13 @@ result.plot( ) ``` -![Excited-state population against delay, decaying exponentially from 1 toward 0 over 40 microseconds.](../assets/plots/t1-light.png#only-light) -![Excited-state population against delay, decaying exponentially from 1 toward 0 over 40 microseconds.](../assets/plots/t1-dark.png#only-dark) +![Excited-state population against delay, decaying exponentially from 1 toward 0 over 40 microseconds.](../assets/plots/t1-light.png#only-light) ![Excited-state population against delay, decaying exponentially from 1 toward 0 over 40 microseconds.](../assets/plots/t1-dark.png#only-dark) -The `label` the variable was given survives the restatement and only the unit -moves, so the axis reads `Delay (μs)`. `markers=True` earns its place on a -41-point sweep, where the points are the measurement and the line between them -is interpolation. [Plotting results](../guide/plotting.md) covers the rest. +The `label` the variable was given survives the restatement and only the unit moves, so the axis reads `Delay (μs)`. `markers=True` earns its place on a 41-point sweep, where the points are the measurement and the line between them is interpolation. [Plotting results](../guide/plotting.md) covers the rest. -The `STATE` array has no trailing `"IQ"` dimension, because a classified -outcome is one number per shot rather than a pair. `result.get(m0)` on the same -handle still returns the IQ field with dims `("delay", "IQ")` and shape -`(41, 2)`. This is the mirror of the raw trace on the [Rabi](rabi.md) page: a -`RAW` field adds a `"time"` dimension in front of `"IQ"`, and a `STATE` field -takes `"IQ"` away. +The `STATE` array has no trailing `"IQ"` dimension, because a classified outcome is one number per shot rather than a pair. `result.get(m0)` on the same handle still returns the IQ field with dims `("delay", "IQ")` and shape `(41, 2)`. This is the mirror of the raw trace on the [Rabi](rabi.md) page: a `RAW` field adds a `"time"` dimension in front of `"IQ"`, and a `STATE` field takes `"IQ"` away. -Under an averaging block the values are the mean of those Bernoulli draws, so -each point is the excited-state population at that delay rather than a single 0 -or 1. They carry the sampling noise of the shot count that produced them, which -is the honest reason a T1 fit wants a thousand shots per point and not fifty. +Under an averaging block the values are the mean of those Bernoulli draws, so each point is the excited-state population at that delay rather than a single 0 or 1. They carry the sampling noise of the shot count that produced them, which is the honest reason a T1 fit wants a thousand shots per point and not fifty. Ramsey reads back identically, with a model that oscillates as well as decays: @@ -250,29 +175,17 @@ result = qp.simulate( result.get(m1, field=qp.MeasurementField.STATE).dims # ("delay",) ``` -The 20 ns spacing that `Linspace(0.0, 3000.0, num=151)` resolves to samples the -2 MHz fringe 25 times per period, which is the constraint that sets the point -count: the delay axis has to resolve the artificial detuning, not just reach -far enough to see the envelope. +The 20 ns spacing that `Linspace(0.0, 3000.0, num=151)` resolves to samples the 2 MHz fringe 25 times per period, which is the constraint that sets the point count: the delay axis has to resolve the artificial detuning, not just reach far enough to see the envelope. -The fringe and the envelope are the two things the measurement separates. The -oscillation is the 2 MHz the program put there; the decay it sits inside is the -one the qubit contributed: +The fringe and the envelope are the two things the measurement separates. The oscillation is the 2 MHz the program put there; the decay it sits inside is the one the qubit contributed: -![Excited-state population against free evolution time, oscillating at 2 MHz inside a decaying envelope drawn as a dashed line.](../assets/plots/ramsey-light.png#only-light) -![Excited-state population against free evolution time, oscillating at 2 MHz inside a decaying envelope drawn as a dashed line.](../assets/plots/ramsey-dark.png#only-dark) +![Excited-state population against free evolution time, oscillating at 2 MHz inside a decaying envelope drawn as a dashed line.](../assets/plots/ramsey-light.png#only-light) ![Excited-state population against free evolution time, oscillating at 2 MHz inside a decaying envelope drawn as a dashed line.](../assets/plots/ramsey-dark.png#only-dark) -Nothing in either run knows about relaxation or precession. `wait` evaluates -its expression and returns, `set_phase` and `reset_phase` do the same, and the -curves come entirely from the two model callbacks reading `env["delay"]`. See -[Running programs](../guide/execution.md) for what the reference executor does -and does not simulate. +Nothing in either run knows about relaxation or precession. `wait` evaluates its expression and returns, `set_phase` and `reset_phase` do the same, and the curves come entirely from the two model callbacks reading `env["delay"]`. See [Running programs](../guide/execution.md) for what the reference executor does and does not simulate. ## Adapting it -A Hahn echo measures T2 instead of T2 star by putting a pi pulse in the middle -of the idle, which refocuses the dephasing that is static over one shot. The -delay is then split in half on either side of it: +A Hahn echo measures T2 instead of T2 star by putting a pi pulse in the middle of the idle, which refocuses the dephasing that is static over one shot. The delay is then split in half on either side of it: ```python half = delay / 2 @@ -283,27 +196,10 @@ echo.wait(q[0].drive, half) echo.play(q[0].drive, "pi_half") ``` -`delay / 2` is an expression like any other and appears in the file as -`(delay / 2)`. Extending it to a CPMG train means repeating the middle pair -`n` times, and a Python `for` loop around those two lines writes `n` copies -into the program at build time rather than a loop into the file. When `n` is -large enough for that to be unwieldy, a [fragment](../guide/fragments.md) names -the repeated piece once. - -To space the delays logarithmically, which puts points where an exponential -actually curves, swap the source for `qp.Logspace(100.0, 40_000.0, num=41)`. -Its bounds are linear values spaced evenly on a log scale, and both must be -positive, so a T1 axis written this way cannot start at zero the way the -`Linspace` above does. - -To find the qubit frequency from the Ramsey rather than from a -[spectroscopy](qubit-spectroscopy.md) scan, run it twice with the artificial -detuning at plus and minus 2 MHz. The fringe frequency that comes back is the -sum of the artificial detuning and the real error, so the two runs separate the -error's magnitude from its sign, which a single run cannot do. - -To read the fringe as an IQ trajectory instead of a population, drop -`MeasurementField.STATE` from `fields=` and plot `data.sel(IQ="I")` against -`data.sel(IQ="Q")`. That form needs no classifier on the instrument, which -matters on a platform whose readout chain cannot discriminate states in real -time. See [Measurements and results](../guide/measurements.md). +`delay / 2` is an expression like any other and appears in the file as `(delay / 2)`. Extending it to a CPMG train means repeating the middle pair `n` times, and a Python `for` loop around those two lines writes `n` copies into the program at build time rather than a loop into the file. When `n` is large enough for that to be unwieldy, a [fragment](../guide/fragments.md) names the repeated piece once. + +To space the delays logarithmically, which puts points where an exponential actually curves, swap the source for `qp.Logspace(100.0, 40_000.0, num=41)`. Its bounds are linear values spaced evenly on a log scale, and both must be positive, so a T1 axis written this way cannot start at zero the way the `Linspace` above does. + +To find the qubit frequency from the Ramsey rather than from a [spectroscopy](qubit-spectroscopy.md) scan, run it twice with the artificial detuning at plus and minus 2 MHz. The fringe frequency that comes back is the sum of the artificial detuning and the real error, so the two runs separate the error's magnitude from its sign, which a single run cannot do. + +To read the fringe as an IQ trajectory instead of a population, drop `MeasurementField.STATE` from `fields=` and plot `data.sel(IQ="I")` against `data.sel(IQ="Q")`. That form needs no classifier on the instrument, which matters on a platform whose readout chain cannot discriminate states in real time. See [Measurements and results](../guide/measurements.md). diff --git a/docs/getting-started.md b/docs/getting-started.md index d65ef46..b97ef1c 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -1,8 +1,6 @@ # Getting started -This page goes from an empty environment to a program that runs and returns -labeled arrays. QProgram runs on Python 3.11 through 3.14, which is the range -the test matrix covers. +This page goes from an empty environment to a program that runs and returns labeled arrays. QProgram runs on Python 3.11 through 3.14, which is the range the test matrix covers. ## Install @@ -10,30 +8,16 @@ the test matrix covers. pip install qprogram ``` -`numpy` (2.1 or newer) and `xarray` (2026.4.0 or newer) are the only runtime -dependencies. Two extras add optional pieces: +`numpy` and `xarray` are the only runtime dependencies. Two extras add optional pieces: ```bash -pip install "qprogram[viz]" # matplotlib >= 3.10.9 -pip install "qprogram[lsp]" # pygls >= 2, < 3 +pip install "qprogram[viz]" # matplotlib +pip install "qprogram[lsp]" # pygls ``` -The `viz` extra is what `QProgramResult.plot()`, `Waveform.plot()`, and -`IQWaveform.plot()` need, and `lsp` is what `python -m qprogram.lsp serve` -needs. Both packages are imported inside the call that uses them, so a missing -extra raises `ModuleNotFoundError` at that call rather than breaking -`import qprogram`; the language server catches that error and re-raises it -naming the extra to install, while `plot()` lets Python's own message through. -The other two language-server front-ends, `python -m qprogram.lsp check` and -`python -m qprogram.lsp explain`, need no extra at all: they run the parser -and validator the base install already carries, which is why an editor -integration can spawn them directly. - -The base install covers the AST, expressions, sweep sources, waveforms, bus -schemas, serialization, validation, the reference platform, and the half of -plotting that describes a figure without drawing it. -Vendor-specific operations come from separate packages that follow the protocol -described in [Building a vendor extension](developer/vendor-extensions.md). +The `viz` extra is what `QProgramResult.plot()`, `Waveform.plot()`, and `IQWaveform.plot()` need, and `lsp` is what `python -m qprogram.lsp serve` needs. Both packages are imported inside the call that uses them, so a missing extra raises `ModuleNotFoundError` at that call rather than breaking `import qprogram`; the language server catches that error and re-raises it naming the extra to install, while `plot()` lets Python's own message through. The other two language-server front-ends, `python -m qprogram.lsp check` and `python -m qprogram.lsp explain`, need no extra at all: they run the parser and validator the base install already carries, which is why an editor integration can spawn them directly. + +The base install covers the AST, expressions, sweep sources, waveforms, bus schemas, serialization, validation, the reference platform, and the half of plotting that describes a figure without drawing it. Vendor-specific operations come from separate packages that follow the protocol described in [Building a vendor extension](developer/vendor-extensions.md). ### Working on QProgram itself @@ -46,9 +30,7 @@ uv sync --all-extras uv run pytest ``` -Previewing the documentation needs the `docs` group as well. The -`--all-extras` still matters there, because mkdocstrings imports the package to -render the API reference: +Previewing the documentation needs the `docs` group as well. The `--all-extras` still matters there, because mkdocstrings imports the package to render the API reference: ```bash uv sync --all-extras --group docs @@ -78,13 +60,7 @@ with program.average(shots=1000): print(qp.dumps(program)) ``` -`BusSchema.transmon()` declares one element kind, `q`, with an IQ `drive` bus -and an IQ `readout` bus that acquires. `q[0].drive` is a `BusRef`, a `str` -subclass whose value is the resolved bus name (`"q0/drive"` under the default -`{element}{index}/{kind}` naming) and which also carries the element, index, -and kind it was resolved from, so a later `rebind` can re-resolve it against -another schema. `program.measure` returns a `MeasurementHandle`; keep it, since -that is how you address the measurement's data after a run. +`BusSchema.transmon()` declares one element kind, `q`, with an IQ `drive` bus and an IQ `readout` bus that acquires. `q[0].drive` is a `BusRef`, a `str` subclass whose value is the resolved bus name (`"q0/drive"` under the default `{element}{index}/{kind}` naming) and which also carries the element, index, and kind it was resolved from, so a later `rebind` can re-resolve it against another schema. `program.measure` returns a `MeasurementHandle`; keep it, since that is how you address the measurement's data after a run. No platform is involved yet. The output is the program in `.qp` form: @@ -110,14 +86,7 @@ body: measure q[0].readout "readout" "weights" name="q0/readout/m0" ``` -A `Range` holds `round((stop - start) / step) + 1` points and lands on `stop` -only when `step` divides `stop - start` evenly, as it does here: 101 points -from `0.0` to `1.0`. Reach for `qp.Linspace` when the count matters more than -the spacing. The `average 1000` block re-runs its body 1000 times and -contributes no result dimension of its own. The measurement name was allocated -from the bus path because the call passed no `name=`, and it is written into the -file, so `q0/readout/m0` still addresses the same measurement after a reload. -[The .qp file format](reference/qp-format.md) has the grammar. +A `Range` holds `round((stop - start) / step) + 1` points and lands on `stop` only when `step` divides `stop - start` evenly, as it does here: 101 points from `0.0` to `1.0`. Reach for `qp.Linspace` when the count matters more than the spacing. The `average 1000` block re-runs its body 1000 times and contributes no result dimension of its own. The measurement name was allocated from the bus path because the call passed no `name=`, and it is written into the file, so `q0/readout/m0` still addresses the same measurement after a reload. [The .qp file format](reference/qp-format.md) has the grammar. ## Save and reload @@ -129,28 +98,13 @@ assert reloaded.body == program.body assert qp.dumps(reloaded) == qp.dumps(program) ``` -`dumps` and `loads` take and return a string; `save` and `load` take a path and -go through the same writer and parser, always in UTF-8 regardless of locale. -Blocks and operations compare structurally, so the two bodies are equal even -though every node in `reloaded` is a new object. The writer is deterministic -too, so two dumps of the same program are byte-identical and a diff between two -files shows only what changed. - -One thing does change across the round trip. `program.schema` was the -`TransmonSchema` that `BusSchema.transmon()` returned; the parser rebuilds a -plain `BusSchema` from the `schema:` section, because the file records the -elements and their buses rather than which constructor produced them. -`reloaded.schema.q[0].drive` still resolves to `"q0/drive"` at runtime, through -`BusSchema.__getattr__`, but a type checker no longer knows that `q` exists. -Measurement handles keep their names, and `reloaded.measurement_handles()` -returns them in declaration order. +`dumps` and `loads` take and return a string; `save` and `load` take a path and go through the same writer and parser, always in UTF-8 regardless of locale. Blocks and operations compare structurally, so the two bodies are equal even though every node in `reloaded` is a new object. The writer is deterministic too, so two dumps of the same program are byte-identical and a diff between two files shows only what changed. + +One thing does change across the round trip. `program.schema` was the `TransmonSchema` that `BusSchema.transmon()` returned; the parser rebuilds a plain `BusSchema` from the `schema:` section, because the file records the elements and their buses rather than which constructor produced them. `reloaded.schema.q[0].drive` still resolves to `"q0/drive"` at runtime, through `BusSchema.__getattr__`, but a type checker no longer knows that `q` exists. Measurement handles keep their names, and `reloaded.measurement_handles()` returns them in declaration order. ## Resolving waveform names -The program above names its waveforms (`"pi_pulse"`, `"readout"`, `"weights"`) -instead of spelling them out. The concrete pulses are calibration data, which -changes far more often than the experiment does, so they live outside the -program and are attached before execution: +The program above names its waveforms (`"pi_pulse"`, `"readout"`, `"weights"`) instead of spelling them out. The concrete pulses are calibration data, which changes far more often than the experiment does, so they live outside the program and are attached before execution: ```python resolved = program.with_waveforms( @@ -162,31 +116,13 @@ resolved = program.with_waveforms( ) ``` -`with_waveforms` deep-copies the program and resolves the names in the copy, so -`program` keeps its aliases and every node, variable, and handle in `resolved` -is a distinct object. Structural equality bridges the gap: a -`MeasurementHandle` compares equal by name, so the `handle` you kept from -building `program` still addresses the right record in a result produced from -`resolved`. A name with no entry in the mapping stays a string, with no error, -and an already-concrete waveform passes through untouched. Each replacement -re-runs the channel-type check, so an IQ pulse aimed at a single-channel bus -raises `ValidationError` here rather than in a vendor compiler later. - -A plain mapping resolves on every bus. Pass a `qp.WaveformLibrary` when one -name has to mean different pulses on different buses: it keys entries at three -tiers, `q[0].drive` exactly, the `q[*].drive` family, or globally, and takes -the most specific match for the bus being resolved. The library is not part of -a `.qp` file, and the aliases do not survive resolution: `qp.dumps(resolved)` -writes the pulses inline as `IQDrag(...)` and `IQPair(...)` constructor calls. -The alias form is therefore the one to keep under version control, with the -library saved separately as `.wfl` through `WaveformLibrary.save`. +`with_waveforms` deep-copies the program and resolves the names in the copy, so `program` keeps its aliases and every node, variable, and handle in `resolved` is a distinct object. Structural equality bridges the gap: a `MeasurementHandle` compares equal by name, so the `handle` you kept from building `program` still addresses the right record in a result produced from `resolved`. A name with no entry in the mapping stays a string, with no error, and an already-concrete waveform passes through untouched. Each replacement re-runs the channel-type check, so an IQ pulse aimed at a single-channel bus raises `ValidationError` here rather than in a vendor compiler later. + +A plain mapping resolves on every bus. Pass a `qp.WaveformLibrary` when one name has to mean different pulses on different buses: it keys entries at three tiers, `q[0].drive` exactly, the `q[*].drive` family, or globally, and takes the most specific match for the bus being resolved. The library is not part of a `.qp` file, and the aliases do not survive resolution: `qp.dumps(resolved)` writes the pulses inline as `IQDrag(...)` and `IQPair(...)` constructor calls. The alias form is therefore the one to keep under version control, with the library saved separately as `.wfl` through `WaveformLibrary.save`. ## Run it -QProgram never talks to instruments. A platform does, through -[`PlatformProtocol`](reference/api-qprogram.md#qprogram.PlatformProtocol). The -package ships one, `ReferencePlatform`, which is a pure-Python interpreter, and -`qp.simulate` wraps it for the one-off case: +QProgram never talks to instruments. A platform does, through [`PlatformProtocol`](reference/api-qprogram.md#qprogram.PlatformProtocol). The package ships one, `ReferencePlatform`, which is a pure-Python interpreter, and `qp.simulate` wraps it for the one-off case: ```python result = qp.simulate(resolved) @@ -196,28 +132,11 @@ print(data.dims, data.shape) # ('gain', 'IQ') (101, 2) print(data.coords["gain"].values[:3]) # [0. 0.01 0.02] ``` -`simulate` builds a throwaway `ReferencePlatform`, expands any fragment calls, -validates the program against that platform's capabilities, and interprets it. -An error-severity `Diagnostic` raises `UnsupportedOperationError` listing every -error; a warning is re-emitted through `warnings.warn` as `ExecutionWarning` -and does not stop the run; an info-severity one is dropped. - -What the interpreter models is control flow and bookkeeping. A sweep binds its -variable once per iteration and gives every measurement inside it one result -dimension, named after the variable's id and carrying the sweep values as -coordinates; `average` re-runs its body and divides the accumulated sums by the -per-point shot count; a conditional evaluates its condition against the state -already written onto a measurement handle; `set_parameter` and `get_parameter` -read and write a flat store keyed `"bus.parameter"`. What it does not model is -physics or timing: `play`, `wait`, `sync`, and the `set_*` operations evaluate -their expressions and then do nothing, so pulse shape, duration, and ordering -never reach the numbers. The shape of the result is real; the values come from -a measurement model. - -That model is consulted once per measurement per shot. The default, -`qp.MockMeasurementModel()`, responds `0j` with no noise and keeps every shot -in the ground state, so a first run returns zeros of the right shape. Give it a -response function to get a curve: +`simulate` builds a throwaway `ReferencePlatform`, expands any fragment calls, validates the program against that platform's capabilities, and interprets it. An error-severity `Diagnostic` raises `UnsupportedOperationError` listing every error; a warning is re-emitted through `warnings.warn` as `ExecutionWarning` and does not stop the run; an info-severity one is dropped. + +What the interpreter models is control flow and bookkeeping. A sweep binds its variable once per iteration and gives every measurement inside it one result dimension, named after the variable's id and carrying the sweep values as coordinates; `average` re-runs its body and divides the accumulated sums by the per-point shot count; a conditional evaluates its condition against the state already written onto a measurement handle; `set_parameter` and `get_parameter` read and write a flat store keyed `"bus.parameter"`. What it does not model is physics or timing: `play`, `wait`, `sync`, and the `set_*` operations evaluate their expressions and then do nothing, so pulse shape, duration, and ordering never reach the numbers. The shape of the result is real; the values come from a measurement model. + +That model is consulted once per measurement per shot. The default, `qp.MockMeasurementModel()`, responds `0j` with no noise and keeps every shot in the ground state, so a first run returns zeros of the right shape. Give it a response function to get a curve: ```python import numpy as np @@ -231,20 +150,13 @@ model = qp.MockMeasurementModel( result = qp.simulate(resolved, model=model) ``` -`env` holds the bound loop variables by id plus the parameter store keyed -`"bus.parameter"`, so a model can respond to whatever the program set. All -randomness comes from one generator seeded by `seed`, so the same program and -seed give the same numbers. +`env` holds the bound loop variables by id plus the parameter store keyed `"bus.parameter"`, so a model can respond to whatever the program set. All randomness comes from one generator seeded by `seed`, so the same program and seed give the same numbers. -A hardware platform is a drop-in for the same call: -`platform.execute(resolved)` returns a `QProgramResult` of the same shape. -[Running programs](guide/execution.md) covers platforms and models in full, and -[Measurements and results](guide/measurements.md) the result contract. +A hardware platform is a drop-in for the same call: `platform.execute(resolved)` returns a `QProgramResult` of the same shape. [Running programs](guide/execution.md) covers platforms and models in full, and [Measurements and results](guide/measurements.md) the result contract. ## Reading a result -`QProgramResult.get` returns one `xarray.DataArray`, and takes the measurement -three ways: +`QProgramResult.get` returns one `xarray.DataArray`, and takes the measurement three ways: ```python data = result.get(handle) # by handle @@ -252,38 +164,19 @@ same = result.get("q0/readout/m0") # by name also = result.get(0) # by position in declaration order ``` -A handle is the spelling to prefer, because it says what it means and survives -reordering. A name is what you have after a `.qp` round trip, where -`reloaded.measurement_handles()` hands back handles that compare equal to the -originals. A position is sugar; passing `bus=` narrows the candidates before -the handle, name, or position is matched. +A handle is the spelling to prefer, because it says what it means and survives reordering. A name is what you have after a `.qp` round trip, where `reloaded.measurement_handles()` hands back handles that compare equal to the originals. A position is sugar; passing `bus=` narrows the candidates before the handle, name, or position is matched. -The `field=` argument picks which measurement field to return and defaults to -`qp.MeasurementField.IQ`, matching the default of `measure(..., fields=)`. A -field the measurement never requested raises rather than substituting another -one, so `result.get(handle, field=qp.MeasurementField.STATE)` on this program -reports: +The `field=` argument picks which measurement field to return and defaults to `qp.MeasurementField.IQ`, matching the default of `measure(..., fields=)`. A field the measurement never requested raises rather than substituting another one, so `result.get(handle, field=qp.MeasurementField.STATE)` on this program reports: ``` KeyError: "Measurement 'q0/readout/m0' has no field 'state'; available: iq" ``` -The `iq` array carries a trailing `"IQ"` dimension with coordinates `["I", -"Q"]`, so `data.sel(IQ="I")` is the in-phase component and `data.values` is the -underlying `numpy` array. +The `iq` array carries a trailing `"IQ"` dimension with coordinates `["I", "Q"]`, so `data.sel(IQ="I")` is the in-phase component and `data.values` is the underlying `numpy` array. -To inspect a program without running it, `qp.validate(program, caps)` returns -the diagnostics and the `ExecutionPlan`, and `qp.explain(program, caps)` -renders that plan as a tree with a domain column per node; -`qp.reference_capabilities()` is the capability descriptor to pass for the -reference platform. Two smaller tools need no program at all: -`Expression.evaluate_or_raise()` reduces an expression to a number in pure -Python once its variables have values, and `Waveform.envelope()` renders a -shape to samples. +To inspect a program without running it, `qp.validate(program, caps)` returns the diagnostics and the `ExecutionPlan`, and `qp.explain(program, caps)` renders that plan as a tree with a domain column per node; `qp.reference_capabilities()` is the capability descriptor to pass for the reference platform. Two smaller tools need no program at all: `Expression.evaluate_or_raise()` reduces an expression to a number in pure Python once its variables have values, and `Waveform.envelope()` renders a shape to samples. ## Related pages -- [Core ideas](guide/concepts.md) covers the AST, blocks, operations, and the - real-time versus host-side boundary. -- [Buses and schemas](guide/buses.md) explains typed bus references and what - the schema catches that a raw string does not. +- [Core ideas](guide/concepts.md) covers the AST, blocks, operations, and the real-time versus host-side boundary. +- [Buses and schemas](guide/buses.md) explains typed bus references and what the schema catches that a raw string does not. diff --git a/docs/guide/buses.md b/docs/guide/buses.md index 7196570..ce813a8 100644 --- a/docs/guide/buses.md +++ b/docs/guide/buses.md @@ -10,18 +10,13 @@ program.play("drive_q0", "pi_pulse") program.measure("readout_q0", "readout", "weights") ``` -That is a complete program. Two things you give up by typing strings: there is -no tab-completion, and nothing checks either the name or the kind of waveform -you put on it. Type `"drvie_q0"` by accident and you find out at execution -time, on hardware. +That is a complete program. Two things you give up by typing strings: there is no tab-completion, and nothing checks either the name or the kind of waveform you put on it. Type `"drvie_q0"` by accident and you find out at execution time, on hardware. A `BusSchema` closes both gaps without changing what ends up in the AST. ## What a schema declares -A schema records element kinds and, for each element kind, the bus kinds that -element exposes, the channel each bus carries, and whether it has an ADC. It -says nothing about how many qubits the chip has, so any index resolves: +A schema records element kinds and, for each element kind, the bus kinds that element exposes, the channel each bus carries, and whether it has an ADC. It says nothing about how many qubits the chip has, so any index resolves: ```python schema = qp.BusSchema.transmon() @@ -32,15 +27,9 @@ q[3].readout # "q3/readout" q[42].drive # "q42/drive" ``` -`schema.elements` maps element name to an `ElementSchema`, whose `buses` is -`{kind: (channel, acquires)}` in declaration order and whose `bus_names` is -just the kinds. `schema.naming` is the `BusNaming` the schema resolves strings -through, and `schema.KIND` is a class-level tag the presets set -(`"transmon"`, `"fluxonium_coupled"`, and so on) and nothing else reads. +`schema.elements` maps element name to an `ElementSchema`, whose `buses` is `{kind: (channel, acquires)}` in declaration order and whose `bus_names` is just the kinds. `schema.naming` is the `BusNaming` the schema resolves strings through, and `schema.KIND` is a class-level tag the presets set (`"transmon"`, `"fluxonium_coupled"`, and so on) and nothing else reads. -A `BusRef` is a real `str` subclass, so it works everywhere QProgram expects a -bus name; in a `.qp` file it emits as an `element[idx].kind` path rather than a -quoted name. It carries six extra fields: +A `BusRef` is a real `str` subclass, so it works everywhere QProgram expects a bus name; in a `.qp` file it emits as an `element[idx].kind` path rather than a quoted name. It carries six extra fields: ```python bus = q[0].readout @@ -55,24 +44,13 @@ bus.acquires # True bus.schema # the BusSchema that produced it ``` -The index field is `idx` rather than `index` because a `str` subclass must not -shadow the inherited `str.index` method. The fields live in `__slots__`, so an -instance carries them without gaining a per-instance `__dict__`, and `BusRef` -overrides `__reduce__` so `copy.deepcopy` and `pickle` rebuild the metadata: -the inherited `str.__reduce_ex__` would pass the string value alone back to a -constructor that wants six more arguments. +The index field is `idx` rather than `index` because a `str` subclass must not shadow the inherited `str.index` method. The fields live in `__slots__`, so an instance carries them without gaining a per-instance `__dict__`, and `BusRef` overrides `__reduce__` so `copy.deepcopy` and `pickle` rebuild the metadata: the inherited `str.__reduce_ex__` would pass the string value alone back to a constructor that wants six more arguments. -Building a ref by hand is how you describe a bus that lives outside any -schema. `qp.BusRef("aux0/rf", "aux", 0, "rf", "single", acquires=False)` -leaves `schema` at `None`, so a program bound to any schema accepts it, while -its `channel` and `acquires` still drive the checks below. +Building a ref by hand is how you describe a bus that lives outside any schema. `qp.BusRef("aux0/rf", "aux", 0, "rf", "single", acquires=False)` leaves `schema` at `None`, so a program bound to any schema accepts it, while its `channel` and `acquires` still drive the checks below. ## Built-in presets -The presets return typed subclasses, so an IDE can complete the bus kinds. The -qubit element is always named `q` and the coupler element, where there is one, -is always named `c` with a single `flux` bus on a real-valued channel and no -ADC. +The presets return typed subclasses, so an IDE can complete the bus kinds. The qubit element is always named `q` and the coupler element, where there is one, is always named `c` with a single `flux` bus on a real-valued channel and no ADC. | Preset | Returns | `q` buses | `c` buses | |---|---|---|---| @@ -83,12 +61,9 @@ ADC. | `qp.BusSchema.fluxonium()` | `FluxoniumSchema` | `drive` (IQ), `readout` (IQ, acquires), `flux_x` (single), `flux_z` (single) | none | | `qp.BusSchema.fluxonium_coupled()` | `FluxoniumCoupledSchema` | `drive` (IQ), `readout` (IQ, acquires), `flux_x` (single), `flux_z` (single) | `flux` (single) | -Every preset takes an optional `naming` argument and nothing else; the schema -classes live in `qp.buses` under those names if you want to subclass one or -name one in a type annotation. +Every preset takes an optional `naming` argument and nothing else; the schema classes live in `qp.buses` under those names if you want to subclass one or name one in a type annotation. -A coupler sits between qubits, so its index is usually a tuple, and a tuple -index joins with an underscore: +A coupler sits between qubits, so its index is usually a tuple, and a tuple index joins with an underscore: ```python coupled = qp.BusSchema.flux_tunable_transmon_coupled() @@ -98,12 +73,7 @@ coupled.c[3].flux # "c3/flux", a single integer works too ## Bus naming -`BusNaming` holds one format string, and its `resolve(element, index, kind)` -substitutes the three pieces by keyword. The default pattern is -`BusNaming.DEFAULT_PATTERN`, `"{element}{index}/{kind}"`, which is why -`q[0].readout` reads `"q0/readout"`. A tuple index is joined with underscores -before substitution. Pass a `naming` to any preset, to `BusSchema()`, or to -`BusSchema.combine`, and every ref that schema produces adopts it: +`BusNaming` holds one format string, and its `resolve(element, index, kind)` substitutes the three pieces by keyword. The default pattern is `BusNaming.DEFAULT_PATTERN`, `"{element}{index}/{kind}"`, which is why `q[0].readout` reads `"q0/readout"`. A tuple index is joined with underscores before substitution. Pass a `naming` to any preset, to `BusSchema()`, or to `BusSchema.combine`, and every ref that schema produces adopts it: ```python named = qp.BusSchema.flux_tunable_transmon(naming=qp.BusNaming("{kind}_{element}{index}_bus")) @@ -112,26 +82,13 @@ named.q[0].readout # "readout_q0_bus" qp.BusNaming().resolve("c", (0, 1), "flux") # "c0_1/flux" ``` -The three supported placeholders are `{element}`, `{index}` and `{kind}`, and -the pattern is not validated when the `BusNaming` is constructed. A bad -pattern raises the first time a ref is resolved: `KeyError` for a placeholder -outside the three (`"{element}-{port}"` raises `KeyError: 'port'`), -`ValueError` for a malformed format string or a format specification the -substituted text cannot satisfy (every piece arrives as text, so `{index:d}` -fails), and `IndexError` for a positional placeholder such as `{0}`. - -Nothing requires the pattern to use all three placeholders, and a pattern that -omits `{kind}` collapses every bus on an element onto one name: -`BusNaming("{element}{index}")` resolves both `q[0].drive` and `q[0].readout` -to `"q0"`. A non-default pattern is written into the `naming:` line of the -`.qp` schema block, so it survives a round-trip; the default pattern is left -out of the file. +The three supported placeholders are `{element}`, `{index}` and `{kind}`, and the pattern is not validated when the `BusNaming` is constructed. A bad pattern raises the first time a ref is resolved: `KeyError` for a placeholder outside the three (`"{element}-{port}"` raises `KeyError: 'port'`), `ValueError` for a malformed format string or a format specification the substituted text cannot satisfy (every piece arrives as text, so `{index:d}` fails), and `IndexError` for a positional placeholder such as `{0}`. + +Nothing requires the pattern to use all three placeholders, and a pattern that omits `{kind}` collapses every bus on an element onto one name: `BusNaming("{element}{index}")` resolves both `q[0].drive` and `q[0].readout` to `"q0"`. A non-default pattern is written into the `naming:` line of the `.qp` schema block, so it survives a round-trip; the default pattern is left out of the file. ## Dynamic schemas -For a one-off or exotic layout, build the schema by hand. There is no static -typing on the result, because bus access goes through `__getattr__` rather -than declared properties, but everything else works the same: +For a one-off or exotic layout, build the schema by hand. There is no static typing on the result, because bus access goes through `__getattr__` rather than declared properties, but everything else works the same: ```python dynamic = qp.BusSchema() @@ -149,15 +106,11 @@ dynamic.q[0].charge # "q0/charge" dynamic.resonator[2].probe # "resonator2/probe" ``` -Each entry in `buses` is a `(channel, acquires)` pair: `channel` is `"single"` -or `"IQ"`, and `acquires` is `True` when the bus has an ADC. Registering an -element name twice replaces the earlier declaration rather than merging into -it, so the last call wins. +Each entry in `buses` is a `(channel, acquires)` pair: `channel` is `"single"` or `"IQ"`, and `acquires` is `True` when the bus has an ADC. Registering an element name twice replaces the earlier declaration rather than merging into it, so the last call wins. ## Combining schemas -Two schemas add together, which is how a chip schema and a separate control -family (an RF switch, a set of couplers) end up in one place: +Two schemas add together, which is how a chip schema and a separate control family (an RF switch, a set of couplers) end up in one place: ```python switch = qp.BusSchema() @@ -168,56 +121,23 @@ combined.q[0].flux # "q0/flux" combined.switch[0].rf # "switch0/rf" ``` -Either operand may be a schema instance or a schema class, so -`qp.buses.FluxTunableTransmonSchema + MyChipSchema` works as well; the class -form is provided by a metaclass, because `__add__` in a class body governs -instances only. `BusSchema.combine(*schemas, naming=None)` is the same -operation spelled out, and it is what you want for three or more schemas in -one call or for choosing the naming explicitly. - -The result is a plain `BusSchema` holding the union of the inputs' elements, so -`combined.q[0].drive` resolves at runtime with no static typing, the same -trade-off as `add_element`. Build your refs from the combined schema rather -than from the originals: a ref's `schema` back-pointer has to match the schema -attached to the program. - -`combine` raises `ValueError` when it is called with no schemas, when the -inputs disagree on their naming pattern and no `naming` is passed -(`cannot combine schemas with different naming patterns [...]`), and when two -inputs declare the same element name with different buses -(`cannot combine schemas: element 'q' is defined differently [...]`, which is -what `transmon() + flux_tunable_transmon()` produces, since both define `q`). -Re-declaring an identical element is allowed and merges once. +Either operand may be a schema instance or a schema class, so `qp.buses.FluxTunableTransmonSchema + MyChipSchema` works as well; the class form is provided by a metaclass, because `__add__` in a class body governs instances only. `BusSchema.combine(*schemas, naming=None)` is the same operation spelled out, and it is what you want for three or more schemas in one call or for choosing the naming explicitly. + +The result is a plain `BusSchema` holding the union of the inputs' elements, so `combined.q[0].drive` resolves at runtime with no static typing, the same trade-off as `add_element`. Build your refs from the combined schema rather than from the originals: a ref's `schema` back-pointer has to match the schema attached to the program. + +`combine` raises `ValueError` when it is called with no schemas, when the inputs disagree on their naming pattern and no `naming` is passed (`cannot combine schemas with different naming patterns [...]`), and when two inputs declare the same element name with different buses (`cannot combine schemas: element 'q' is defined differently [...]`, which is what `transmon() + flux_tunable_transmon()` produces, since both define `q`). Re-declaring an identical element is allowed and merges once. ## Channel types and acquisition -`ChannelType` is `Literal["single", "IQ"]`: a single real-valued output, or a -pair of I and Q outputs. What the channel gates is the waveform an operation -carries, not the operation itself, and the two waveform hierarchies are -disjoint, so the test is exact. An `"IQ"` bus takes an `IQWaveform` (`IQPair`, -`IQDrag`, `IQRotation`, `IQZero`, `Modulated`) and rejects a `Waveform`; a -`"single"` bus takes a `Waveform` (`Square`, `FlatTop`, `Gaussian`, and the -rest) and rejects an `IQWaveform`. - -`play` checks its waveform, and `measure` checks both its readout pulse and -its integration weights. Every other bus-carrying operation (`wait`, `sync`, -`set_frequency`, `set_phase`, `reset_phase`, `set_gain`, `set_offset`, -`set_parameter`, `get_parameter`) accepts either channel type. `set_offset` in -particular does not compare `offset_path1` against the bus channel, so a -second path on a single-channel bus passes both the builder and `validate` -without comment. - -`acquires` is read by `measure`, which refuses a bus without an ADC, and again -when a fragment is expanded, because a bus that arrived as a fragment parameter -could not be checked while the fragment body was being written. +`ChannelType` is `Literal["single", "IQ"]`: a single real-valued output, or a pair of I and Q outputs. What the channel gates is the waveform an operation carries, not the operation itself, and the two waveform hierarchies are disjoint, so the test is exact. An `"IQ"` bus takes an `IQWaveform` (`IQPair`, `IQDrag`, `IQRotation`, `IQZero`, `Modulated`) and rejects a `Waveform`; a `"single"` bus takes a `Waveform` (`Square`, `FlatTop`, `Gaussian`, and the rest) and rejects an `IQWaveform`. + +`play` checks its waveform, and `measure` checks both its readout pulse and its integration weights. Every other bus-carrying operation (`wait`, `sync`, `set_frequency`, `set_phase`, `reset_phase`, `set_gain`, `set_offset`, `set_parameter`, `get_parameter`) accepts either channel type. `set_offset` in particular does not compare `offset_path1` against the bus channel, so a second path on a single-channel bus passes both the builder and `validate` without comment. + +`acquires` is read by `measure`, which refuses a bus without an ADC, and again when a fragment is expanded, because a bus that arrived as a fragment parameter could not be checked while the fragment body was being written. ## Checks that run when you build a program -The schema-identity check compares the ref's `schema` back-pointer against the -program's, so it only fires for a ref a schema produced. The channel and -acquisition checks read `channel` and `acquires` off the ref itself, so they -fire for a hand-built `BusRef` too. Missing elements and missing bus kinds -fail earlier still, when the accessor is read: +The schema-identity check compares the ref's `schema` back-pointer against the program's, so it only fires for a ref a schema produced. The channel and acquisition checks read `channel` and `acquires` off the ref itself, so they fire for a hand-built `BusRef` too. Missing elements and missing bus kinds fail earlier still, when the accessor is read: | Check | Fires when | Result | |---|---|---| @@ -246,28 +166,13 @@ q[0].flux_x # AttributeError, a flux-tunable transmon has one flux bus ftt.resonator # AttributeError, this schema has no resonator element ``` -`measure` runs its checks in the order schema identity, acquisition, readout -pulse channel, weights channel, then measurement-name allocation, so the -first thing wrong with the call is the thing reported. Vendor operations -appended through a `VendorNamespace` get the schema-identity check on every -`BusRef` attribute they carry, including refs inside lists, but not the -channel and acquisition checks, which the `play` and `measure` builders run on -their own arguments. - -A string waveform alias carries no channel, so it is not checked when the -operation is appended. It is checked when `with_waveforms` resolves the alias -to a concrete waveform, against the bus the operation targets, which is the -same `ValidationError` arriving later. The bus it resolves against is the one -the operation carries at that moment, so a -[`rebind`](#rebinding-buses-in-a-program) run first changes both the check and, -for a `WaveformLibrary` with per-bus entries, which waveform the alias -resolves to. +`measure` runs its checks in the order schema identity, acquisition, readout pulse channel, weights channel, then measurement-name allocation, so the first thing wrong with the call is the thing reported. Vendor operations appended through a `VendorNamespace` get the schema-identity check on every `BusRef` attribute they carry, including refs inside lists, but not the channel and acquisition checks, which the `play` and `measure` builders run on their own arguments. + +A string waveform alias carries no channel, so it is not checked when the operation is appended. It is checked when `with_waveforms` resolves the alias to a concrete waveform, against the bus the operation targets, which is the same `ValidationError` arriving later. The bus it resolves against is the one the operation carries at that moment, so a [`rebind`](#rebinding-buses-in-a-program) run first changes both the check and, for a `WaveformLibrary` with per-bus entries, which waveform the alias resolves to. ## Plain strings always bypass validation -Raw strings still work and skip every check. This is on purpose: you can keep -a program mostly schema-backed and slot in a one-off bus by name without -declaring it. +Raw strings still work and skip every check. This is on purpose: you can keep a program mostly schema-backed and slot in a one-off bus by name without declaring it. ```python prog.play("raw_bus", qp.waveforms.Square(0.5, 100)) # OK, no validation @@ -275,13 +180,7 @@ prog.measure("raw_bus", "readout", "weights") # OK, no validation prog.play(q[0].drive, "pi_pulse") # OK, the alias is checked later ``` -Two other behaviors follow the same split. Auto-allocated measurement names -are per bus for a `BusRef` (`q0/readout/m0`, `q0/readout/m1`) and share one -global counter for raw-string buses (`m0`, `m1`); see -[Measurements and results](measurements.md). And a platform's capabilities are -declared per `(element, bus kind)` slot, so a schema-backed `BusRef` routes to -the profile for its own kind of bus while a raw string always routes to -`default_bus_profile`; see [Capabilities and validation](capabilities.md). +Two other behaviors follow the same split. Auto-allocated measurement names are per bus for a `BusRef` (`q0/readout/m0`, `q0/readout/m1`) and share one global counter for raw-string buses (`m0`, `m1`); see [Measurements and results](measurements.md). And a platform's capabilities are declared per `(element, bus kind)` slot, so a schema-backed `BusRef` routes to the profile for its own kind of bus while a raw string always routes to `default_bus_profile`; see [Capabilities and validation](capabilities.md). ## One schema per program @@ -291,26 +190,13 @@ A `QProgram` holds at most one `BusSchema`, passed at construction: program = qp.QProgram(label="rabi", schema=schema) ``` -If you build a program with `schema=schema_a` and then call -`program.play(schema_b.q[0].drive, ...)`, QProgram refuses with a -`ValidationError` at the call site. The comparison is by identity, not by -structure, so two separately constructed `transmon()` schemas count as -different. That bus would serialize fine but mean something different on load, -so the builder rejects it rather than letting it through. +If you build a program with `schema=schema_a` and then call `program.play(schema_b.q[0].drive, ...)`, QProgram refuses with a `ValidationError` at the call site. The comparison is by identity, not by structure, so two separately constructed `transmon()` schemas count as different. That bus would serialize fine but mean something different on load, so the builder rejects it rather than letting it through. -You can omit the schema entirely. In that case the first schema-backed ref the -program sees is adopted as the program's schema, and every later ref is -compared against it. A fragment carries a schema the same way, and calling one -reconciles the two: a fragment built against a schema lends it to a program -that has none, and two different schemas raise. See [Fragments](fragments.md). +You can omit the schema entirely. In that case the first schema-backed ref the program sees is adopted as the program's schema, and every later ref is compared against it. A fragment carries a schema the same way, and calling one reconciles the two: a fragment built against a schema lends it to a program that has none, and two different schemas raise. See [Fragments](fragments.md). ## Defining your own typed schema -For a chip type the presets do not cover, write a typed schema class. The -pattern is what the presets do internally, and the base classes are the -underscore-prefixed ones in `qp.buses`: an accessor exposes one property per -bus kind, a factory turns an index into an accessor, and the schema exposes one -property per element. +For a chip type the presets do not cover, write a typed schema class. The pattern is what the presets do internally, and the base classes are the underscore-prefixed ones in `qp.buses`: an accessor exposes one property per bus kind, a factory turns an index into an accessor, and the schema exposes one property per element. ```python class MyQubitBuses(qp.buses._TypedElementAccessor): @@ -358,59 +244,26 @@ class MyChipSchema(qp.BusSchema): return qp.buses.CouplerFactory("c", self._naming, self) ``` -A factory takes `(element, naming, schema)` and an accessor takes -`(element, index, naming, schema)`; that last argument is the back-pointer -every `BusRef` carries in `bus.schema`, and it is what lets a program reject a -reference built from a different schema. `_ref(kind, channel, acquires=False)` -does the rest: it resolves the name through the schema's naming and fills in -the metadata, with `acquires` keyword-only. - -Call `add_element` for every element, including the ones your typed properties -already cover. The properties are what a reader writes, but the `elements` -dictionary is what the `.qp` writer emits and what `combine` merges, and -`_ref` never consults it: a class with a `q` property and no -`add_element("q", ...)` resolves `q[0].drive` happily, writes an empty -`schema:` block, and then fails to reload with -`ParseError: Line 7: inline schema has no element declarations`. Accept -`naming` in the constructor too, so the class works with `combine` and with -`rebind(naming=...)`, both of which re-declare elements under another pattern. - -User-defined classes serialize through the same inline form as the presets -(see [the .qp schema declaration](../reference/qp-format.md#schema-declaration)). -The Python class identity does not survive a round-trip: a loaded program gets -a plain `BusSchema` with `KIND` back at `""`, holding the same elements, bus -kinds and naming. Runtime access and the validation behavior are unchanged, -and the typed properties are gone. +A factory takes `(element, naming, schema)` and an accessor takes `(element, index, naming, schema)`; that last argument is the back-pointer every `BusRef` carries in `bus.schema`, and it is what lets a program reject a reference built from a different schema. `_ref(kind, channel, acquires=False)` does the rest: it resolves the name through the schema's naming and fills in the metadata, with `acquires` keyword-only. + +Call `add_element` for every element, including the ones your typed properties already cover. The properties are what a reader writes, but the `elements` dictionary is what the `.qp` writer emits and what `combine` merges, and `_ref` never consults it: a class with a `q` property and no `add_element("q", ...)` resolves `q[0].drive` happily, writes an empty `schema:` block, and then fails to reload with `ParseError: Line 7: inline schema has no element declarations`. Accept `naming` in the constructor too, so the class works with `combine` and with `rebind(naming=...)`, both of which re-declare elements under another pattern. + +User-defined classes serialize through the same inline form as the presets (see [the .qp schema declaration](../reference/qp-format.md#schema-declaration)). The Python class identity does not survive a round-trip: a loaded program gets a plain `BusSchema` with `KIND` back at `""`, holding the same elements, bus kinds and naming. Runtime access and the validation behavior are unchanged, and the typed properties are gone. ## Re-resolving a bus coordinate -`qp.buses.resolve_ref(schema, element, index, kind)` turns a structural -coordinate back into a `BusRef`. It is two attribute reads with a subscript -between them, `getattr(getattr(schema, element)[index], kind)`, which is why it -works identically for a typed preset and for a dynamic schema: the typed path -lands on a declared property, the dynamic path on `__getattr__`. The ref comes -back resolved under `schema`'s naming and pointing at `schema`, and the -failures are the accessor failures from the table above, an `AttributeError` -for an unknown element or bus kind. +`qp.buses.resolve_ref(schema, element, index, kind)` turns a structural coordinate back into a `BusRef`. It is two attribute reads with a subscript between them, `getattr(getattr(schema, element)[index], kind)`, which is why it works identically for a typed preset and for a dynamic schema: the typed path lands on a declared property, the dynamic path on `__getattr__`. The ref comes back resolved under `schema`'s naming and pointing at `schema`, and the failures are the accessor failures from the table above, an `AttributeError` for an unknown element or bus kind. ```python qp.buses.resolve_ref(coupled, "q", 0, "readout") # "q0/readout" qp.buses.resolve_ref(coupled, "c", (0, 1), "flux") # "c0_1/flux" ``` -Two callers use it. The `.qp` parser calls it for every `element[i].kind` path -in a file, which is how a loaded program gets real `BusRef`s rather than -strings. `QProgram.rebind` calls it for every ref it rewrites, which is how -re-indexing a qubit or moving a program onto another chip's schema stays -checked against that schema. The naming-only case goes through -`qp.buses.naming_substituted_schema(schema, naming)`, which returns a dynamic -copy of `schema` with the same elements declared under a new `BusNaming`. +Two callers use it. The `.qp` parser calls it for every `element[i].kind` path in a file, which is how a loaded program gets real `BusRef`s rather than strings. `QProgram.rebind` calls it for every ref it rewrites, which is how re-indexing a qubit or moving a program onto another chip's schema stays checked against that schema. The naming-only case goes through `qp.buses.naming_substituted_schema(schema, naming)`, which returns a dynamic copy of `schema` with the same elements declared under a new `BusNaming`. ## Rebinding buses in a program -`QProgram.rebind` rewrites the bus on every operation in a program by -re-resolving each one structurally instead of substituting strings. Its -parameters are all keyword-only: +`QProgram.rebind` rewrites the bus on every operation in a program by re-resolving each one structurally instead of substituting strings. Its parameters are all keyword-only: ```python program.rebind( @@ -422,36 +275,11 @@ program.rebind( ) ``` -`schema` is the schema the refs are resolved against, and it defaults to the -program's own, which is the re-index-within-one-chip case; pass another schema -to move the program onto another chip. `elements` maps `(element, idx)` to -`(element, idx)`, so `{("q", 0): ("q", 1)}` moves every operation on qubit 0 -onto qubit 1 while every pair the map does not list passes through unchanged; -an index is an `int` or a tuple, the same shape as `BusRef.idx`. `naming` -re-resolves the refs under a different `BusNaming`, through -`qp.buses.naming_substituted_schema`, and it needs a schema to substitute into: -on a program that has none it raises `ValidationError: rebind(naming=...) -requires the program to have a schema to re-resolve against`. `strings` covers -raw-string buses, described below. - -Every schema-backed ref goes through `qp.buses.resolve_ref` with its remapped -element and index and its own bus kind, so the rebound bus is a `BusRef` again, -carrying the `channel` and `acquires` the target schema declares and still -emitting as an `element[idx].kind` path in a `.qp` file. A coordinate the target -schema does not declare fails rather than resolving to a plausible name: -rebinding a `play` on `q[0].drive` onto a transmon coupler raises -`AttributeError: 'CouplerBuses' object has no attribute 'drive'`, because a -coupler declares only `flux`. Auto-allocated measurement names are re-derived -from the new bus while user-supplied names are left alone; see -[Measurements and results](measurements.md). - -`rebind` returns a new `QProgram` and mutates nothing. The copy is deep, and -fragment calls are expanded into it first when the program has any, so the -result holds the inlined bodies rather than the calls; see -[Fragments](fragments.md). The schema is copied along with the program unless -you pass one, and the schema-identity check compares by identity, so a ref held -from the original schema cannot be appended to the rebound program; read the -schema back off the result if you want to keep building. +`schema` is the schema the refs are resolved against, and it defaults to the program's own, which is the re-index-within-one-chip case; pass another schema to move the program onto another chip. `elements` maps `(element, idx)` to `(element, idx)`, so `{("q", 0): ("q", 1)}` moves every operation on qubit 0 onto qubit 1 while every pair the map does not list passes through unchanged; an index is an `int` or a tuple, the same shape as `BusRef.idx`. `naming` re-resolves the refs under a different `BusNaming`, through `qp.buses.naming_substituted_schema`, and it needs a schema to substitute into: on a program that has none it raises `ValidationError: rebind(naming=...) requires the program to have a schema to re-resolve against`. `strings` covers raw-string buses, described below. + +Every schema-backed ref goes through `qp.buses.resolve_ref` with its remapped element and index and its own bus kind, so the rebound bus is a `BusRef` again, carrying the `channel` and `acquires` the target schema declares and still emitting as an `element[idx].kind` path in a `.qp` file. A coordinate the target schema does not declare fails rather than resolving to a plausible name: rebinding a `play` on `q[0].drive` onto a transmon coupler raises `AttributeError: 'CouplerBuses' object has no attribute 'drive'`, because a coupler declares only `flux`. Auto-allocated measurement names are re-derived from the new bus while user-supplied names are left alone; see [Measurements and results](measurements.md). + +`rebind` returns a new `QProgram` and mutates nothing. The copy is deep, and fragment calls are expanded into it first when the program has any, so the result holds the inlined bodies rather than the calls; see [Fragments](fragments.md). The schema is copied along with the program unless you pass one, and the schema-identity check compares by identity, so a ref held from the original schema cannot be appended to the rebound program; read the schema back off the result if you want to keep building. ```python schema = qp.BusSchema.transmon() @@ -471,21 +299,13 @@ renamed = program.rebind(naming=qp.BusNaming("{kind}_{element}{index}_bus")) sorted(renamed.buses) # ["drive_q0_bus", "readout_q0_bus"] ``` -A raw-string bus carries no element, index or kind, so there is nothing to -re-resolve it from, and `rebind` will not guess. A raw string that `strings` -does not cover fails the whole call: +A raw-string bus carries no element, index or kind, so there is nothing to re-resolve it from, and `rebind` will not guess. A raw string that `strings` does not cover fails the whole call: ``` ValidationError: rebind left raw-string bus(es) unported: 'aux_line'. Raw strings carry no schema metadata to re-resolve — map them via strings={...} (map a name to itself to keep it), or pass allow_unported_strings=True to leave them in place. ``` -The two ways past that differ in what the call records. `strings` renames the -buses it lists, and mapping a name to itself keeps it while saying in the source -that keeping it was the intent. `allow_unported_strings=True` lifts the failure -for every uncovered string at once, and what it costs is that record: the -strings stay pointing at the old buses in a program whose schema-backed refs -have all moved, and neither the call nor the result says which ones were left -behind. +The two ways past that differ in what the call records. `strings` renames the buses it lists, and mapping a name to itself keeps it while saying in the source that keeping it was the intent. `allow_unported_strings=True` lifts the failure for every uncovered string at once, and what it costs is that record: the strings stay pointing at the old buses in a program whose schema-backed refs have all moved, and neither the call nor the result says which ones were left behind. ```python mixed = qp.QProgram(label="mixed", schema=schema) @@ -506,7 +326,4 @@ mixed.rebind(elements={("q", 0): ("q", 1)}, allow_unported_strings=True) # kept | `qp.BusSchema.transmon().q[0].drive` | one line | channel, ADC | yes | | `platform.get_bus_schema()` | from platform | channel, ADC | yes | -All three produce a `str` at the AST level. Mix them freely. The third is the -one to reach for when a platform is in the loop: `get_bus_schema` is on -`PlatformProtocol`, so the schema comes from the same object that will run the -program, and the bus names it produces are the ones that platform expects. +All three produce a `str` at the AST level. Mix them freely. The third is the one to reach for when a platform is in the loop: `get_bus_schema` is on `PlatformProtocol`, so the schema comes from the same object that will run the program, and the bus names it produces are the ones that platform expects. diff --git a/docs/guide/capabilities.md b/docs/guide/capabilities.md index 6e65318..db4c316 100644 --- a/docs/guide/capabilities.md +++ b/docs/guide/capabilities.md @@ -1,26 +1,12 @@ # Capabilities, diagnostics, and profiles -A QProgram describes *what* you want to happen. A platform is what actually runs -it. Different platforms support different subsets of the language: one might cap -loop nesting at four levels, another might accept arbitrary numpy sweeps at -waveform parameters but not at `Wait.duration`, another might not implement a -particular vendor operation. Within a single platform, a logical bus may be wired -to a different physical instrument than its siblings (drive on a fast waveform -generator, flux on a slow DAC), and an operation may run as a real-time hardware -loop on one path or as host-side (software-timed) per-shot orchestration on -another. - -The capability protocol is how a platform declares which features it supports -per bus and per execution domain, and how QProgram catches programs that exceed -the declaration *before* anything reaches the sequencer. `qp.validate(program, -caps)` and `qp.explain(program, caps)` are functions of a program and a -descriptor and nothing else, so either runs on any program at any point, with no -platform handle and no connected instrument. +A QProgram describes *what* you want to happen. A platform is what actually runs it. Different platforms support different subsets of the language: one might cap loop nesting at four levels, another might accept arbitrary numpy sweeps at waveform parameters but not at `Wait.duration`, another might not implement a particular vendor operation. Within a single platform, a logical bus may be wired to a different physical instrument than its siblings (drive on a fast waveform generator, flux on a slow DAC), and an operation may run as a real-time hardware loop on one path or as host-side (software-timed) per-shot orchestration on another. + +The capability protocol is how a platform declares which features it supports per bus and per execution domain, and how QProgram catches programs that exceed the declaration *before* anything reaches the sequencer. `qp.validate(program, caps)` and `qp.explain(program, caps)` are functions of a program and a descriptor and nothing else, so either runs on any program at any point, with no platform handle and no connected instrument. ## The shape of a descriptor -`qp.PlatformCapabilities` is the whole declaration. It is a frozen dataclass -with three fields: +`qp.PlatformCapabilities` is the whole declaration. It is a frozen dataclass with three fields: | Field | Type | Holds | |-----------------------|-------------------------------------------------|---------------------------------------------------------------------------------------------------| @@ -28,20 +14,11 @@ with three fields: | `platform` | `BusCapabilities` | The platform-wide slot: block tokens, expression tokens, and bus-less operations. | | `default_bus_profile` | `BusCapabilities` | Fallback for raw-string buses, and for bus-touching ops whose `(element, kind)` key is not in `bus`. | -The per-bus grain exists because drive and readout buses commonly live on -different hardware than flux buses. The platform-wide grain covers what belongs -to no single bus: control-flow blocks, expression node kinds, and sweep shapes. +The per-bus grain exists because drive and readout buses commonly live on different hardware than flux buses. The platform-wide grain covers what belongs to no single bus: control-flow blocks, expression node kinds, and sweep shapes. -Each of those slots is a `qp.BusCapabilities`, which splits the slot into its two -execution domains. `rt` describes what runs on the real-time hardware sequencer; -`host` describes what runs as per-iteration dispatch from the lab server. Either -half may be `None` when the slot has no engine for that domain: a flux bus driven -only by a slow DAC has `rt=None`, a real-time-only bus has `host=None`. Read one -half with `slot.get("rt")`, and the non-`None` ones with -`slot.supported_domains()`, which returns a subset of `{"rt", "host"}`. +Each of those slots is a `qp.BusCapabilities`, which splits the slot into its two execution domains. `rt` describes what runs on the real-time hardware sequencer; `host` describes what runs as per-iteration dispatch from the lab server. Either half may be `None` when the slot has no engine for that domain: a flux bus driven only by a slow DAC has `rt=None`, a real-time-only bus has `host=None`. Read one half with `slot.get("rt")`, and the non-`None` ones with `slot.supported_domains()`, which returns a subset of `{"rt", "host"}`. -A slot half is a `qp.CompilerCapabilities`, the descriptor the validator actually -consumes. There is no separate "advertised" and "enforced" surface; this is both. +A slot half is a `qp.CompilerCapabilities`, the descriptor the validator actually consumes. There is no separate "advertised" and "enforced" surface; this is both. | Field | Type | Holds | |-------------------|--------------------------------------------|------------------------------------------------------------------------------| @@ -52,31 +29,15 @@ consumes. There is no separate "advertised" and "enforced" surface; this is both | `predicates` | `tuple[Predicate, ...]` | Merged predicates, parent's first, run against every visited node. | | `vendor_versions` | `Mapping[str, tuple[int, int, int]]` | Which vendor-extension versions the profile was written against. Informational; mirrors the `.qp` `require ` line. | -`capabilities`, `limits`, and `predicates` are the three orthogonal capability -axes. Tokens are flags: either the slot advertises `op.play` or it does not. -Limits are numbers the validator measures the program against. Predicates are -callables that inspect an AST node with cross-node data-flow facts in hand, -which is what makes a constraint like "a wait may be swept, but only with evenly -spaced values" expressible at all. +`capabilities`, `limits`, and `predicates` are the three orthogonal capability axes. Tokens are flags: either the slot advertises `op.play` or it does not. Limits are numbers the validator measures the program against. Predicates are callables that inspect an AST node with cross-node data-flow facts in hand, which is what makes a constraint like "a wait may be swept, but only with evenly spaced values" expressible at all. -A `qp.Profile` is a named, versioned bundle of the same three axes plus -`vendor_versions`, and `CompilerCapabilities.from_profile(name)` materializes -one. Profiles are domain-agnostic: a platform decides which profile fills each -slot half, and the same profile commonly fills both. +A `qp.Profile` is a named, versioned bundle of the same three axes plus `vendor_versions`, and `CompilerCapabilities.from_profile(name)` materializes one. Profiles are domain-agnostic: a platform decides which profile fills each slot half, and the same profile commonly fills both. ## Routing: which slot is checked? -The validator decides where each AST node's required tokens are checked from -what the node touches. Slot lookup for a bus goes through -`caps.for_bus(bus)`: a `BusRef` that carries schema metadata resolves to -`caps.bus[(bus.element, bus.kind)]` when that key is present, and a plain `str` -or a schema-less `BusRef` always falls through to `caps.default_bus_profile`. +The validator decides where each AST node's required tokens are checked from what the node touches. Slot lookup for a bus goes through `caps.for_bus(bus)`: a `BusRef` that carries schema metadata resolves to `caps.bus[(bus.element, bus.kind)]` when that key is present, and a plain `str` or a schema-less `BusRef` always falls through to `caps.default_bus_profile`. -A two-slot descriptor makes the difference visible. `schema.q[0].drive` is a -`BusRef` with `element == "q"` and `kind == "drive"`, so it finds the fast -waveform generator's slot; the flux bus finds a slot with no real-time half at -all; and the raw string `"drive_q0"` carries no schema to key on, so it falls -through to the default: +A two-slot descriptor makes the difference visible. `schema.q[0].drive` is a `BusRef` with `element == "q"` and `kind == "drive"`, so it finds the fast waveform generator's slot; the flux bus finds a slot with no real-time half at all; and the raw string `"drive_q0"` carries no schema to key on, so it falls through to the default: ```python import qprogram as qp @@ -123,23 +84,13 @@ The node-to-slot table: | Bus-less op (empty `BUS_ATTRS`, e.g. a vendor alias-addressed op) | `caps.platform` | | Any block (`sweep`, `average`, `parallel`, `conditional`, plain `block`) | `caps.platform` | -Which attributes hold buses is a class fact, `Operation.BUS_ATTRS`, defaulting -to `("bus",)`. `Sync` holds a list under `targets` instead, and sets -`BROADCASTS_WHEN_NO_BUS = True` so that an empty target list means every bus -rather than none. +Which attributes hold buses is a class fact, `Operation.BUS_ATTRS`, defaulting to `("bus",)`. `Sync` holds a list under `targets` instead, and sets `BROADCASTS_WHEN_NO_BUS = True` so that an empty target list means every bus rather than none. -Within the routed slot the node's required tokens split by namespace: tokens -under `expr.*` check against `caps.platform` regardless of routing, because they -describe which expression node kinds the platform's compiler accepts rather than -anything a particular instrument can do. Every other token checks against the -primary routed slot. +Within the routed slot the node's required tokens split by namespace: tokens under `expr.*` check against `caps.platform` regardless of routing, because they describe which expression node kinds the platform's compiler accepts rather than anything a particular instrument can do. Every other token checks against the primary routed slot. ## A first validation -`qp.reference_capabilities()` builds the in-tree software platform's descriptor: -every token in the live registry, with `set_parameter` and `get_parameter` -present only in each bus slot's `host` half. That makes it a real descriptor to -try things against, so the examples below run as written. +`qp.reference_capabilities()` builds the in-tree software platform's descriptor: every token in the live registry, with `set_parameter` and `get_parameter` present only in each bus slot's `host` half. That makes it a real descriptor to try things against, so the examples below run as written. ```python import qprogram as qp @@ -156,17 +107,9 @@ with program.average(1000), program.sweep(lo, qp.Range(5e9, 6e9, 1e6)): diagnostics, plan = qp.validate(program, caps) ``` -`qp.validate` returns a `(diagnostics, plan)` pair and never raises. A platform's -`execute()` typically calls it first and raises -`UnsupportedOperationError` if any `severity="error"` diagnostic is present; -`severity="warning"` diagnostics are surfaced without raising, and -`severity="info"` diagnostics are passed through as advisory output. -`qp.ReferencePlatform` follows that convention exactly, re-emitting warnings -through `warnings.warn` as `ExecutionWarning`. +`qp.validate` returns a `(diagnostics, plan)` pair and never raises. A platform's `execute()` typically calls it first and raises `UnsupportedOperationError` if any `severity="error"` diagnostic is present; `severity="warning"` diagnostics are surfaced without raising, and `severity="info"` diagnostics are passed through as advisory output. `qp.ReferencePlatform` follows that convention exactly, re-emitting warnings through `warnings.warn` as `ExecutionWarning`. -If you have a live platform handle, the same work lives on it. All three are -`PlatformProtocol` defaults; `validate` and `plan` delegate to `qp.validate` and -discard the half they do not return, so ask for both at once when you need both: +If you have a live platform handle, the same work lives on it. All three are `PlatformProtocol` defaults; `validate` and `plan` delegate to `qp.validate` and discard the half they do not return, so ask for both at once when you need both: ```python platform.validate(program) # list[Diagnostic] @@ -176,48 +119,17 @@ platform.explain(program) # str ## What the validator does -Two walks, in order. A program containing fragment `Call` nodes is expanded -first, so both walks see the substituted fragment bodies rather than the call -sites. - -The first walk builds a `qp.ValidationContext`: which loop binds each variable, -each bound variable's sweep kind, the deepest repetition nesting, the largest -`Parallel` arity, the measurement count, the requested fields per measurement -name, and the set of buses the program references. These are the facts no single -node can answer for itself, gathered once so that predicates do not each -re-walk the tree. - -The second walk is a single recursive post-order pass. Per node it resolves the -routed slots, checks the required tokens against each domain half, runs that -half's predicates, and records two domain sets: `available`, the domains the slot -would allow, and `support`, what is left after `DomainConstraint`s apply. -Post-order matters because a block is classified from children whose support is -already known. - -For an operation, `support` equals `available`; a `DomainConstraint` never -subtracts from an operation. For a block, `support` is its own slot's allowance -intersected with the consensus of its immediate op-children, minus whatever -constraints target it. When that subtraction leaves nothing, a block whose -op-children consensus is exactly `{rt}` and whose own slot carries `host` falls -back to `{host}` instead of to nothing: the operations stay real-time and only -the loop's iteration mechanism moves host-side. Block-children are not part of -that consensus. A host-side-only block-child instead acts as an implicit -`exclude={"rt"}` on its parent, since a real-time parent cannot host a host-side -sub-block. An `average` is the one block whose consensus is narrowed further: it -accumulates measurement results, so only op-children with `AFFECTS_AVERAGING` -set decide its domain, and the rest still validate and run without pulling the -average host-side. An `average` holding no such child falls back to all of its -op-children, so the narrowing can never widen a domain. - -An op-child that can run nowhere empties its parent's support without a second -diagnostic, because the child's own diagnostic already explains it, and it is -also excluded from the consensus so that it cannot manufacture a misleading -`mixed-domain` error on the parent. - -Four finishing steps run after the walks: the whole-program limit checks, the -profile-independent `Conditional` checks, one `forced-host` warning per highest -forced block, and the `reorderable-averaging` hints. Then every node-bearing -diagnostic is stamped with its structural path. +Two walks, in order. A program containing fragment `Call` nodes is expanded first, so both walks see the substituted fragment bodies rather than the call sites. + +The first walk builds a `qp.ValidationContext`: which loop binds each variable, each bound variable's sweep kind, the deepest repetition nesting, the largest `Parallel` arity, the measurement count, the requested fields per measurement name, and the set of buses the program references. These are the facts no single node can answer for itself, gathered once so that predicates do not each re-walk the tree. + +The second walk is a single recursive post-order pass. Per node it resolves the routed slots, checks the required tokens against each domain half, runs that half's predicates, and records two domain sets: `available`, the domains the slot would allow, and `support`, what is left after `DomainConstraint`s apply. Post-order matters because a block is classified from children whose support is already known. + +For an operation, `support` equals `available`; a `DomainConstraint` never subtracts from an operation. For a block, `support` is its own slot's allowance intersected with the consensus of its immediate op-children, minus whatever constraints target it. When that subtraction leaves nothing, a block whose op-children consensus is exactly `{rt}` and whose own slot carries `host` falls back to `{host}` instead of to nothing: the operations stay real-time and only the loop's iteration mechanism moves host-side. Block-children are not part of that consensus. A host-side-only block-child instead acts as an implicit `exclude={"rt"}` on its parent, since a real-time parent cannot host a host-side sub-block. An `average` is the one block whose consensus is narrowed further: it accumulates measurement results, so only op-children with `AFFECTS_AVERAGING` set decide its domain, and the rest still validate and run without pulling the average host-side. An `average` holding no such child falls back to all of its op-children, so the narrowing can never widen a domain. + +An op-child that can run nowhere empties its parent's support without a second diagnostic, because the child's own diagnostic already explains it, and it is also excluded from the consensus so that it cannot manufacture a misleading `mixed-domain` error on the parent. + +Four finishing steps run after the walks: the whole-program limit checks, the profile-independent `Conditional` checks, one `forced-host` warning per highest forced block, and the `reorderable-averaging` hints. Then every node-bearing diagnostic is stamped with its structural path. ## Diagnostics @@ -234,8 +146,7 @@ diagnostic is stamped with its structural path. | `limit` | A `(name, observed_value)` tuple when a numeric limit fired. The threshold itself stays in `CompilerCapabilities.limits`. | | `domain` | On `forced-host`, the domain the node ended up in. On `missing-capability`, the single domain the token was missing from, or `None` when it was missing from both. | -`__str__` renders as `[severity] code: message (at path)`, which is what the -examples on this page print. +`__str__` renders as `[severity] code: message (at path)`, which is what the examples on this page print. The codes the validator emits, and the condition that produces each: @@ -252,9 +163,7 @@ The codes the validator emits, and the condition that produces each: | `forced-host` | warning | A block's support fell to `{host}` while its `available` still contained `rt`, so real-time would have been viable without the constraints. One per highest block in each forced chain. | | `reorderable-averaging` | info | An `average` is host-side-only only because it encloses a host-side sweep whose measurement sequence is real-time-capable, in the exact shape `qp.optimize` can rewrite. | -Those ten are the whole set. The `parse-error` code you may also come across -belongs to `check_text` in `qprogram.lsp`, which reports `.qp` text that fails to -parse before there is a program to validate at all. +Those ten are the whole set. The `parse-error` code you may also come across belongs to `check_text` in `qprogram.lsp`, which reports `.qp` text that fails to parse before there is a program to validate at all. Real messages, from a slot that advertises nothing: @@ -282,22 +191,13 @@ for diag in qp.validate(unsupported, empty_caps)[0]: [error] missing-capability: 'Play' requires capability 'waveform.alias' which is not supported by 'no-op-v1' (rt) / 'no-op-v1' (host) (at body[0]) ``` -Set `host=None` on that slot and the same two diagnostics name only `(rt)` and -carry `domain="rt"`; drop both halves and the token check never runs, so the -diagnostic becomes `[error] empty-domain: 'Play' has no executable domain on -its routed slot. (at body[0])` +Set `host=None` on that slot and the same two diagnostics name only `(rt)` and carry `domain="rt"`; drop both halves and the token check never runs, so the diagnostic becomes `[error] empty-domain: 'Play' has no executable domain on its routed slot. (at body[0])` -When at least one domain supports a node, the complaints from the other domain -are suppressed, because the fallback worked; diagnostics appear only when no -domain can run the node. What does surface is deduplicated twice over. There is -one diagnostic per missing token rather than one per domain, which is why the -two above each name both `(rt)` and `(host)`, and a predicate registered on both -halves of a slot contributes its equal diagnostic once. +When at least one domain supports a node, the complaints from the other domain are suppressed, because the fallback worked; diagnostics appear only when no domain can run the node. What does surface is deduplicated twice over. There is one diagnostic per missing token rather than one per domain, which is why the two above each name both `(rt)` and `(host)`, and a predicate registered on both halves of a slot contributes its equal diagnostic once. ## Capability tokens -Tokens are flat dotted strings. The prefix determines both what the token means -and which grain it lives on: +Tokens are flat dotted strings. The prefix determines both what the token means and which grain it lives on: | Prefix | Examples | Lives on | |------------------------|---------------------------------------------------------------------|-----------------| @@ -311,26 +211,15 @@ and which grain it lives on: | `measure.fields.` | `measure.fields.iq`, `measure.fields.raw`, `measure.fields.state` | bus | | `vendor..` | `vendor.myvendor.acquire`, `vendor.myvendor.active_reset` | bus, or platform for bus-less vendor ops | -Every token any in-tree `required_capabilities()` may emit is listed in -`qp.protocol.CAPABILITY_REGISTRY`, and `Profile.__post_init__` validates its -token set against it. A typo in a vendor package therefore fails at profile -construction rather than being silently accepted and never matching: +Every token any in-tree `required_capabilities()` may emit is listed in `qp.protocol.CAPABILITY_REGISTRY`, and `Profile.__post_init__` validates its token set against it. A typo in a vendor package therefore fails at profile construction rather than being silently accepted and never matching: ``` ValueError: Unknown capability token(s): ['op.zap']. Register via qprogram.protocol.register_capability_tokens before use. ``` -Vendors widen the registry with `qp.register_capability_tokens(*tokens)`, which -is idempotent and rejects only malformed shapes (empty, leading or trailing dot, -doubled dot). Each vendor owns its own `vendor..*` prefix. Registering -`measure.fields.` also widens what `fields=` accepts at the `measure(...)` -call site, since `qp.protocol.known_measurement_fields()` is derived from the -registry. +Vendors widen the registry with `qp.register_capability_tokens(*tokens)`, which is idempotent and rejects only malformed shapes (empty, leading or trailing dot, doubled dot). Each vendor owns its own `vendor..*` prefix. Registering `measure.fields.` also widens what `fields=` accepts at the `measure(...)` call site, since `qp.protocol.known_measurement_fields()` is derived from the registry. -Every `Operation` and `Block` declares the tokens *it* needs through -`required_capabilities()`. The set is **instance-aware** and -**domain-agnostic**: it depends on the node's data, not just its class, and is -the same set whether the validator checks `rt` or `host`. +Every `Operation` and `Block` declares the tokens *it* needs through `required_capabilities()`. The set is **instance-aware** and **domain-agnostic**: it depends on the node's data, not just its class, and is the same set whether the validator checks `rt` or `host`. ```python qp.operations.Play("drive_q0", qp.waveforms.Square(0.5, 100)).required_capabilities() @@ -349,30 +238,17 @@ qp.blocks.Sweep(qp.Variable("f"), qp.Values([1, 2, 4])).required_capabilities() # {'block.sweep', 'sweep.arbitrary', 'sweep.values'} ``` -Each method is non-recursive: the validator visits every node and checks that -node's own set against the slot that node routes to, so a node that recursed -into its children would double-count them, and would check a child's tokens -against its parent's slot. +Each method is non-recursive: the validator visits every node and checks that node's own set against the slot that node routes to, so a node that recursed into its children would double-count them, and would check a child's tokens against its parent's slot. ## Real-time vs host-side classification -The DSL makes no syntactic distinction between real-time and host-side -(software-timed) loops. The same `sweep` may run in either domain depending on -what is inside it. The validator's classifier picks, and the answer is in the -`ExecutionPlan`. +The DSL makes no syntactic distinction between real-time and host-side (software-timed) loops. The same `sweep` may run in either domain depending on what is inside it. The validator's classifier picks, and the answer is in the `ExecutionPlan`. ### `DomainConstraint`: predicates that narrow the domain instead of erroring -The canonical example: a sequencer can hold a real-time loop sweeping -`IQDrag.amplitude` (a register write per iteration) but cannot recompute -`IQDrag.sigma` between iterations, because the Gaussian envelope is precomputed -at upload. Sweeping `sigma` in a real-time loop has to fall back to -per-iteration host-side dispatch, and per-iteration dispatch *does* work. +The canonical example: a sequencer can hold a real-time loop sweeping `IQDrag.amplitude` (a register write per iteration) but cannot recompute `IQDrag.sigma` between iterations, because the Gaussian envelope is precomputed at upload. Sweeping `sigma` in a real-time loop has to fall back to per-iteration host-side dispatch, and per-iteration dispatch *does* work. -Profiles express that with a `qp.DomainConstraint`, which carries three fields: -the `node` it applies to, the `exclude` frozenset of domains that node cannot -run in, and a `reason` string that surfaces in the eventual `forced-host` -message. +Profiles express that with a `qp.DomainConstraint`, which carries three fields: the `node` it applies to, the `exclude` frozenset of domains that node cannot run in, and a `reason` string that surfaces in the eventual `forced-host` message. ```python def drag_sigma_is_host_only(node, ctx): @@ -389,68 +265,28 @@ def drag_sigma_is_host_only(node, ctx): ) ``` -A constraint's `node` **must be a `Block`**: the loop whose binding is the -problem, which is what `ctx.binding_loop_of(var)` returns. Targeting the -operation instead is reported as `bad-domain-constraint` and the constraint is -dropped, because it is the loop that has to move host-side, not the pulse. The -classifier subtracts the exclusion from the target block's support set, and every -ancestor inherits the result through the host-side propagation rule. +A constraint's `node` **must be a `Block`**: the loop whose binding is the problem, which is what `ctx.binding_loop_of(var)` returns. Targeting the operation instead is reported as `bad-domain-constraint` and the constraint is dropped, because it is the loop that has to move host-side, not the pulse. The classifier subtracts the exclusion from the target block's support set, and every ancestor inherits the result through the host-side propagation rule. -`Diagnostic` is reserved for hard outcomes, cases the compiler genuinely cannot -run anywhere; the validator surfaces one when no domain can run the node. -`DomainConstraint` is the soft outcome: the block *would* be supported, except in -the listed domains, and it stays silent whenever a domain fallback works. A -predicate may yield any mixture of the two from one call. +`Diagnostic` is reserved for hard outcomes, cases the compiler genuinely cannot run anywhere; the validator surfaces one when no domain can run the node. `DomainConstraint` is the soft outcome: the block *would* be supported, except in the listed domains, and it stays silent whenever a domain fallback works. A predicate may yield any mixture of the two from one call. ### `forced-host` warnings -When a block's support is reduced to `{host}` from a set that included `rt`, the -validator emits one `severity="warning"` `forced-host` diagnostic on the -*highest* block in that forced chain. Ancestors that are host-side-only purely -because of this child are not separately reported, which keeps the output -skimmable. The message names that block's **immediate** cause: its own -`DomainConstraint` reasons if any target it, otherwise the host-side-only -sub-block it contains, with that sub-block's reasons in parentheses. The reasons -for a sub-block are gathered from its whole subtree, so a constraint two levels -down still explains the warning. - -The condition is `support == {host}` **and** `"rt" in available`. A block that -never had `rt` in the first place is natively host-side, not forced, and gets no -warning. In the `lo_sweep` program above, the `sweep` contains a `set_parameter` -that only the bus slot's `host` half advertises, so the sweep's op-children -consensus is `{host}` from the start and it is silent. The `average` around it -had both domains available (only measurements gate an `average`, and the measure -is real-time-capable), lost `rt` to the sweep, and so carries the warning: +When a block's support is reduced to `{host}` from a set that included `rt`, the validator emits one `severity="warning"` `forced-host` diagnostic on the *highest* block in that forced chain. Ancestors that are host-side-only purely because of this child are not separately reported, which keeps the output skimmable. The message names that block's **immediate** cause: its own `DomainConstraint` reasons if any target it, otherwise the host-side-only sub-block it contains, with that sub-block's reasons in parentheses. The reasons for a sub-block are gathered from its whole subtree, so a constraint two levels down still explains the warning. + +The condition is `support == {host}` **and** `"rt" in available`. A block that never had `rt` in the first place is natively host-side, not forced, and gets no warning. In the `lo_sweep` program above, the `sweep` contains a `set_parameter` that only the bus slot's `host` half advertises, so the sweep's op-children consensus is `{host}` from the start and it is silent. The `average` around it had both domains available (only measurements gate an `average`, and the measure is real-time-capable), lost `rt` to the sweep, and so carries the warning: ``` [warning] forced-host: Block 'Average' falls back to host-side execution: contains host-side-only sub-block 'Sweep' (parameter 'lo_frequency' is swept via set_parameter (host-side dispatch per iteration)). (at body[0]) [info] reorderable-averaging: Block 'Average' runs host-side only because it encloses a host-side sweep; its measurement sequence supports real-time hardware. Moving the sweep outside the average (hoisting the host-side-only setup with it) would let the averaging run in real-time hardware — see qprogram.optimize(). (at body[0]) ``` -The `reorderable-averaging` hint fires only for the shape `qp.optimize` can -actually rewrite: an `average` whose sole child is one flat sweep whose body is a -leading contiguous run of host-side-only ops followed by real-time-capable ops -including at least one measurement. Hoisting a host-side op that sits *after* a -kept op would reorder it past that op and could change results, so such an -average is not reorderable. The validator cannot prove the reorder preserves -intent either, since interleaved and grouped shots differ on a drifting device, -which is why it suggests rather than rewrites. +The `reorderable-averaging` hint fires only for the shape `qp.optimize` can actually rewrite: an `average` whose sole child is one flat sweep whose body is a leading contiguous run of host-side-only ops followed by real-time-capable ops including at least one measurement. Hoisting a host-side op that sits *after* a kept op would reorder it past that op and could change results, so such an average is not reorderable. The validator cannot prove the reorder preserves intent either, since interleaved and grouped shots differ on a drifting device, which is why it suggests rather than rewrites. ## The execution plan -The second return value of `qp.validate` is an `ExecutionPlan`, a -`Mapping[Operation | Block, frozenset[Domain]]`. `frozenset({"rt"})` means a -real-time hardware path, `frozenset({"host"})` means host-side dispatch, -`frozenset({"rt", "host"})` means the platform may pick either at compile time, -and an empty frozenset means nothing can run the node, which always comes with an -error diagnostic explaining why. +The second return value of `qp.validate` is an `ExecutionPlan`, a `Mapping[Operation | Block, frozenset[Domain]]`. `frozenset({"rt"})` means a real-time hardware path, `frozenset({"host"})` means host-side dispatch, `frozenset({"rt", "host"})` means the platform may pick either at compile time, and an empty frozenset means nothing can run the node, which always comes with an error diagnostic explaining why. -The plan covers every visited node except the root body, in the post-order the -classifier walked. It is keyed by node **identity**, not by structural equality: -two `play "drive_q0" "pi_pulse"` operations compare equal yet get one entry each, -so a compiler can give the same pulse different treatment at different call -sites. A plain `dict` would have collapsed them and a three-operation program -would come back with two entries. +The plan covers every visited node except the root body, in the post-order the classifier walked. It is keyed by node **identity**, not by structural equality: two `play "drive_q0" "pi_pulse"` operations compare equal yet get one entry each, so a compiler can give the same pulse different treatment at different call sites. A plain `dict` would have collapsed them and a three-operation program would come back with two entries. ```python for node, domains in plan.items(): @@ -465,18 +301,11 @@ Sweep ['host'] Average ['host'] ``` -Operations were classified from their slots, the sweep from its op-children, and -the average from the sweep it contains. +Operations were classified from their slots, the sweep from its op-children, and the average from the sweep it contains. ## Seeing the plan: `explain()` -`qp.explain(program, caps)`, or `platform.explain(program)`, renders the same -conclusion as a tree. Each body node appears as its `.qp` text, with the domain -set in an aligned column (`[rt|host]`, `[rt]`, `[host]`, or `[--]` for no -executable domain) and diagnostics annotated inline: `!!` for errors, `~` for -warnings, `i` for info. Node-less diagnostics such as the whole-program limits -land in a footer. The header carries the program label and the counts by -severity, and says so when a program with fragment calls was expanded first. +`qp.explain(program, caps)`, or `platform.explain(program)`, renders the same conclusion as a tree. Each body node appears as its `.qp` text, with the domain set in an aligned column (`[rt|host]`, `[rt]`, `[host]`, or `[--]` for no executable domain) and diagnostics annotated inline: `!!` for errors, `~` for warnings, `i` for info. Node-less diagnostics such as the whole-program limits land in a footer. The header carries the program label and the counts by severity, and says so when a program with fragment calls was expanded first. ```python print(qp.explain(program, caps)) @@ -492,19 +321,11 @@ body └─ measure "readout_q0" "readout" "weights" name="m0" [rt|host] ``` -Rows come from the `.qp` writer's own serializers, so a row reads the way the -program would be written to a file, and a node the writer cannot serialize falls -back to its `repr`. A `forced-host` annotation is shortened to its reason clause, -since the row it sits on already names the block. A `Conditional` renders as an -`if/elif/else chain` row with one row per arm. +Rows come from the `.qp` writer's own serializers, so a row reads the way the program would be written to a file, and a node the writer cannot serialize falls back to its `repr`. A `forced-host` annotation is shortened to its reason clause, since the row it sits on already names the block. A `Conditional` renders as an `if/elif/else chain` row with one row per arm. ### From a diagnostic to a `.qp` line -Every node-bearing diagnostic carries a structural `path`. Resolve it against the -program with `qp.resolve_path(program, diag.path)`, or map it to a line in the -serialized text. `loads()` records `program.source_map` as path to 1-based line, -and because the round-trip preserves structure, a path computed against the built -program looks up directly in the reloaded one: +Every node-bearing diagnostic carries a structural `path`. Resolve it against the program with `qp.resolve_path(program, diag.path)`, or map it to a line in the serialized text. `loads()` records `program.source_map` as path to 1-based line, and because the round-trip preserves structure, a path computed against the built program looks up directly in the reloaded one: ```python diag = diagnostics[0] @@ -516,8 +337,7 @@ line = qp.loads(text).source_map[diag.path] print(text.splitlines()[line - 1]) # ' average 1000:' ``` -A path is a tuple of segments rooted at `program.body`, whose own path is `()`. -Four segment kinds occur, matching the child taxonomy the validator walks: +A path is a tuple of segments rooted at `program.body`, whose own path is `()`. Four segment kinds occur, matching the child taxonomy the validator walks: | Segment | Addresses | |--------------|---------------------------------------------------------------| @@ -526,8 +346,7 @@ Four segment kinds occur, matching the child taxonomy the validator walks: | `"else"` | A `Conditional`'s else body | | `"loop:"` | A `Parallel`'s i-th composed loop header, itself a `Sweep` | -`qp.format_path` renders integer segments in brackets and string segments after a -dot. A program with a `|` composition and an `else_` arm reaches all four: +`qp.format_path` renders integer segments in brackets and string segments after a dot. A program with a `|` composition and an `else_` arm reaches all four: ```python paths_program = qp.QProgram(label="paths") @@ -563,17 +382,11 @@ body[0][1].else Block body[0][1].else[0] Wait ``` -The `Parallel`'s two sweep headers are `loop:0` and `loop:1`, while its body -elements are plain integers, so a diagnostic on the `measure` carries `(0, 0)`. -Each arm body and the else body is a `Block` of its own, which is why a node -inside one takes two segments: the arm, then the index within it. A dangling -segment raises `KeyError` from `resolve_path`, naming the segment and the prefix -that did resolve. +The `Parallel`'s two sweep headers are `loop:0` and `loop:1`, while its body elements are plain integers, so a diagnostic on the `measure` carries `(0, 0)`. Each arm body and the else body is a `Block` of its own, which is why a node inside one takes two segments: the arm, then the index within it. A dangling segment raises `KeyError` from `resolve_path`, naming the segment and the prefix that did resolve. ## Numeric limits -A profile's `limits` dict carries numeric thresholds. The validator measures the -program and emits one `limit-exceeded` diagnostic per breach. It reads four keys: +A profile's `limits` dict carries numeric thresholds. The validator measures the program and emits one `limit-exceeded` diagnostic per breach. It reads four keys: | Limit | Lives on | Compared against | |--------------------------|-------------|---------------------------------------------------------| @@ -582,10 +395,7 @@ program and emits one `limit-exceeded` diagnostic per breach. It reads four keys | `max_measurements` | platform | Total `MeasurementOperation` count. | | `min_wait_duration_ns` | bus | Each `Wait.duration` that is a plain `int`, against the touched bus's limits. A duration given as an `Expression` has no static value to compare and is left unchecked. | -The three platform-level limits are read from whichever half of `caps.platform` -is present, preferring `rt` when both are, since the real-time engine is -typically the more constrained one. The messages name the observed value and the -threshold: +The three platform-level limits are read from whichever half of `caps.platform` is present, preferring `rt` when both are, since the real-time engine is typically the more constrained one. The messages name the observed value and the threshold: ``` [error] limit-exceeded: Program nests loops 2 deep; limit max_loop_nesting=1 @@ -594,14 +404,11 @@ threshold: [error] limit-exceeded: Wait duration 4 ns is shorter than min_wait_duration_ns=8 (at body[0][0][0]) ``` -A limit the platform does not declare is not checked, and a key outside those -four is ignored by the validator, so a profile may carry limits this validator -has no check for and a vendor compiler is free to read them itself. +A limit the platform does not declare is not checked, and a key outside those four is ignored by the validator, so a profile may carry limits this validator has no check for and a vendor compiler is free to read them itself. ### A live device can tighten limits -The profile's limits are *defaults*. A concrete device may know its specific -hardware is tighter and pass `limit_overrides` when materializing the descriptor: +The profile's limits are *defaults*. A concrete device may know its specific hardware is tighter and pass `limit_overrides` when materializing the descriptor: ```python tight = qp.CompilerCapabilities.from_profile( @@ -611,27 +418,13 @@ tight = qp.CompilerCapabilities.from_profile( tight.limits # {'max_loop_nesting': 4} ``` -Overrides are applied after the whole `extends` chain has merged, so they win -over every profile in it. Inside a platform implementation this is how -device-specific limits flow through without re-publishing the vendor profile. -`extra_predicates=(my_pred,)` does the same for a rack-level predicate that does -not belong in a vendor-shipped profile. +Overrides are applied after the whole `extends` chain has merged, so they win over every profile in it. Inside a platform implementation this is how device-specific limits flow through without re-publishing the vendor profile. `extra_predicates=(my_pred,)` does the same for a rack-level predicate that does not belong in a vendor-shipped profile. ## Predicates and the validation context -Some constraints depend on how *several* AST nodes interact, not on any one node -in isolation. The canonical hard-error example: `Wait.duration` accepts a -`Variable`, but on some backends the wait instruction takes a fixed-step -register, so a variable bound by an arbitrary-valued source (`Values`, -`Logspace`, `File`) is illegal while the same variable bound by a `Range` is -fine. A flat token cannot express that; the answer depends on the binding loop, -which is a different node. +Some constraints depend on how *several* AST nodes interact, not on any one node in isolation. The canonical hard-error example: `Wait.duration` accepts a `Variable`, but on some backends the wait instruction takes a fixed-step register, so a variable bound by an arbitrary-valued source (`Values`, `Logspace`, `File`) is illegal while the same variable bound by a `Range` is fine. A flat token cannot express that; the answer depends on the binding loop, which is a different node. -A predicate is any callable taking `(node, ctx)` and returning an iterable of -`Diagnostic` and `DomainConstraint` objects, in any order and any mixture. -`qp.Predicate` is the runtime-checkable protocol for that signature, and -`qp.PredicateFn` the plain callable alias, for authors who would rather not pull -`Protocol` into scope. +A predicate is any callable taking `(node, ctx)` and returning an iterable of `Diagnostic` and `DomainConstraint` objects, in any order and any mixture. `qp.Predicate` is the runtime-checkable protocol for that signature, and `qp.PredicateFn` the plain callable alias, for authors who would rather not pull `Protocol` into scope. ```python def reject_arbitrary_wait(node, ctx): @@ -648,15 +441,9 @@ def reject_arbitrary_wait(node, ctx): ) ``` -A node is judged against each domain half of each slot it routes to, so a -predicate carried by both halves of a profile runs once per (domain, bus) pair: -twice for a single-bus node, and twice more for every additional bus a multi-bus -operation such as `Sync` touches. Equal outputs are discarded, so the mistake -above is reported once rather than twice, but a predicate must be a cheap, -side-effect-free function of `(node, ctx)` for that to be true. +A node is judged against each domain half of each slot it routes to, so a predicate carried by both halves of a profile runs once per (domain, bus) pair: twice for a single-bus node, and twice more for every additional bus a multi-bus operation such as `Sync` touches. Equal outputs are discarded, so the mistake above is reported once rather than twice, but a predicate must be a cheap, side-effect-free function of `(node, ctx)` for that to be true. -The `qp.ValidationContext` the predicate receives is a read-only view of the -program-wide facts gathered by the first walk: +The `qp.ValidationContext` the predicate receives is a read-only view of the program-wide facts gathered by the first walk: | Query | Returns | |---------------------------------|------------------------------------------------------------------------| @@ -669,15 +456,11 @@ program-wide facts gathered by the first walk: | `ctx.known_measurement_names()` | Every measurement name in the program, whether the author spelled it or the builder allocated it. | | `ctx.program_buses` | Every bus the program references. Elements may be `BusRef`s, which subclass `str`, so per-bus routing keeps its schema awareness. | -New queries are added there rather than passed around separately, so predicate -authors have one surface to read. Treat the context as immutable. +New queries are added there rather than passed around separately, so predicate authors have one surface to read. Treat the context as immutable. ## Profile bundles -A `qp.Profile` is a named, versioned bundle of capabilities, limits, predicates, -and vendor versions, registered with `qp.register_profile`. Importing a vendor -package registers its profiles as a side effect, the same activation pattern the -serializer uses. +A `qp.Profile` is a named, versioned bundle of capabilities, limits, predicates, and vendor versions, registered with `qp.register_profile`. Importing a vendor package registers its profiles as a side effect, the same activation pattern the serializer uses. ```python import qprogram_myvendor # an example vendor: registers "myvendor-default-v1" @@ -688,40 +471,19 @@ profile.extends # the parent it inherits from, or None profile.limits ``` -`qp.resolve_profile(name)` raises `KeyError` for an unregistered name, and lists -what is available so a typo is easy to spot: +`qp.resolve_profile(name)` raises `KeyError` for an unregistered name, and lists what is available so a typo is easy to spot: ``` KeyError: "Unknown profile 'nope'. Available: qprogram-base-v1" ``` -`register_profile` is idempotent for an *equal* `Profile`, so an import-time -side effect that runs twice is safe even when it rebuilds the bundle each time. -Only different content under an existing name raises -`ValueError: Profile 'dup' is already registered with different content`. Of an -equal pair the registry keeps the first object, so the profile you just built is -not necessarily the one `resolve_profile` returns. - -Equality is the dataclass's own, field by field, and predicates count as the -objects they are. A profile whose predicates are rebuilt on each construction, a -`lambda` or a closure or a `functools.partial`, is never equal to a second -construction of itself and still raises; hold them as module-level functions to -get the idempotency. - -Core QProgram ships one profile, `qprogram-base-v1`, exposed as -`qp.QPROGRAM_BASE_V1` and registered as a side effect of `import qprogram`. It is -a root profile (`extends=None`) at version `(0, 1, 0)` with no limits and no -predicates, carrying 33 tokens: the five `block.*` tokens, the eighteen `expr.*` -tokens, the two `sweep.` tokens, and one `sweep.` token per -built-in source. Those are exactly the non-bus capabilities the DSL exposes, -which is why it fills a platform-level slot and nothing else. `op.set_parameter` -and `op.get_parameter` are bus-scoped ops, so their tokens belong on a bus -profile and are absent here. - -Core declares every built-in sweep source, so a platform that inherits this -profile accepts them all. A platform that wants to *refuse* one, no native log -sweep for instance, declares its own platform profile rather than extending this -one. There is no `removes=` field. +`register_profile` is idempotent for an *equal* `Profile`, so an import-time side effect that runs twice is safe even when it rebuilds the bundle each time. Only different content under an existing name raises `ValueError: Profile 'dup' is already registered with different content`. Of an equal pair the registry keeps the first object, so the profile you just built is not necessarily the one `resolve_profile` returns. + +Equality is the dataclass's own, field by field, and predicates count as the objects they are. A profile whose predicates are rebuilt on each construction, a `lambda` or a closure or a `functools.partial`, is never equal to a second construction of itself and still raises; hold them as module-level functions to get the idempotency. + +Core QProgram ships one profile, `qprogram-base-v1`, exposed as `qp.QPROGRAM_BASE_V1` and registered as a side effect of `import qprogram`. It is a root profile (`extends=None`) at version `(0, 1, 0)` with no limits and no predicates, carrying 33 tokens: the five `block.*` tokens, the eighteen `expr.*` tokens, the two `sweep.` tokens, and one `sweep.` token per built-in source. Those are exactly the non-bus capabilities the DSL exposes, which is why it fills a platform-level slot and nothing else. `op.set_parameter` and `op.get_parameter` are bus-scoped ops, so their tokens belong on a bus profile and are absent here. + +Core declares every built-in sweep source, so a platform that inherits this profile accepts them all. A platform that wants to *refuse* one, no native log sweep for instance, declares its own platform profile rather than extending this one. There is no `removes=` field. ```python platform_slot = qp.CompilerCapabilities.from_profile("qprogram-base-v1") @@ -731,11 +493,7 @@ platform_slot.supports("op.play") # False, that is a bus token ### Profiles can extend other profiles -A profile can inherit from another by name. `from_profile` walks the `extends` -chain root-first and merges: capabilities and predicates *accumulate* (parent to -child, with the parent's predicates ordered first), while limits and -`vendor_versions` *inherit and may be overridden* by the child. The resulting -`CompilerCapabilities.profile` and `.version` name the leaf, not the merge. +A profile can inherit from another by name. `from_profile` walks the `extends` chain root-first and merges: capabilities and predicates *accumulate* (parent to child, with the parent's predicates ordered first), while limits and `vendor_versions` *inherit and may be overridden* by the child. The resulting `CompilerCapabilities.profile` and `.version` name the leaf, not the merge. ```python strict = qp.Profile( @@ -749,18 +507,11 @@ strict = qp.Profile( qp.register_profile(strict) ``` -This mirrors QIR's profile design and is the only composition mode the protocol -supports; arbitrary intersection of unrelated profiles is out of scope. A cycle -in the `extends` chain is detected when the chain is walked, not when the -profiles are registered, and raises a `ValueError` naming the profile the walk -revisited (`Profile inheritance cycle detected at 'myplat-strict-v1'`). A parent -that was never registered raises `KeyError` from the same walk. +This mirrors QIR's profile design and is the only composition mode the protocol supports; arbitrary intersection of unrelated profiles is out of scope. A cycle in the `extends` chain is detected when the chain is walked, not when the profiles are registered, and raises a `ValueError` naming the profile the walk revisited (`Profile inheritance cycle detected at 'myplat-strict-v1'`). A parent that was never registered raises `KeyError` from the same walk. ## A descriptor built by hand -A platform normally builds its descriptor from registered profiles. Spelled out -by hand here so the example runs as written, with both predicates from this page -wired into the bus slot that checks them: +A platform normally builds its descriptor from registered profiles. Spelled out by hand here so the example runs as written, with both predicates from this page wired into the bus slot that checks them: ```python import qprogram as qp @@ -800,11 +551,7 @@ body └─ play "drive_q0" IQDrag(amplitude=0.5, duration=40, sigma=sigma, beta=0.1) [rt|host] ``` -The first sweep is unrunnable: the predicate's `Diagnostic` empties the wait's -domain set, and an op-child that can run nowhere empties its parent's too, -silently, because the child's own diagnostic already explains it. The second -sweep runs, host-side, and the `play` inside it stays real-time-capable. Only the -loop's iteration mechanism moved. +The first sweep is unrunnable: the predicate's `Diagnostic` empties the wait's domain set, and an op-child that can run nowhere empties its parent's too, silently, because the child's own diagnostic already explains it. The second sweep runs, host-side, and the `play` inside it stays real-time-capable. Only the loop's iteration mechanism moved. ## Quick reference @@ -826,11 +573,7 @@ loop's iteration mechanism moved. ## See also -- [Building a vendor extension](../developer/vendor-extensions.md): how vendors - ship their own profile. -- [Capability protocol internals](../developer/capability-protocol.md): the - design, and how to add tokens, predicates, and profiles. -- [Errors](../reference/errors.md): the platform-side exception families a - backend raises when validation reports problems. -- [API reference](../reference/api-qprogram.md#capability-protocol): - auto-generated reference for the capability-protocol types. +- [Building a vendor extension](../developer/vendor-extensions.md): how vendors ship their own profile. +- [Capability protocol internals](../developer/capability-protocol.md): the design, and how to add tokens, predicates, and profiles. +- [Errors](../reference/errors.md): the platform-side exception families a backend raises when validation reports problems. +- [API reference](../reference/api-qprogram.md#capability-protocol): auto-generated reference for the capability-protocol types. diff --git a/docs/guide/concepts.md b/docs/guide/concepts.md index 957766d..ea469b8 100644 --- a/docs/guide/concepts.md +++ b/docs/guide/concepts.md @@ -1,19 +1,12 @@ # Core ideas -QProgram is a fluent builder for a small AST. Everything else in the library -reads that one tree: `qp.dumps` writes it out, `qp.validate` classifies its -nodes against a platform, `qp.optimize` rewrites it, and a platform's `execute` -interprets it. +QProgram is a fluent builder for a small AST. Everything else in the library reads that one tree: `qp.dumps` writes it out, `qp.validate` classifies its nodes against a platform, `qp.optimize` rewrites it, and a platform's `execute` interprets it. ## The shape of a program -A `QProgram` owns a `label` and an optional `description`, a body, a list of -declared variables, and optionally a `BusSchema`. +A `QProgram` owns a `label` and an optional `description`, a body, a list of declared variables, and optionally a `BusSchema`. -The body is the root `Block`: every operation and sub-block you append lands in -it, in order. `variables` hands back a fresh list of the placeholders you -declared, in declaration order. `buses` is recomputed on every access by walking -the body. +The body is the root `Block`: every operation and sub-block you append lands in it, in order. `variables` hands back a fresh list of the placeholders you declared, in declaration order. `buses` is recomputed on every access by walking the body. ```python import qprogram as qp @@ -24,33 +17,15 @@ program.variables # list[Variable], in declaration order program.buses # set[str], recomputed from the body on each access ``` -`QProgram.buses` returns `body.buses()`, which unions each child's `buses()`, -and an operation's `buses()` reads the attributes its class lists in -`BUS_ATTRS`: `("bus",)` for most operations, `("targets",)` for `Sync`, and -empty for `Call`, which overrides `buses()` instead of reading the list. Two -things follow for a program that uses fragments. A bus named only inside a -fragment body stays invisible until `program.expand()` has substituted the call -arguments, and a `Call` reports every string-valued argument bound at its site, -because a `Parameter` is untyped and a string argument may be a bus, a waveform -alias, or neither. The set is therefore an over-approximation while calls are -unexpanded. +`QProgram.buses` returns `body.buses()`, which unions each child's `buses()`, and an operation's `buses()` reads the attributes its class lists in `BUS_ATTRS`: `("bus",)` for most operations, `("targets",)` for `Sync`, and empty for `Call`, which overrides `buses()` instead of reading the list. Two things follow for a program that uses fragments. A bus named only inside a fragment body stays invisible until `program.expand()` has substituted the call arguments, and a `Call` reports every string-valued argument bound at its site, because a `Parameter` is untyped and a string argument may be a bus, a waveform alias, or neither. The set is therefore an over-approximation while calls are unexpanded. -Calling something like `program.play(...)` does not run hardware. It appends a -typed `Play` node to the currently active block. +Calling something like `program.play(...)` does not run hardware. It appends a typed `Play` node to the currently active block. ## Blocks and operations -Every node in the AST is one of two things. Operations are the leaves: `Play`, -`Measure`, `Wait`, `Sync`, `SetFrequency`, `SetGain`, `SetOffset`, `SetPhase`, -`ResetPhase`, `SetParameter`, `GetParameter`, and `Call`. Blocks are the -containers: `Block` (a plain grouping with no extra semantics), `Sweep`, -`Average`, `Parallel`, and `Conditional`. +Every node in the AST is one of two things. Operations are the leaves: `Play`, `Measure`, `Wait`, `Sync`, `SetFrequency`, `SetGain`, `SetOffset`, `SetPhase`, `ResetPhase`, `SetParameter`, `GetParameter`, and `Call`. Blocks are the containers: `Block` (a plain grouping with no extra semantics), `Sweep`, `Average`, `Parallel`, and `Conditional`. -Every block carries an `elements` list of children, operations or nested blocks. -The property returns the block's own list rather than a copy, and `append` is -the sanctioned way to extend it. Reading a program back is a matter of indexing -into that list, as here for a program that plays one pulse and then averages a -gain sweep: +Every block carries an `elements` list of children, operations or nested blocks. The property returns the block's own list rather than a copy, and `append` is the sanctioned way to extend it. Reading a program back is a matter of indexing into that list, as here for a program that plays one pulse and then averages a gain sweep: ```python [type(el).__name__ for el in program.body.elements] @@ -61,35 +36,15 @@ gain sweep: # ['SetGain', 'Play', 'Measure'] ``` -Two blocks keep children somewhere other than `elements`. A `Conditional` holds -`arms`, a list of `(condition, body)` pairs in source order, plus an optional -`else_body`; there is no shared body to append to, so `Conditional.append` -raises `ValidationError` and the arms are populated through the builder methods. -A `Parallel` keeps its composed loop headers on `loops` and only the shared body -in `elements`, which is why it occupies one repetition level rather than one per -composed loop. - -Blocks that re-run their body set the class attribute `REPEATS` to `True`: -`Sweep`, `Average` (averaging is repetition), and `Parallel`. A plain `Block` and -a `Conditional` leave it `False`, since branching selects a body rather than -iterating over one. Validation reads `REPEATS` to compute loop nesting depth, so -a vendor block that repeats is counted against a platform's limit without any -change to the core. +Two blocks keep children somewhere other than `elements`. A `Conditional` holds `arms`, a list of `(condition, body)` pairs in source order, plus an optional `else_body`; there is no shared body to append to, so `Conditional.append` raises `ValidationError` and the arms are populated through the builder methods. A `Parallel` keeps its composed loop headers on `loops` and only the shared body in `elements`, which is why it occupies one repetition level rather than one per composed loop. + +Blocks that re-run their body set the class attribute `REPEATS` to `True`: `Sweep`, `Average` (averaging is repetition), and `Parallel`. A plain `Block` and a `Conditional` leave it `False`, since branching selects a body rather than iterating over one. Validation reads `REPEATS` to compute loop nesting depth, so a vendor block that repeats is counted against a platform's limit without any change to the core. ## Walking the tree -`Block.walk()` yields the block itself first, then every descendant in -pre-order: depth first, children in declaration order. `Operation.walk()` yields -just that operation, so a caller can walk any node without first testing its -type. +`Block.walk()` yields the block itself first, then every descendant in pre-order: depth first, children in declaration order. `Operation.walk()` yields just that operation, so a caller can walk any node without first testing its type. -The two blocks whose children live outside `elements` extend the walk. A -`Conditional` yields itself, then each arm body's nodes in source order, then -the `else` body's; the arm conditions are `Expression`s rather than AST nodes, -so they are not yielded and code that needs them reads `arms`. A `Parallel` -yields itself, then each composed `Sweep` header with its own descendants, then -the shared body, so a consumer meets the loops that bind the variables before -the operations that read them. +The two blocks whose children live outside `elements` extend the walk. A `Conditional` yields itself, then each arm body's nodes in source order, then the `else` body's; the arm conditions are `Expression`s rather than AST nodes, so they are not yielded and code that needs them reads `arms`. A `Parallel` yields itself, then each composed `Sweep` header with its own descendants, then the shared body, so a consumer meets the loops that bind the variables before the operations that read them. ```python program = qp.QProgram() @@ -103,49 +58,19 @@ with program.else_(): # ['Block', 'Measure', 'Conditional', 'Block', 'Play', 'Block', 'Wait'] ``` -The first `Block` in that list is the body itself; the other two are the arm -bodies of the conditional. +The first `Block` in that list is the body itself; the other two are the arm bodies of the conditional. -`Block.buses()`, `Block.waveforms()`, and `Block.variables()` aggregate the same -subtree into a set. Three blocks add what `elements` cannot reach: -`Sweep.variables()` adds the variable the loop binds, `Conditional.variables()` -adds the variables read by the arm conditions, and `Parallel.variables()` adds -the variables its loop headers bind. +`Block.buses()`, `Block.waveforms()`, and `Block.variables()` aggregate the same subtree into a set. Three blocks add what `elements` cannot reach: `Sweep.variables()` adds the variable the loop binds, `Conditional.variables()` adds the variables read by the arm conditions, and `Parallel.variables()` adds the variables its loop headers bind. ## Structural equality -Operations, blocks, and waveforms all compare the same way. The two objects must -be of exactly the same class, since the check is `type(self) is type(other)` and -a subclass therefore never equals its base. Then every entry of `vars()` must -match, private attributes included: equality is over the whole instance -`__dict__`, not a curated subset, so a block compares its `_elements` list and a -`Sweep` also compares its `variable` and `source`. The per-value verdict comes -from `ast_eq`, which recurses through `list`, `dict`, and `numpy.ndarray` and -defers to the value's own `==` for everything else. - -That delegation is what makes whole-tree comparison work. A `Variable` compares -by its `id` string, so a variable's currently assigned value never enters the -comparison and the body of a program loaded back from `.qp` compares equal to -the body that was written. A `Constant` compares by value and a `BusRef` -compares as the `str` it subclasses, so a schema-backed reference equals the raw -bus name it resolves to. -A `MeasurementHandle` compares by name, which means two auto-named measurements -on the same bus are not equal: their handles are `m0` and `m1`. An array only -ever compares equal to another array, so a list of samples and the equivalent -`ndarray` stay distinct. - -Hashing walks the same attributes through `ast_hash` and combines the class name -with the sorted `(key, hash)` pairs, but it is not an exact mirror of equality. -An array hashes by `(shape, value.tobytes())`, which is dtype-sensitive where -`ast_eq` compares contents only, so two nodes that differ only in a sample -array's dtype compare equal yet land in different buckets of a `dict` or `set`. -Both `__eq__` and `__hash__` read live attributes, so a node -used as a dictionary key must not be mutated afterwards; `QProgram.rebind` and -`with_waveforms` rewrite a `deepcopy` for that reason. - -`QProgram` itself defines no `__eq__`, so two programs compare by identity. -Compare `a.body == b.body` for the tree, and the label and description -separately. +Operations, blocks, and waveforms all compare the same way. The two objects must be of exactly the same class, since the check is `type(self) is type(other)` and a subclass therefore never equals its base. Then every entry of `vars()` must match, private attributes included: equality is over the whole instance `__dict__`, not a curated subset, so a block compares its `_elements` list and a `Sweep` also compares its `variable` and `source`. The per-value verdict comes from `ast_eq`, which recurses through `list`, `dict`, and `numpy.ndarray` and defers to the value's own `==` for everything else. + +That delegation is what makes whole-tree comparison work. A `Variable` compares by its `id` string, so a variable's currently assigned value never enters the comparison and the body of a program loaded back from `.qp` compares equal to the body that was written. A `Constant` compares by value and a `BusRef` compares as the `str` it subclasses, so a schema-backed reference equals the raw bus name it resolves to. A `MeasurementHandle` compares by name, which means two auto-named measurements on the same bus are not equal: their handles are `m0` and `m1`. An array only ever compares equal to another array, so a list of samples and the equivalent `ndarray` stay distinct. + +Hashing walks the same attributes through `ast_hash` and combines the class name with the sorted `(key, hash)` pairs, but it is not an exact mirror of equality. An array hashes by `(shape, value.tobytes())`, which is dtype-sensitive where `ast_eq` compares contents only, so two nodes that differ only in a sample array's dtype compare equal yet land in different buckets of a `dict` or `set`. Both `__eq__` and `__hash__` read live attributes, so a node used as a dictionary key must not be mutated afterwards; `QProgram.rebind` and `with_waveforms` rewrite a `deepcopy` for that reason. + +`QProgram` itself defines no `__eq__`, so two programs compare by identity. Compare `a.body == b.body` for the tree, and the label and description separately. ```python def build(): @@ -165,10 +90,7 @@ a.body == b.body # still True: a Variable compares by id, not by value ## Context managers push and pop blocks -Control flow lives inside `with` blocks. Each one pushes a new block onto a -stack, lets you append children to it, then pops it on exit. The active block is -the innermost one still open, and the program body is the outermost, so an -operation appended after a `with` exits lands back in the enclosing block. +Control flow lives inside `with` blocks. Each one pushes a new block onto a stack, lets you append children to it, then pops it on exit. The active block is the innermost one still open, and the program body is the outermost, so an operation appended after a `with` exits lands back in the enclosing block. ```python with program.average(shots=1000): @@ -178,21 +100,13 @@ with program.average(shots=1000): program.measure("readout_q0", "readout", "weights") ``` -The block context managers (`sweep`, `average`, `block`, and the -`if_` / `elif_` / `else_` chain) are described in -[Control flow](control-flow.md). +The block context managers (`sweep`, `average`, `block`, and the `if_` / `elif_` / `else_` chain) are described in [Control flow](control-flow.md). ## Real-time and host-side execution -QProgram makes no syntactic distinction between a real-time sweep and a -host-side sweep. The same `sweep` over `play` may run on the sequencer while the -same `sweep` over `set_parameter` runs as a Python loop. What decides is not the -shape of the source but a computation `qp.validate` performs against the -platform's capability declaration. +QProgram makes no syntactic distinction between a real-time sweep and a host-side sweep. The same `sweep` over `play` may run on the sequencer while the same `sweep` over `set_parameter` runs as a Python loop. What decides is not the shape of the source but a computation `qp.validate` performs against the platform's capability declaration. -Every node reports the capability tokens it needs, in isolation, from -`required_capabilities()`. The tokens are instance-aware, so what a node asks for -depends on the arguments it was built with: +Every node reports the capability tokens it needs, in isolation, from `required_capabilities()`. The tokens are instance-aware, so what a node asks for depends on the arguments it was built with: ```python program = qp.QProgram() @@ -210,32 +124,11 @@ sorted(sweep.elements[1].required_capabilities()) # ['expr.variable', 'op.wait'] ``` -A platform declares which of those tokens it carries per slot, a slot being a -`(bus, domain)` pair with the domains real-time (`rt`) and host-side (`host`). -Validation routes each node to a slot, blocks and bus-less operations to the -platform-wide slot and bus-touching operations to the slot of each bus they -name, then checks the node's tokens there. An operation's domains are the halves -of its slot that carry every token it asked for and whose predicates found -nothing wrong with it. A block's are its own allowance intersected with the -consensus of its immediate operation children, which is why a `sweep` holding -one host-side-only operation is host-side as a whole. When a block that could -have run in real time is pulled to the host that way, the operations inside it -still run in real time; what moves to the host is the block's iteration, one -real-time shot dispatched per point. - -The validator never raises. `qp.validate` returns the `Diagnostic`s together -with an `ExecutionPlan` mapping each visited node to the domains it can run in, -and leaves the reaction to the caller: `ReferencePlatform.execute` raises -`UnsupportedOperationError` on any diagnostic of severity `"error"`, re-emits -warnings through `warnings.warn` as `ExecutionWarning`, and drops info-level -ones. - -Because the domains come from the platform's declaration and not from the source -text, the same `sweep` in the same `.qp` file can run real-time on one backend -and host-side on another. -[Capabilities, diagnostics, and profiles](capabilities.md) has the routing -table, the classification rules in full, the ten diagnostic codes, the numeric -limits, and the predicate protocol. +A platform declares which of those tokens it carries per slot, a slot being a `(bus, domain)` pair with the domains real-time (`rt`) and host-side (`host`). Validation routes each node to a slot, blocks and bus-less operations to the platform-wide slot and bus-touching operations to the slot of each bus they name, then checks the node's tokens there. An operation's domains are the halves of its slot that carry every token it asked for and whose predicates found nothing wrong with it. A block's are its own allowance intersected with the consensus of its immediate operation children, which is why a `sweep` holding one host-side-only operation is host-side as a whole. When a block that could have run in real time is pulled to the host that way, the operations inside it still run in real time; what moves to the host is the block's iteration, one real-time shot dispatched per point. + +The validator never raises. `qp.validate` returns the `Diagnostic`s together with an `ExecutionPlan` mapping each visited node to the domains it can run in, and leaves the reaction to the caller: `ReferencePlatform.execute` raises `UnsupportedOperationError` on any diagnostic of severity `"error"`, re-emits warnings through `warnings.warn` as `ExecutionWarning`, and drops info-level ones. + +Because the domains come from the platform's declaration and not from the source text, the same `sweep` in the same `.qp` file can run real-time on one backend and host-side on another. [Capabilities, diagnostics, and profiles](capabilities.md) has the routing table, the classification rules in full, the ten diagnostic codes, the numeric limits, and the predicate protocol. ## Numbers, variables, and expressions @@ -251,8 +144,7 @@ program.wait("drive_q0", 100 + t * 2) # Expression program.set_frequency("drive_q0", 5e9 + freq * 1e6) # arithmetic ``` -This is how you sweep waveform parameters too. The waveform holds the variable, -and the sweep that binds it decides the values: +This is how you sweep waveform parameters too. The waveform holds the variable, and the sweep that binds it decides the values: ```python amp = program.variable("amp") @@ -260,19 +152,11 @@ with program.sweep(amp, qp.Range(0.0, 1.0, 0.01)): program.play("drive_q0", qp.waveforms.Gaussian(amplitude=amp, duration=40, sigma=8)) ``` -An expression built this way is a tree of `Expression` nodes, and its shape is -what produces the `expr.*` tokens the validator checks against `caps.platform`. -See [Variables and expressions](variables.md) for the operators, the math -functions, and how binding works at run time. +An expression built this way is a tree of `Expression` nodes, and its shape is what produces the `expr.*` tokens the validator checks against `caps.platform`. See [Variables and expressions](variables.md) for the operators, the math functions, and how binding works at run time. ## Buses are strings, with optional metadata -Every operation targets a bus by name. You can use a plain string -(`"drive_q0"`) or a `BusRef` that comes from a `BusSchema`. The AST stores -exactly the same thing in both cases, because `BusRef` subclasses `str`. The -schema-backed form additionally carries `element`, `idx`, `kind`, `channel`, -`acquires`, and its producing `schema` as attributes, which is what lets the -builder reject a wrong target at the call site rather than at run time. +Every operation targets a bus by name. You can use a plain string (`"drive_q0"`) or a `BusRef` that comes from a `BusSchema`. The AST stores exactly the same thing in both cases, because `BusRef` subclasses `str`. The schema-backed form additionally carries `element`, `idx`, `kind`, `channel`, `acquires`, and its producing `schema` as attributes, which is what lets the builder reject a wrong target at the call site rather than at run time. ```python schema = qp.BusSchema.transmon() @@ -283,26 +167,18 @@ program.play(q[0].drive, qp.waveforms.Square(0.5, 100)) # ValidationError program.measure(q[0].drive, "readout", "weights") # ValidationError ``` -The second call fails because `q[0].drive` has `channel="IQ"` and `Square` is a -single-channel `Waveform`: +The second call fails because `q[0].drive` has `channel="IQ"` and `Square` is a single-channel `Waveform`: ``` Bus 'q0/drive' is an IQ channel but received a single-channel Waveform (Square). Use an IQWaveform (e.g. IQPair, IQDrag) instead. ``` -The third fails because `q[0].drive` has `acquires=False`, so it has no ADC to -measure with. Routing uses the same metadata: `caps.for_bus(q[0].drive)` looks -up `caps.bus[("q", "drive")]` and falls back to `caps.default_bus_profile` when -there is no entry, while a plain string always takes the default profile. See -[Buses and schemas](buses.md) for the built-in schemas and the naming -conventions. +The third fails because `q[0].drive` has `acquires=False`, so it has no ADC to measure with. Routing uses the same metadata: `caps.for_bus(q[0].drive)` looks up `caps.bus[("q", "drive")]` and falls back to `caps.default_bus_profile` when there is no entry, while a plain string always takes the default profile. See [Buses and schemas](buses.md) for the built-in schemas and the naming conventions. ## Waveforms are pure data -`Waveform` instances describe an envelope. They compare and hash structurally, -they can carry `Variable`s as parameters, and they only get evaluated to a -sample array when something asks for `.envelope()`. +`Waveform` instances describe an envelope. They compare and hash structurally, they can carry `Variable`s as parameters, and they only get evaluated to a sample array when something asks for `.envelope()`. ```python g = qp.waveforms.Gaussian(amplitude=amp, duration=40, sigma=8) @@ -311,25 +187,13 @@ amp.set_value(0.7) g.envelope() # numpy array of 40 float64 samples, peak 0.6986 ``` -The peak sample falls short of the requested amplitude because the Gaussian is -centered in the sample window and an even sample count straddles the center -rather than landing on it. +The peak sample falls short of the requested amplitude because the Gaussian is centered in the sample window and an even sample count straddles the center rather than landing on it. -A program can carry waveforms inline or by string alias. Inline is concrete; the -alias contributes a `waveform.alias` token instead of a per-class one and gets -resolved later via `with_waveforms`, usually from calibration data the platform -owns. +A program can carry waveforms inline or by string alias. Inline is concrete; the alias contributes a `waveform.alias` token instead of a per-class one and gets resolved later via `with_waveforms`, usually from calibration data the platform owns. ## Measurements return handles -`program.measure(...)` returns a `MeasurementHandle`. Its name survives `.qp` -round-trips and identifies the record in the result object after execution. When -you do not pass a name, one is allocated: a `BusRef` gives the bus path followed -by `/m` and a per-bus counter (`q0/readout/m0`, `q0/readout/m1`, ...), while -raw-string buses share one global `m0`, `m1`, ... counter. The counters are -derived from the AST on each call rather than stored on the program, which keeps -`deepcopy`, `with_waveforms`, and `.qp` round-trips free of hidden state at the -cost of one walk per measurement. +`program.measure(...)` returns a `MeasurementHandle`. Its name survives `.qp` round-trips and identifies the record in the result object after execution. When you do not pass a name, one is allocated: a `BusRef` gives the bus path followed by `/m` and a per-bus counter (`q0/readout/m0`, `q0/readout/m1`, ...), while raw-string buses share one global `m0`, `m1`, ... counter. The counters are derived from the AST on each call rather than stored on the program, which keeps `deepcopy`, `with_waveforms`, and `.qp` round-trips free of hidden state at the cost of one walk per measurement. ```python m0 = program.measure(q[0].readout, "readout", "weights") @@ -340,14 +204,10 @@ data0 = result.get(m0) data1 = result.get(m1) ``` -`program.measurement_handles()` returns the same handle instances the AST holds, -in declaration order, which is how a conditional reading `m0.state` sees the -value the runtime wrote. [Measurements and results](measurements.md) covers -naming rules, the `fields` argument, and access patterns. +`program.measurement_handles()` returns the same handle instances the AST holds, in declaration order, which is how a conditional reading `m0.state` sees the value the runtime wrote. [Measurements and results](measurements.md) covers naming rules, the `fields` argument, and access patterns. ## Related pages - [Operations](operations.md) for the signature and semantics of each leaf. -- [.qp file format](../reference/qp-format.md) for how the tree is written to - disk. +- [.qp file format](../reference/qp-format.md) for how the tree is written to disk. - [API reference](../reference/api-qprogram.md), generated from the docstrings. diff --git a/docs/guide/control-flow.md b/docs/guide/control-flow.md index 531e051..3143041 100644 --- a/docs/guide/control-flow.md +++ b/docs/guide/control-flow.md @@ -1,24 +1,12 @@ # Control flow -Control flow is built from context managers. Each `with` block pushes a -container onto the program's block stack, and every operation appended while it -is open lands in that container. `program.body` is the root container, and -leaving a `with` block pops the stack back to the enclosing one. - -Five constructs make up the control flow: `sweep`, which is the only loop; -`average`, which repeats a body and collapses the repetitions; the `if_` / -`elif_` / `else_` chain; `block`, a grouping with no semantics of its own; and -`Parallel`, which has no method of its own and is built with `|` on two or more -sweep contexts. None of them says where the code runs. Validation derives that -from the operations inside, which -[Real-time and host-side](#real-time-and-host-side) covers. +Control flow is built from context managers. Each `with` block pushes a container onto the program's block stack, and every operation appended while it is open lands in that container. `program.body` is the root container, and leaving a `with` block pops the stack back to the enclosing one. + +Five constructs make up the control flow: `sweep`, which is the only loop; `average`, which repeats a body and collapses the repetitions; the `if_` / `elif_` / `else_` chain; `block`, a grouping with no semantics of its own; and `Parallel`, which has no method of its own and is built with `|` on two or more sweep contexts. None of them says where the code runs. Validation derives that from the operations inside, which [Real-time and host-side](#real-time-and-host-side) covers. ## sweep -A `Sweep` binds a `Variable` to each value a `SweepSource` produces. What -changes between a hardware ramp, an explicit table, a log-spaced set, and a -composed pattern is the source, not the block, so there is one loop type rather -than one per shape of values. +A `Sweep` binds a `Variable` to each value a `SweepSource` produces. What changes between a hardware ramp, an explicit table, a log-spaced set, and a composed pattern is the source, not the block, so there is one loop type rather than one per shape of values. ```python import qprogram as qp @@ -33,10 +21,7 @@ with program.sweep(freq).from_range(4e9, 6e9, 1e6): ### Naming the source -`sweep(variable, source)` binds a source object directly. `sweep(variable)`, -with the source left out, returns a source builder whose `from_*` methods -construct one. Both produce the same `Sweep` node and the same `.qp` line, so -the choice is about the call site. +`sweep(variable, source)` binds a source object directly. `sweep(variable)`, with the source left out, returns a source builder whose `from_*` methods construct one. Both produce the same `Sweep` node and the same `.qp` line, so the choice is about the call site. ```python with program.sweep(freq).from_range(4e9, 6e9, 1e6): @@ -45,29 +30,13 @@ with program.sweep(freq, qp.Range(4e9, 6e9, 1e6)): ... ``` -Reach for the builder when writing a sweep out by hand: it is the shorter -spelling and needs no source class in scope. Pass the object when the source is -computed rather than written, which covers holding it in a variable, building it -in a comprehension, reading it from a scan spec, and nesting combinators deeper -than the `rotate` and `repeat` shortcuts below reach. - -An omitted source is detected with a sentinel rather than `None`, so -`sweep(freq, None)`, a source that failed to be computed, is rejected instead of -quietly returning a builder: `Sweep source must be a SweepSource, got None`. - -A builder is not a context manager, because it has no values yet. Entering one -raises `ValidationError` listing the `from_*` methods and the two-argument form, -rather than sweeping nothing. Reaching for `repeat` or `rotate` on a builder -raises `AttributeError` for the same reason: those shape values that have -already been picked. - -Every registered source has a builder, matched on the class name with case and -underscores ignored, so `from_iq_table` finds a source class named `IQTable`. -The five scalar built-ins are also written out as real methods (`from_range`, -`from_linspace`, `from_logspace`, `from_values`, `from_file`) so that editors -complete and type-check them; everything else resolves against the live -registry, the combinators and vendor-registered sources included. Registering a -source is the whole of what its builder needs. A misspelling lists what exists: +Reach for the builder when writing a sweep out by hand: it is the shorter spelling and needs no source class in scope. Pass the object when the source is computed rather than written, which covers holding it in a variable, building it in a comprehension, reading it from a scan spec, and nesting combinators deeper than the `rotate` and `repeat` shortcuts below reach. + +An omitted source is detected with a sentinel rather than `None`, so `sweep(freq, None)`, a source that failed to be computed, is rejected instead of quietly returning a builder: `Sweep source must be a SweepSource, got None`. + +A builder is not a context manager, because it has no values yet. Entering one raises `ValidationError` listing the `from_*` methods and the two-argument form, rather than sweeping nothing. Reaching for `repeat` or `rotate` on a builder raises `AttributeError` for the same reason: those shape values that have already been picked. + +Every registered source has a builder, matched on the class name with case and underscores ignored, so `from_iq_table` finds a source class named `IQTable`. The five scalar built-ins are also written out as real methods (`from_range`, `from_linspace`, `from_logspace`, `from_values`, `from_file`) so that editors complete and type-check them; everything else resolves against the live registry, the combinators and vendor-registered sources included. Registering a source is the whole of what its builder needs. A misspelling lists what exists: ``` AttributeError: no sweep source is registered for 'from_rang'. Did you mean from_range, from_rotate, from_repeat? Available: from_concat, from_file, from_linspace, from_logspace, from_range, from_repeat, from_rotate, from_values. Add one with qp.register_sweep_source(cls) and its builder appears here too. @@ -86,80 +55,27 @@ AttributeError: no sweep source is registered for 'from_rang'. Did you mean from | `qp.Repeat(source, times)` | `.from_repeat(source, times)`, `.repeat(times)` | the inner points tiled `times` times | arbitrary | `sweep.repeat` | | `qp.Rotate(source, by=1)` | `.from_rotate(source, by)`, `.rotate(by)` | the inner points shifted left by `by` | arbitrary | `sweep.rotate` | -`Range` takes `step=1` by default. The ramp always begins at `start` and holds -`round((stop - start) / step) + 1` points, so it lands on `stop` only when -`step` divides `stop - start` evenly. Otherwise the last point falls short or -overshoots: `qp.Range(0, 1, 0.3)` gives `0, 0.3, 0.6, 0.9`, `qp.Range(0, 1, 0.6)` -gives `0, 0.6, 1.2`, and `qp.Range(0, 0.4, 1)` is the single point `0.0`. The -rounding is deliberate, since it absorbs the floating-point division noise in a -range like `(0.0, 1.0, 0.01)`. Use `Linspace` when the last point has to land -exactly on `stop`. A zero step raises, and so does a step pointing away from -`stop`: `Range step -0.1 moves away from stop (0.0 -> 1.0); flip the step sign -or swap the bounds`. - -`Linspace` includes both ends, and `num=1` yields `[start]`. It also exposes -`step()`, which returns `(stop - start) / (num - 1)` for a compiler that wants -the ramp in start-and-step form, and `0.0` for a single point, where the spacing -is undefined. - -`Logspace` takes the actual first and last values, not the exponents -`numpy.logspace` takes, so a frequency sweep reads -`qp.Logspace(1e6, 1e9, num=50)` rather than `qp.Logspace(6, 9, num=50)`. Both -bounds must be strictly positive. - -`Values` accepts anything `numpy.asarray` accepts and stores it as a 1-D `float` -array; a 2-D input raises, as does an empty one. Its parameter is named `points` -so that it does not collide with the `values()` method every source implements. -`values()` hands back the stored array itself rather than a copy, so treat it as -read-only: the source's equality and hash are derived from it. - -`File` stores the path, so a `.qp` file records where the points came from -instead of inlining them. Nothing is cached, because a loaded array would enter -the source's structural equality and an already-loaded instance would stop -comparing equal to a fresh one. Both `length()` and `values()` therefore read -the file, which has to be readable wherever the program is validated or run: a -missing path raises `OSError`, and a file holding an empty or multi-dimensional -array raises `ValidationError`. - -The three combinators wrap other sources. A bare sequence where a source is -expected is wrapped in `Values`, so `qp.Rotate([0.0, 1.57, 3.14], by=1)` needs -no inner constructor. `Concat` takes an iterable, so a generator expression -works directly, and a single source passed where the iterable belongs raises -with `Write Concat([a, b]) or Concat(gen_expr)`. `Repeat` tiles: -`qp.Repeat(qp.Values([0, 1]), times=3)` sweeps `0, 1, 0, 1, 0, 1`, and each -repetition is a sweep point of its own with its own result entry, which is what -separates it from `average`. `Rotate` shifts left by `by`, so -`qp.Rotate(qp.Values([0, 1, 2, 3]), by=1)` sweeps `1, 2, 3, 0`; `by` may be -negative, shifting right, or larger than the point count, wrapping as -`numpy.roll` does, and the point count is unchanged either way. The -phase-cycling pattern falls out of the two together: -`qp.Concat(qp.Rotate(base, by=i) for i in range(base.length()))`. - -Nesting combinators more than two deep is a signal to write a named -`SweepSource` subclass instead. A registered subclass serializes from its own -public attributes, so the `.qp` file records one constructor call carrying the -parameters that describe the pattern rather than the stack of wrappers that -builds it. - -A sweep with no points never executes its body, so a built-in rejects an empty -parameterization as soon as it can see one: at construction for the sources -that carry their values, and on first read for `File`, which learns the length -only when it loads the array. Wherever `length()` returns, it returns at least -one, and `qp.sweeps.validate_source` holds a subclass to the same rule. +`Range` takes `step=1` by default. The ramp always begins at `start` and holds `round((stop - start) / step) + 1` points, so it lands on `stop` only when `step` divides `stop - start` evenly. Otherwise the last point falls short or overshoots: `qp.Range(0, 1, 0.3)` gives `0, 0.3, 0.6, 0.9`, `qp.Range(0, 1, 0.6)` gives `0, 0.6, 1.2`, and `qp.Range(0, 0.4, 1)` is the single point `0.0`. The rounding is deliberate, since it absorbs the floating-point division noise in a range like `(0.0, 1.0, 0.01)`. Use `Linspace` when the last point has to land exactly on `stop`. A zero step raises, and so does a step pointing away from `stop`: `Range step -0.1 moves away from stop (0.0 -> 1.0); flip the step sign or swap the bounds`. + +`Linspace` includes both ends, and `num=1` yields `[start]`. It also exposes `step()`, which returns `(stop - start) / (num - 1)` for a compiler that wants the ramp in start-and-step form, and `0.0` for a single point, where the spacing is undefined. + +`Logspace` takes the actual first and last values, not the exponents `numpy.logspace` takes, so a frequency sweep reads `qp.Logspace(1e6, 1e9, num=50)` rather than `qp.Logspace(6, 9, num=50)`. Both bounds must be strictly positive. + +`Values` accepts anything `numpy.asarray` accepts and stores it as a 1-D `float` array; a 2-D input raises, as does an empty one. Its parameter is named `points` so that it does not collide with the `values()` method every source implements. `values()` hands back the stored array itself rather than a copy, so treat it as read-only: the source's equality and hash are derived from it. + +`File` stores the path, so a `.qp` file records where the points came from instead of inlining them. Nothing is cached, because a loaded array would enter the source's structural equality and an already-loaded instance would stop comparing equal to a fresh one. Both `length()` and `values()` therefore read the file, which has to be readable wherever the program is validated or run: a missing path raises `OSError`, and a file holding an empty or multi-dimensional array raises `ValidationError`. + +The three combinators wrap other sources. A bare sequence where a source is expected is wrapped in `Values`, so `qp.Rotate([0.0, 1.57, 3.14], by=1)` needs no inner constructor. `Concat` takes an iterable, so a generator expression works directly, and a single source passed where the iterable belongs raises with `Write Concat([a, b]) or Concat(gen_expr)`. `Repeat` tiles: `qp.Repeat(qp.Values([0, 1]), times=3)` sweeps `0, 1, 0, 1, 0, 1`, and each repetition is a sweep point of its own with its own result entry, which is what separates it from `average`. `Rotate` shifts left by `by`, so `qp.Rotate(qp.Values([0, 1, 2, 3]), by=1)` sweeps `1, 2, 3, 0`; `by` may be negative, shifting right, or larger than the point count, wrapping as `numpy.roll` does, and the point count is unchanged either way. The phase-cycling pattern falls out of the two together: `qp.Concat(qp.Rotate(base, by=i) for i in range(base.length()))`. + +Nesting combinators more than two deep is a signal to write a named `SweepSource` subclass instead. A registered subclass serializes from its own public attributes, so the `.qp` file records one constructor call carrying the parameters that describe the pattern rather than the stack of wrappers that builds it. + +A sweep with no points never executes its body, so a built-in rejects an empty parameterization as soon as it can see one: at construction for the sources that carry their values, and on first read for `File`, which learns the length only when it loads the array. Wherever `length()` returns, it returns at least one, and `qp.sweeps.validate_source` holds a subclass to the same rule. ### What a source has to answer -Three things, all without running the program: `length()`, `values()`, and the -class-level `KIND`. `Parallel` needs the length at construction to refuse loops -that cannot advance in lockstep, and the reference executor needs it to size -every result array before the first shot. A platform reads `KIND` through -`ValidationContext.sweep_kind_of` to choose between a loop register with an -increment, a value table, and a host-side step per point. The interpreter, the -result coordinates, and `qp.optimize` all need the concrete values. +Three things, all without running the program: `length()`, `values()`, and the class-level `KIND`. `Parallel` needs the length at construction to refuse loops that cannot advance in lockstep, and the reference executor needs it to size every result array before the first shot. A platform reads `KIND` through `ValidationContext.sweep_kind_of` to choose between a loop register with an increment, a value table, and a host-side step per point. The interpreter, the result coordinates, and `qp.optimize` all need the concrete values. -That contract is why a source cannot wrap a callable: a deferred function -answers none of the three ahead of time. Passing one says so, and says what to -do instead. +That contract is why a source cannot wrap a callable: a deferred function answers none of the three ahead of time. Passing one says so, and says what to do instead. ``` ValidationError: Sweep source must be a SweepSource, not a callable. A source describes its values statically (length, kind, and a serializable parameterization); a function can answer none of those before the program runs. Materialize it — Values(f(...)) — or declare a SweepSource subclass with the parameters it needs. @@ -167,33 +83,15 @@ ValidationError: Sweep source must be a SweepSource, not a callable. A source de ### Linear or arbitrary -`KIND` is a claim about compilability, not a description of the numbers. -`sweep.linear` means the values are exactly `start + step * i`, which is what -lets a sequencer run the loop from one register plus an increment, and `Range` -and `Linspace` are the two sources that claim it. Everything else reports -`sweep.arbitrary`, meaning a value table or a host-side step per point. - -Two sources can produce identical values and still differ here. -`qp.Values([0, 1, 2])` is arbitrary even though the numbers are evenly spaced, -because nothing about the source proves that regularity to a compiler. -Combinators degrade in the same conservative direction and always report -arbitrary, `Repeat` of a linear source included: a tiled ramp is re-runnable as -a nested loop, but it is not itself `start + step * i`. Under-claiming costs a -platform one optimization; over-claiming would have it emit a single ramp for a -sweep that is not one. - -Capability tokens have the same two levels. A `Sweep` requires `block.sweep` -plus the source's own token and its `sweep.`, and a combinator unions the -tokens of what it wraps, so `qp.Rotate(qp.Logspace(1e6, 1e9, 50))` needs -`block.sweep`, `sweep.rotate`, `sweep.arbitrary`, and `sweep.logspace`. A -platform that cannot generate a `Logspace` therefore also refuses a rotation of -one, instead of silently materializing the points into a table. +`KIND` is a claim about compilability, not a description of the numbers. `sweep.linear` means the values are exactly `start + step * i`, which is what lets a sequencer run the loop from one register plus an increment, and `Range` and `Linspace` are the two sources that claim it. Everything else reports `sweep.arbitrary`, meaning a value table or a host-side step per point. + +Two sources can produce identical values and still differ here. `qp.Values([0, 1, 2])` is arbitrary even though the numbers are evenly spaced, because nothing about the source proves that regularity to a compiler. Combinators degrade in the same conservative direction and always report arbitrary, `Repeat` of a linear source included: a tiled ramp is re-runnable as a nested loop, but it is not itself `start + step * i`. Under-claiming costs a platform one optimization; over-claiming would have it emit a single ramp for a sweep that is not one. + +Capability tokens have the same two levels. A `Sweep` requires `block.sweep` plus the source's own token and its `sweep.`, and a combinator unions the tokens of what it wraps, so `qp.Rotate(qp.Logspace(1e6, 1e9, 50))` needs `block.sweep`, `sweep.rotate`, `sweep.arbitrary`, and `sweep.logspace`. A platform that cannot generate a `Logspace` therefore also refuses a rotation of one, instead of silently materializing the points into a table. ### Shaping a bound source -`repeat` and `rotate` are also methods on the loop context, which covers the -everyday shaping without naming a combinator class. Each wraps whatever is bound -so far, so the outermost wrapper is the last call: +`repeat` and `rotate` are also methods on the loop context, which covers the everyday shaping without naming a combinator class. Each wraps whatever is bound so far, so the outermost wrapper is the last call: ```python base = [0.0, 1.57, 3.14] @@ -203,9 +101,7 @@ with program.sweep(freq).from_values(base).repeat(3).rotate(by=1): ... # qp.Rotate(qp.Repeat(qp.Values(base), times=3), by=1) ``` -Both are pure, the way `|` is: they return a fresh context and leave the -original one usable. They shape one sweep, so calling either on a `|` -composition raises. +Both are pure, the way `|` is: they return a fresh context and leave the original one usable. They shape one sweep, so calling either on a `|` composition raises. ``` ValidationError: repeat() shapes one sweep's source, but this context already composes 2 sweeps with `|`. Call repeat() on each sweep before composing them. @@ -213,9 +109,7 @@ ValidationError: repeat() shapes one sweep's source, but this context already co ### Writing your own source -Subclass `qp.SweepSource`, declare `KIND` and `TOKEN`, implement `length()` and -`values()`, and register the class. Its public attributes are its parameters, so -the `.qp` form is derived from the object rather than from a per-class callback. +Subclass `qp.SweepSource`, declare `KIND` and `TOKEN`, implement `length()` and `values()`, and register the class. Its public attributes are its parameters, so the `.qp` form is derived from the object rather than from a per-class callback. ```python import numpy as np @@ -236,37 +130,15 @@ class Chevron(qp.SweepSource): return np.linspace(self.center - self.span / 2, self.center + self.span / 2, self.num) ``` -`register_sweep_source` returns the class, so it works as a decorator. It keys -the registry by `__name__`, which is the constructor name on the wire, and adds -`TOKEN` to the capability registry so a profile can list it without a separate -call. Re-registering the same class is a no-op; registering a different class -under a taken name raises `ValueError`, since it would change how every existing -file parses that constructor. - -The source now round-trips through `.qp` as -`Chevron(center=..., span=..., num=...)`, reports its token to the validator, -composes inside the combinators, and gets its own `from_chevron` builder, none -of which needs a change in the core. `qp.sweeps.validate_source(source)` checks -the `length()` and `values()` invariants; it materializes the values, so it -belongs in a test rather than on the hot path. For a one-off computation with no -parameters worth naming, skip the class and materialize the array: -`qp.Values(my_function(...))`. +`register_sweep_source` returns the class, so it works as a decorator. It keys the registry by `__name__`, which is the constructor name on the wire, and adds `TOKEN` to the capability registry so a profile can list it without a separate call. Re-registering the same class is a no-op; registering a different class under a taken name raises `ValueError`, since it would change how every existing file parses that constructor. + +The source now round-trips through `.qp` as `Chevron(center=..., span=..., num=...)`, reports its token to the validator, composes inside the combinators, and gets its own `from_chevron` builder, none of which needs a change in the core. `qp.sweeps.validate_source(source)` checks the `length()` and `values()` invariants; it materializes the values, so it belongs in a test rather than on the hot path. For a one-off computation with no parameters worth naming, skip the class and materialize the array: `qp.Values(my_function(...))`. ## average -`average(shots)` repeats its body `shots` times and averages the measurement -results over the repetitions. `shots` has to be an integer of at least one, and -a `bool` is rejected even though it is an `int`: `Average shots must be an -integer >= 1, got True`. +`average(shots)` repeats its body `shots` times and averages the measurement results over the repetitions. `shots` has to be an integer of at least one, and a `bool` is rejected even though it is an `int`: `Average shots must be an integer >= 1, got True`. -Averaging is repetition, so the block occupies a repetition level on the -sequencer and counts toward the loop-nesting limit exactly as a sweep does. -Unlike a sweep it contributes no dimension to the results. The executor -accumulates a sum and a shot count per sweep point and divides, so `iq` and -`raw` come back as means and `state` as the excited-state population over the -shots. `qp.Repeat` is the opposite choice: it turns each repetition into a sweep -point with its own result entry. The block requires `block.average` and nothing -else. +Averaging is repetition, so the block occupies a repetition level on the sequencer and counts toward the loop-nesting limit exactly as a sweep does. Unlike a sweep it contributes no dimension to the results. The executor accumulates a sum and a shot count per sweep point and divides, so `iq` and `raw` come back as means and `state` as the excited-state population over the shots. `qp.Repeat` is the opposite choice: it turns each repetition into a sweep point with its own result entry. The block requires `block.average` and nothing else. ```python with program.average(shots=1000): @@ -276,8 +148,7 @@ with program.average(shots=1000): ## block -A generic container with no semantics of its own, used to group operations or -scope a comment. +A generic container with no semantics of its own, used to group operations or scope a comment. ```python with program.block(): @@ -286,16 +157,11 @@ with program.block(): program.wait("drive_q0", 50) ``` -It requires `block.block`, contributes no result dimension, and occupies no -repetition level. It is not inert to validation, though: the real-time and -host-side consensus is computed per block over that block's direct operation -children, so grouping operations changes which of them are compared with each -other. +It requires `block.block`, contributes no result dimension, and occupies no repetition level. It is not inert to validation, though: the real-time and host-side consensus is computed per block over that block's direct operation children, so grouping operations changes which of them are compared with each other. ## Conditionals -`if_`, `elif_`, and `else_` build a chain of arms from sequential `with` blocks, -mirroring the shape of Python's own `if` statement. +`if_`, `elif_`, and `else_` build a chain of arms from sequential `with` blocks, mirroring the shape of Python's own `if` statement. ```python schema = qp.BusSchema.transmon() @@ -318,72 +184,37 @@ with program.else_(): ### What a condition may reference -A condition is a single `Comparison` holding at least one measurement-state -reference and, apart from that, only `int` literals. Four spellings reach it: +A condition is a single `Comparison` holding at least one measurement-state reference and, apart from that, only `int` literals. Four spellings reach it: - `m.state == 1` and `m.state != 0`, a measurement against an `int` literal - `1 == m.state`, the reverse order, which builds the same node -- `m1.state == m2.state`, one measurement against another, for asking whether - two qubits landed in the same state -- `qp.eq(m.state, 1)` and `qp.ne(m.state, 1)`, the helper forms, for building a - condition without relying on operator overloading - -`state` is the only field a condition may read. A classified scalar is the only -thing there is to branch on, so `iq` and `raw` are excluded by design rather -than by omission. Only `==` and `!=` exist, because those are the two operators -the proxy behind `handle.state` overloads: `m.state < 1` raises `TypeError` from -Python itself. Comparing against a `float` or a `bool` also raises `TypeError`, -the latter with `handle.state cannot be compared to a bool; use 0 or 1 to -compare against a classified state`. - -Anything outside that shape is refused at the `if_()` or `elif_()` call, which -names what it got instead. A bare variable comparison and a logical combination -of two conditions both fail there: +- `m1.state == m2.state`, one measurement against another, for asking whether two qubits landed in the same state +- `qp.eq(m.state, 1)` and `qp.ne(m.state, 1)`, the helper forms, for building a condition without relying on operator overloading + +`state` is the only field a condition may read. A classified scalar is the only thing there is to branch on, so `iq` and `raw` are excluded by design rather than by omission. Only `==` and `!=` exist, because those are the two operators the proxy behind `handle.state` overloads: `m.state < 1` raises `TypeError` from Python itself. Comparing against a `float` or a `bool` also raises `TypeError`, the latter with `handle.state cannot be compared to a bool; use 0 or 1 to compare against a classified state`. + +Anything outside that shape is refused at the `if_()` or `elif_()` call, which names what it got instead. A bare variable comparison and a logical combination of two conditions both fail there: ``` ValidationError: if_() condition must reference at least one measurement-state ref (e.g. `handle.state`); got a comparison of Variable and Constant ValidationError: if_() expects a Comparison condition such as `handle.state == 0` or `handle.state != 1`; got LogicalBinaryOp ``` -There is no `and` or `or` of two conditions. Nest a second `if_` inside the arm -instead. +There is no `and` or `or` of two conditions. Nest a second `if_` inside the arm instead. ### Chain rules -`elif_` and `else_` find the open chain through pending-chain state that `if_` -records when it appends its `Conditional`, and that is cleared as soon as -anything else is appended at the same level. Each therefore has to follow the -matching `if_` or `elif_` immediately and at the same nesting level. An -operation or a `block()` in between closes the chain, and the following `elif_` -raises `elif_() must immediately follow an if_() / elif_() block at the same -nesting level; no open conditional chain`. Appends inside an arm body sit deeper -on the block stack and leave the chain open. +`elif_` and `else_` find the open chain through pending-chain state that `if_` records when it appends its `Conditional`, and that is cleared as soon as anything else is appended at the same level. Each therefore has to follow the matching `if_` or `elif_` immediately and at the same nesting level. An operation or a `block()` in between closes the chain, and the following `elif_` raises `elif_() must immediately follow an if_() / elif_() block at the same nesting level; no open conditional chain`. Appends inside an arm body sit deeper on the block stack and leave the chain open. -A chain takes at most one `else_`, which terminates it: leaving the `else_` body -clears the pending-chain state, so a following `elif_` or `else_` raises the -same no-open-chain error. Conditionals nest, and a chain written inside a loop -body is a chain at that level. +A chain takes at most one `else_`, which terminates it: leaving the `else_` body clears the pending-chain state, so a following `elif_` or `else_` raises the same no-open-chain error. Conditionals nest, and a chain written inside a loop body is a chain at that level. ### Requesting state classification -Reading `handle.state` requires that the producing measurement asked for -classification. `measure(...)` defaults to `fields=(MeasurementField.IQ,)`, so -pass `fields=(qp.MeasurementField.IQ, qp.MeasurementField.STATE)`, or -`fields=(qp.MeasurementField.STATE,)` for state alone. Without it `qp.validate` -emits a `missing-classification` error: `Conditional references -q0/readout/m0.state, but the measurement does not request state classification -(add MeasurementField.STATE to fields=)`. In an `m1.state == m2.state` -comparison both measurements are checked. A condition naming a handle that no -measurement in the program produces gives `unknown-measurement` instead. Both -checks are profile-independent, so they run whatever the platform declares. +Reading `handle.state` requires that the producing measurement asked for classification. `measure(...)` defaults to `fields=(MeasurementField.IQ,)`, so pass `fields=(qp.MeasurementField.IQ, qp.MeasurementField.STATE)`, or `fields=(qp.MeasurementField.STATE,)` for state alone. Without it `qp.validate` emits a `missing-classification` error: `Conditional references q0/readout/m0.state, but the measurement does not request state classification (add MeasurementField.STATE to fields=)`. In an `m1.state == m2.state` comparison both measurements are checked. A condition naming a handle that no measurement in the program produces gives `unknown-measurement` instead. Both checks are profile-independent, so they run whatever the platform declares. ### The Conditional node -`Conditional` does not use the inherited `elements` list, because each arm -carries its own body and there is no shared body to put there. `arms` holds -`(condition, body)` pairs in source order, and the terminal `else` body lives on -`else_body`, which is `None` when the chain has none. `append` raises rather -than landing a node somewhere with no meaning. +`Conditional` does not use the inherited `elements` list, because each arm carries its own body and there is no shared body to put there. `arms` holds `(condition, body)` pairs in source order, and the terminal `else` body lives on `else_body`, which is `None` when the chain has none. `append` raises rather than landing a node somewhere with no meaning. ```python cond = program.body.elements[-1] @@ -393,22 +224,13 @@ len(cond.arms) # 2: the if_ and the elif_ cond.else_body # the else_ body, or None ``` -`walk()` yields the conditional, then each arm body in source order, then the -`else` body. Arm conditions are expressions rather than AST nodes, so they are -not yielded; read `arms` for those. `variables()` does include the conditions' -variables, so a branch taken on a swept threshold counts that variable as part -of the conditional even when no operation inside reads it. Branching selects a -body rather than iterating, so a conditional occupies no repetition level. +`walk()` yields the conditional, then each arm body in source order, then the `else` body. Arm conditions are expressions rather than AST nodes, so they are not yielded; read `arms` for those. `variables()` does include the conditions' variables, so a branch taken on a swept threshold counts that variable as part of the conditional even when no operation inside reads it. Branching selects a body rather than iterating, so a conditional occupies no repetition level. -The block requires `block.conditional` plus the `expr.*` tokens of every arm -condition, which for `m.state == 1` are `expr.comparison`, -`expr.measurement_ref`, and `expr.constant`. The `else` arm has no condition and -contributes none. +The block requires `block.conditional` plus the `expr.*` tokens of every arm condition, which for `m.state == 1` are `expr.comparison`, `expr.measurement_ref`, and `expr.constant`. The `else` arm has no condition and contributes none. ### Active reset -Reset by measurement is what the construct exists for: measure the qubit, then -apply a pi-pulse only if it landed in `|1⟩`. +Reset by measurement is what the construct exists for: measure the qubit, then apply a pi-pulse only if it landed in `|1⟩`. ```python m = program.measure( @@ -421,18 +243,11 @@ with program.if_(m.state == 1): program.play(q[0].drive, "pi_pulse") ``` -Written this way the program names no vendor operation, so it runs anywhere -`block.conditional` and `measure.fields.state` are declared. Compared with -calling a vendor's packaged `active_reset`, the trade-off is that a platform -with a tuned reset choreography receives the general pattern rather than a -request for its own. +Written this way the program names no vendor operation, so it runs anywhere `block.conditional` and `measure.fields.state` are declared. Compared with calling a vendor's packaged `active_reset`, the trade-off is that a platform with a tuned reset choreography receives the general pattern rather than a request for its own. ## Parallel loops with `|` -`|` on two or more sweep contexts composes them into a `Parallel` block that -advances the loops in lockstep over one shared body. That is how a program -sweeps coupled parameters along a single axis instead of over their cross -product. +`|` on two or more sweep contexts composes them into a `Parallel` block that advances the loops in lockstep over one shared body. That is how a program sweeps coupled parameters along a single axis instead of over their cross product. ```python gain = program.variable("gain") @@ -443,27 +258,20 @@ with program.sweep(freq, qp.Linspace(4e9, 6e9, 101)) | program.sweep(gain, qp.Li program.play("drive_q0", "pi_pulse") ``` -Every composed loop must report the same number of iterations. Sources answer -that statically, so the check happens when the `with` block opens rather than at -run time, and the error names the counts it found. Composing -`qp.Range(4e9, 6e9, 1e6)` with `qp.Range(0.0, 1.0, 0.01)` is the easy mistake: -the two read as a matched pair but hold 2001 and 101 points. +Every composed loop must report the same number of iterations. Sources answer that statically, so the check happens when the `with` block opens rather than at run time, and the error names the counts it found. Composing `qp.Range(4e9, 6e9, 1e6)` with `qp.Range(0.0, 1.0, 0.01)` is the easy mistake: the two read as a matched pair but hold 2001 and 101 points. ``` ValidationError: parallel loops must have the same number of iterations to advance in lockstep; got Sweep('freq'): 2001, Sweep('gain'): 101 ``` -Kinds may differ. A linear ramp composes with an explicit table, as long as the -table holds the same number of points: +Kinds may differ. A linear ramp composes with an explicit table, as long as the table holds the same number of points: ```python with program.sweep(freq).from_linspace(4e9, 6e9, 41) | program.sweep(gain).from_values(measured_gains): program.play("drive_q0", qp.waveforms.Gaussian(amplitude=gain, duration=40, sigma=8)) ``` -Extra pipes chain more than two. `__or__` is pure: it returns a fresh context -carrying the concatenated list and touches the program only on entry, so a list -of sweeps can be folded programmatically. +Extra pipes chain more than two. `__or__` is pure: it returns a fresh context carrying the concatenated list and touches the program only on entry, so a list of sweeps can be folded programmatically. ```python import functools @@ -475,27 +283,15 @@ with composed: program.play("drive_q0", "pi_pulse") ``` -The composed headers live on `loops`, not among `elements`, which holds the -shared body. `walk()` yields the block, then each header with its descendants, -then the body, so a consumer meets the loops that bind the variables before the -operations that read them, and `variables()` unions the headers' variables back -in, since the inherited walk over the body alone would miss them. +The composed headers live on `loops`, not among `elements`, which holds the shared body. `walk()` yields the block, then each header with its descendants, then the body, so a consumer meets the loops that bind the variables before the operations that read them, and `variables()` unions the headers' variables back in, since the inherited walk over the body alone would miss them. -In the result `DataArray` a parallel composition is one dimension, named by -joining the variable ids with `|` (`"freq|gain"`), and each variable contributes -its own coordinate array on that shared dimension. `plot` reads the first two of -them on an axis and a twin axis opposite it, in the order the name gives — see -[Plotting results](plotting.md#two-variables-on-one-axis). +In the result `DataArray` a parallel composition is one dimension, named by joining the variable ids with `|` (`"freq|gain"`), and each variable contributes its own coordinate array on that shared dimension. `plot` reads the first two of them on an axis and a twin axis opposite it, in the order the name gives — see [Plotting results](plotting.md#two-variables-on-one-axis). -`Parallel` has no context-manager method of its own. Constructing one directly, -as an analyzer or a code generator might, is `qp.blocks.Parallel(loops=[...])` -with at least two `qp.blocks.Sweep` instances: fewer raises `Parallel requires -at least two loops, got 1`. +`Parallel` has no context-manager method of its own. Constructing one directly, as an analyzer or a code generator might, is `qp.blocks.Parallel(loops=[...])` with at least two `qp.blocks.Sweep` instances: fewer raises `Parallel requires at least two loops, got 1`. ## Nesting -Blocks nest to any depth, and the innermost open one receives whatever is -appended next. +Blocks nest to any depth, and the innermost open one receives whatever is appended next. ```python delay = program.variable("delay") @@ -513,31 +309,13 @@ with program.average(shots=1000): # level 1 program.measure("readout_q0", "readout", "weights") ``` -What a platform limit counts is repetition levels, not blocks. `Sweep`, -`Parallel`, and `Average` each declare `REPEATS = True` and contribute one -level, a `Parallel` one in total rather than one per composed loop, because its -headers advance together instead of nesting. A `Conditional` and a plain `block` -contribute none. Validation reads that flag rather than testing concrete -classes, so a vendor block that repeats its body is counted correctly by -declaring the flag too. The deepest count wrapping any leaf is -`max_loop_nesting`, which is 3 in the program above: the average, the parallel -composition, and the inner sweep. - -When the platform declares a `max_loop_nesting` limit and the program exceeds -it, validation returns a `limit-exceeded` error, `Program nests loops 4 deep; -limit max_loop_nesting=3`. `max_parallel_loops` is checked the same way against -the widest `Parallel` in the program. +What a platform limit counts is repetition levels, not blocks. `Sweep`, `Parallel`, and `Average` each declare `REPEATS = True` and contribute one level, a `Parallel` one in total rather than one per composed loop, because its headers advance together instead of nesting. A `Conditional` and a plain `block` contribute none. Validation reads that flag rather than testing concrete classes, so a vendor block that repeats its body is counted correctly by declaring the flag too. The deepest count wrapping any leaf is `max_loop_nesting`, which is 3 in the program above: the average, the parallel composition, and the inner sweep. + +When the platform declares a `max_loop_nesting` limit and the program exceeds it, validation returns a `limit-exceeded` error, `Program nests loops 4 deep; limit max_loop_nesting=3`. `max_parallel_loops` is checked the same way against the widest `Parallel` in the program. ## Wire form -A sweep is a `for` header: `for in (param=value, ...):`, with -every source parameter written as a keyword so that positional ordering cannot -drift. `Values` is the one special case, rendered as the bracket literal -`[0.1, 0.2, 0.3]` and never truncated, since the literal has to reload to -exactly the same sweep. A `Parallel` joins its headers with a pipe, repeating the -`for` keyword for each. `average :` and `block:` are keyword-led like any -registered block, and a `Conditional` writes one header per arm, `if :`, -`elif :`, `else:`, with the condition's outer parentheses dropped. +A sweep is a `for` header: `for in (param=value, ...):`, with every source parameter written as a keyword so that positional ordering cannot drift. `Values` is the one special case, rendered as the bracket literal `[0.1, 0.2, 0.3]` and never truncated, since the literal has to reload to exactly the same sweep. A `Parallel` joins its headers with a pipe, repeating the `for` keyword for each. `average :` and `block:` are keyword-led like any registered block, and a `Conditional` writes one header per arm, `if :`, `elif :`, `else:`, with the condition's outer parentheses dropped. The program below uses every construct on this page. @@ -606,26 +384,9 @@ body: ## Real-time and host-side -QProgram has no real-time loop keyword. The same `sweep` may compile to the -sequencer in one program and be stepped from Python in another, and validation -decides per node. An operation's domain is the set of slots, `rt` and `host`, of -its routed bus that declare the operation's tokens. A block takes the consensus -of its own operation children, and operation children in different domains at -one level are a `mixed-domain` error. Domain constraints contributed by platform -predicates subtract from the block they target, a real-time-only block may not -contain a host-side block child (`host-in-rt`) while the reverse is always -allowed, and a block that could have run real-time but ends up host-side carries -one `forced-host` warning naming the reason. -[Capabilities, diagnostics, and profiles](capabilities.md) has the routing rules -in full. - -The reference platform declares the bus-scoped parameter operations, -`set_parameter` and `get_parameter`, in the `host` half of each bus slot only, -and carries a predicate that excludes `rt` from the loop binding a variable fed -to `set_parameter`. Real platforms are wired the same way, which is what makes -plans and `forced-host` warnings against the reference platform mean something. -A program that sweeps a slow-control knob outside a pulse loop classifies like -this: +QProgram has no real-time loop keyword. The same `sweep` may compile to the sequencer in one program and be stepped from Python in another, and validation decides per node. An operation's domain is the set of slots, `rt` and `host`, of its routed bus that declare the operation's tokens. A block takes the consensus of its own operation children, and operation children in different domains at one level are a `mixed-domain` error. Domain constraints contributed by platform predicates subtract from the block they target, a real-time-only block may not contain a host-side block child (`host-in-rt`) while the reverse is always allowed, and a block that could have run real-time but ends up host-side carries one `forced-host` warning naming the reason. [Capabilities, diagnostics, and profiles](capabilities.md) has the routing rules in full. + +The reference platform declares the bus-scoped parameter operations, `set_parameter` and `get_parameter`, in the `host` half of each bus slot only, and carries a predicate that excludes `rt` from the loop binding a variable fed to `set_parameter`. Real platforms are wired the same way, which is what makes plans and `forced-host` warnings against the reference platform mean something. A program that sweeps a slow-control knob outside a pulse loop classifies like this: ```python platform = qp.ReferencePlatform(schema=schema) @@ -656,23 +417,14 @@ body └─ measure q[0].readout "readout" "weights" name="q0/readout/m0" [rt|host] ``` -The shot loop is host-side here as well, and not because of anything inside it: -a host-side-only sub-block forces its parent, which is what the warning reports. -The inner frequency sweep keeps both domains, so a compiler is free to run it on -the sequencer. +The shot loop is host-side here as well, and not because of anything inside it: a host-side-only sub-block forces its parent, which is what the warning reports. The inner frequency sweep keeps both domains, so a compiler is free to run it on the sequencer. ## Measurement names inside loops -Measurement names are allocated when `measure` is called, not per iteration, and -the counter is derived from the AST on each call rather than stored on the -program. A `measure` inside a `sweep` therefore has exactly one name however -many times the loop runs. Two `measure` calls on the same bus in one loop body -get distinct names, `q0/readout/m0` and `q0/readout/m1`, and the result array -carries both. See [Measurements and results](measurements.md). +Measurement names are allocated when `measure` is called, not per iteration, and the counter is derived from the AST on each call rather than stored on the program. A `measure` inside a `sweep` therefore has exactly one name however many times the loop runs. Two `measure` calls on the same bus in one loop body get distinct names, `q0/readout/m0` and `q0/readout/m1`, and the result array carries both. See [Measurements and results](measurements.md). ## Related pages - [Operations](operations.md) for the operations that go inside these blocks - [Variables and expressions](variables.md) for what a swept variable can feed -- [The `.qp` format](../reference/qp-format.md) for the grammar behind the wire - forms above +- [The `.qp` format](../reference/qp-format.md) for the grammar behind the wire forms above diff --git a/docs/guide/execution.md b/docs/guide/execution.md index 593bc23..6d38b4a 100644 --- a/docs/guide/execution.md +++ b/docs/guide/execution.md @@ -1,24 +1,12 @@ # Running programs -Core qprogram ships one execution back-end. `ReferencePlatform` validates a -program against a permissive capability descriptor, walks the AST in pure -Python, and returns a `QProgramResult` of `xarray.DataArray`s. Nothing is -compiled and no instrument is contacted, so a program runs anywhere the package -is installed. What comes back is the reference semantics vendor compilers are -tested against, which is why the result shapes below are a contract rather than -an implementation detail. - -The executor simulates results, not devices: measurement outcomes come from a -pluggable measurement model, and pulse and timing operations have their -expressions evaluated and then do nothing. -[What the reference executor does not model](#what-the-reference-executor-does-not-model) -draws the boundary in full. +Core qprogram ships one execution back-end. `ReferencePlatform` validates a program against a permissive capability descriptor, walks the AST in pure Python, and returns a `QProgramResult` of `xarray.DataArray`s. Nothing is compiled and no instrument is contacted, so a program runs anywhere the package is installed. What comes back is the reference semantics vendor compilers are tested against, which is why the result shapes below are a contract rather than an implementation detail. + +The executor simulates results, not devices: measurement outcomes come from a pluggable measurement model, and pulse and timing operations have their expressions evaluated and then do nothing. [What the reference executor does not model](#what-the-reference-executor-does-not-model) draws the boundary in full. ## Simulating a program -`qp.simulate(program, *, model=None, schema=None, parameters=None)` builds a -one-off `ReferencePlatform`, executes `program` on it, and returns the result. -Everything after `program` is keyword-only. +`qp.simulate(program, *, model=None, schema=None, parameters=None)` builds a one-off `ReferencePlatform`, executes `program` on it, and returns the result. Everything after `program` is keyword-only. | Argument | Meaning | |---|---| @@ -55,29 +43,15 @@ da = result.get("m0") # dims ("g", "IQ"), coords from the sweep result.plot("m0") # a noisy Rabi oscillation (needs matplotlib, the `viz` extra) ``` -`plot` takes the same arguments `get` does and draws what it finds, choosing -the figure from the array's shape. [Plotting results](plotting.md) covers it. +`plot` takes the same arguments `get` does and draws what it finds, choosing the figure from the array's shape. [Plotting results](plotting.md) covers it. -`simulate` raises rather than returning a partial result. A program that -validation rejects raises `UnsupportedOperationError`, an operation whose -expression references a variable no enclosing loop binds raises -`UnassignedVariableError`, and a measurement that requests `raw` from a model -whose trace is the wrong shape raises `ValueError`. +`simulate` raises rather than returning a partial result. A program that validation rejects raises `UnsupportedOperationError`, an operation whose expression references a variable no enclosing loop binds raises `UnassignedVariableError`, and a measurement that requests `raw` from a model whose trace is the wrong shape raises `ValueError`. ## Result shapes -One record is produced per measurement operation, in the order a setup walk over -the AST finds them, which is declaration order. Each record carries one -`DataArray` per requested measurement field, plus a primary array: the `iq` -field when the measurement requested it, otherwise the first requested field in -canonical order (`state`, `iq`, `raw`). +One record is produced per measurement operation, in the order a setup walk over the AST finds them, which is declaration order. Each record carries one `DataArray` per requested measurement field, plus a primary array: the `iq` field when the measurement requested it, otherwise the first requested field in canonical order (`state`, `iq`, `raw`). -Dimensions are the `Sweep` and `Parallel` blocks enclosing the measurement, -outermost first. A `Sweep` contributes a dimension named after its variable's id -with the sweep values as its coordinate. A parallel composition contributes one -shared dimension named by joining every composed variable's id with `|`, and -attaches one coordinate per variable to it. `Average` and `Conditional` -contribute no dimension of their own. +Dimensions are the `Sweep` and `Parallel` blocks enclosing the measurement, outermost first. A `Sweep` contributes a dimension named after its variable's id with the sweep values as its coordinate. A parallel composition contributes one shared dimension named by joining every composed variable's id with `|`, and attaches one coordinate per variable to it. `Average` and `Conditional` contribute no dimension of their own. For an averaged program with no sweep, a single sweep, and two nested sweeps: @@ -105,8 +79,7 @@ with two.sweep(freq, qp.Range(4e9, 5e9, 0.5e9)), two.sweep(gain, qp.Range(0.0, 1 qp.simulate(two).get("m0").dims # ("freq", "gain", "IQ"), shape (3, 5, 2) ``` -Parallel loops collapse into one axis, so a composition of a three-point and a -three-point sweep is three points, not nine: +Parallel loops collapse into one axis, so a composition of a three-point and a three-point sweep is three points, not nine: ```python import qprogram as qp @@ -124,13 +97,9 @@ da.coords["a"].values # [0.0, 0.5, 1.0] da.coords["b"].values # [10.0, 15.0, 20.0] ``` -Both coordinates describe the same three samples, which is why a figure of them -reads one along the axis and the other on a twin scale opposite it rather than -picking between them. See -[Plotting results](plotting.md#two-variables-on-one-axis). +Both coordinates describe the same three samples, which is why a figure of them reads one along the axis and the other on a twin scale opposite it rather than picking between them. See [Plotting results](plotting.md#two-variables-on-one-axis). -The trailing dimensions depend on which field you ask for. Writing `*sweeps` for -the loop dimensions above: +The trailing dimensions depend on which field you ask for. Writing `*sweeps` for the loop dimensions above: | Field | Dims | Content under `average(shots)` | |---|---|---| @@ -138,20 +107,9 @@ the loop dimensions above: | `state` | `(*sweeps)` | Excited-state population, the mean of the per-shot `0`/`1` classifications. Outside averaging it is the single shot's `0` or `1`. | | `raw` | `(*sweeps, "time", "IQ")` | Mean trace, with `time` coordinates `0 .. raw_samples - 1`. | -`get` reads these through its `field` argument, which defaults to -`qp.MeasurementField.IQ`, matching the default of `measure(..., fields=)`. It -never substitutes a field the measurement did not request, the default included, -so a state-only measurement needs the field spelled out or `get` raises -`KeyError`. A vendor-registered field is legal -to request against the reference platform, since its capabilities cover every -registered token, but the reference model produces only the three core fields: -an accepted vendor field comes back as zeros. - -Averaging works by summing into an accumulator and dividing by a shot count kept -per sweep point, shared across fields. A sweep point where a measurement never -ran has a count of zero and holds NaN rather than the zero a plain division -would leave. That is what a measurement inside a conditional arm looks like at -the points where the branch selected a different arm: +`get` reads these through its `field` argument, which defaults to `qp.MeasurementField.IQ`, matching the default of `measure(..., fields=)`. It never substitutes a field the measurement did not request, the default included, so a state-only measurement needs the field spelled out or `get` raises `KeyError`. A vendor-registered field is legal to request against the reference platform, since its capabilities cover every registered token, but the reference model produces only the three core fields: an accepted vendor field comes back as zeros. + +Averaging works by summing into an accumulator and dividing by a shot count kept per sweep point, shared across fields. A sweep point where a measurement never ran has a count of zero and holds NaN rather than the zero a plain division would leave. That is what a measurement inside a conditional arm looks like at the points where the branch selected a different arm: ```python import numpy as np @@ -173,8 +131,7 @@ arm = qp.simulate(p, model=model).get("m1").sel(IQ="I").values ## Measurement models -The executor asks the model for one sample per measurement shot. The interface is -`MeasurementModel`, a runtime-checkable protocol with a single method: +The executor asks the model for one sample per measurement shot. The interface is `MeasurementModel`, a runtime-checkable protocol with a single method: ```python from collections.abc import Mapping @@ -185,36 +142,15 @@ import qprogram as qp def sample(self, bus: str, env: Mapping[str, float]) -> qp.MeasurementSample: ... ``` -`bus` is the measurement's bus as a plain string, or `""` for a measurement -operation that carries no bus attribute. `env` holds the currently bound loop -variables keyed by variable id, plus the platform parameters keyed -`"bus.parameter"`. Parameter keys always contain a dot and variable ids never -do, so the two can never collide. Only variables with a numeric value are -present: an unbound variable is absent from `env` rather than present with a -placeholder, so a model that indexes it fails with `KeyError` instead of -returning something plausible and wrong. - -`MeasurementSample` is a frozen dataclass with four fields: `i` and `q` are the -shot's in-phase and quadrature floats, `state` is the classified outcome as `0` -or `1`, and `raw` is an array of shape `(raw_samples, 2)` holding I and Q per -time sample. Only the first three are required; `raw` defaults to an empty -`(0, 2)` array, which is what a model that simulates no ADC wants, and it is read -only by a measurement that requests `MeasurementField.RAW`. - -The executor reads the trace length from the model itself, as -`getattr(model, "raw_samples", 16)`, and allocates its accumulator from that -before the first sample arrives. A trace of any other shape is rejected with a -`ValueError` naming the measurement, the shape received, and the shape expected, -rather than being broadcast into the accumulator: a `(2,)` trace would otherwise -be copied across every time sample and produce a wrong result in silence. The -check goes through `numpy.shape`, so a nested list is a valid trace. - -The protocol is runtime-checkable, so `isinstance(model, qp.MeasurementModel)` -answers whether an object satisfies it. - -`MockMeasurementModel` is what `simulate` and `ReferencePlatform` fall back to -when no model is given. Its five arguments cover a response curve, a -classification probability, and the noise around both: +`bus` is the measurement's bus as a plain string, or `""` for a measurement operation that carries no bus attribute. `env` holds the currently bound loop variables keyed by variable id, plus the platform parameters keyed `"bus.parameter"`. Parameter keys always contain a dot and variable ids never do, so the two can never collide. Only variables with a numeric value are present: an unbound variable is absent from `env` rather than present with a placeholder, so a model that indexes it fails with `KeyError` instead of returning something plausible and wrong. + +`MeasurementSample` is a frozen dataclass with four fields: `i` and `q` are the shot's in-phase and quadrature floats, `state` is the classified outcome as `0` or `1`, and `raw` is an array of shape `(raw_samples, 2)` holding I and Q per time sample. Only the first three are required; `raw` defaults to an empty `(0, 2)` array, which is what a model that simulates no ADC wants, and it is read only by a measurement that requests `MeasurementField.RAW`. + +The executor reads the trace length from the model itself, as `getattr(model, "raw_samples", 16)`, and allocates its accumulator from that before the first sample arrives. A trace of any other shape is rejected with a `ValueError` naming the measurement, the shape received, and the shape expected, rather than being broadcast into the accumulator: a `(2,)` trace would otherwise be copied across every time sample and produce a wrong result in silence. The check goes through `numpy.shape`, so a nested list is a valid trace. + +The protocol is runtime-checkable, so `isinstance(model, qp.MeasurementModel)` answers whether an object satisfies it. + +`MockMeasurementModel` is what `simulate` and `ReferencePlatform` fall back to when no model is given. Its five arguments cover a response curve, a classification probability, and the noise around both: | Argument | Meaning | |---|---| @@ -224,15 +160,9 @@ classification probability, and the noise around both: | `raw_samples` | Length of the `raw` trace. Defaults to `16`. | | `seed` | Seed for the model's private `numpy.random.default_rng`. Defaults to `0`. | -One generator drives both the noise and the state draws, so a program run twice -against two models built from the same seed gives identical arrays, and a -different seed gives different draws wherever a draw reaches the result. The -draws happen in execution order, so editing the program changes the sequence -even when the seed does not. +One generator drives both the noise and the state draws, so a program run twice against two models built from the same seed gives identical arrays, and a different seed gives different draws wherever a draw reaches the result. The draws happen in execution order, so editing the program changes the sequence even when the seed does not. -Anything with a `sample` method is a model. Writing one directly is the way to -simulate a response the two callbacks cannot express, or to return a raw trace -with real structure instead of a replicated IQ point: +Anything with a `sample` method is a model. Writing one directly is the way to simulate a response the two callbacks cannot express, or to return a raw trace with real structure instead of a replicated IQ point: ```python import numpy as np @@ -263,18 +193,9 @@ result.get("m0").sel(IQ="I").values # peaks at 1.0 on resonance ## The reference platform -`qp.ReferencePlatform(schema=None, model=None, parameters=None, -vendor_op_handlers=None)` is what `simulate` builds internally, and constructing -it yourself is how you keep the platform between runs. `schema` is returned by -`get_bus_schema()`, which raises `ValueError` when the platform was built -without one, and drives `get_buses()`, which renders one name per -`(element, bus kind)` pair with `*` in the index position because a schema names -kinds rather than enumerating indices. +`qp.ReferencePlatform(schema=None, model=None, parameters=None, vendor_op_handlers=None)` is what `simulate` builds internally, and constructing it yourself is how you keep the platform between runs. `schema` is returned by `get_bus_schema()`, which raises `ValueError` when the platform was built without one, and drives `get_buses()`, which renders one name per `(element, bus kind)` pair with `*` in the index position because a schema names kinds rather than enumerating indices. -`parameters` is copied once into the public `platform.parameters` dict. That copy -is read by `get_parameter`, written by `set_parameter`, and exposed to the -measurement model through `env`, so a run's writes persist on the platform and -are visible to the next `execute` call: +`parameters` is copied once into the public `platform.parameters` dict. That copy is read by `get_parameter`, written by `set_parameter`, and exposed to the measurement model through `env`, so a run's writes persist on the platform and are visible to the next `execute` call: ```python import qprogram as qp @@ -296,51 +217,17 @@ platform.get_global_parameters() # ["cluster.lo"], sorted, fully qualified platform.get_parameters("cluster") # ["lo"] ``` -Reading a key the store does not hold yields `0.0` rather than raising, and -`get_parameters` reports what has been set rather than what a bus accepts, since -the reference platform keeps one flat store and validates no parameter names. -`get_global_parameters` returns those same `"bus.parameter"` keys, not the -bus-less parameters the name suggests. - -`vendor_op_handlers` maps a vendor `Operation` subclass to a callable -`handler(op, parameters)` that runs when an instance of that class executes. A -handled operation skips the interpreter's eager expression evaluation entirely, -which is what lets a get-style vendor operation write its own output variable -without that variable being force-evaluated first; such a handler evaluates any -value expression itself, with the loop variables already bound. Vendor -operations without a handler execute generically, and a vendor measurement -operation records a result like any other measurement. +Reading a key the store does not hold yields `0.0` rather than raising, and `get_parameters` reports what has been set rather than what a bus accepts, since the reference platform keeps one flat store and validates no parameter names. `get_global_parameters` returns those same `"bus.parameter"` keys, not the bus-less parameters the name suggests. + +`vendor_op_handlers` maps a vendor `Operation` subclass to a callable `handler(op, parameters)` that runs when an instance of that class executes. A handled operation skips the interpreter's eager expression evaluation entirely, which is what lets a get-style vendor operation write its own output variable without that variable being force-evaluated first; such a handler evaluates any value expression itself, with the loop variables already bound. Vendor operations without a handler execute generically, and a vendor measurement operation records a result like any other measurement. ### Capabilities and the execution convention -`platform.capabilities` returns `qp.reference_capabilities()`, recomputed on -every access so that a vendor extension imported after the platform was -constructed still has its tokens honored. Every token in the live capability -registry is supported, core and vendor alike, with one deliberate hole: the -bus-scoped parameter operations `op.set_parameter` and `op.get_parameter` appear -in each bus slot's `host` half and are absent from its `rt` half, and the -platform slot carries them in neither half. Setting or reading a platform -parameter is a configuration action rather than a real-time sequencer -instruction, and keeping that restriction here is what makes plans, -`forced-host` warnings, and `explain()` meaningful against the reference -platform instead of uniformly permissive. - -The descriptor also carries one predicate. A `set_parameter` whose value is a -bound variable emits a `DomainConstraint` excluding `rt` from the loop that -binds the variable, with the reason -`parameter '' is swept via set_parameter (host-side dispatch per iteration)`. -The constraint targets the loop, not the operation, so the operation stays -real-time capable while the loop around it drops to host-side. - -`execute` follows the convention the protocol documents. It expands fragment -calls, validates, raises `UnsupportedOperationError` listing every -`severity="error"` diagnostic, re-emits every `severity="warning"` diagnostic -through `warnings.warn` under the `qp.ExecutionWarning` category, drops -`severity="info"` diagnostics, and only then interprets. It accepts and ignores -any `**kwargs`, so a call written for a real back-end still runs here. -`ExecutionWarning` subclasses `UserWarning`, so -`warnings.simplefilter("error", qp.ExecutionWarning)` turns a forced-host -fallback into a failure in a test suite. +`platform.capabilities` returns `qp.reference_capabilities()`, recomputed on every access so that a vendor extension imported after the platform was constructed still has its tokens honored. Every token in the live capability registry is supported, core and vendor alike, with one deliberate hole: the bus-scoped parameter operations `op.set_parameter` and `op.get_parameter` appear in each bus slot's `host` half and are absent from its `rt` half, and the platform slot carries them in neither half. Setting or reading a platform parameter is a configuration action rather than a real-time sequencer instruction, and keeping that restriction here is what makes plans, `forced-host` warnings, and `explain()` meaningful against the reference platform instead of uniformly permissive. + +The descriptor also carries one predicate. A `set_parameter` whose value is a bound variable emits a `DomainConstraint` excluding `rt` from the loop that binds the variable, with the reason `parameter '' is swept via set_parameter (host-side dispatch per iteration)`. The constraint targets the loop, not the operation, so the operation stays real-time capable while the loop around it drops to host-side. + +`execute` follows the convention the protocol documents. It expands fragment calls, validates, raises `UnsupportedOperationError` listing every `severity="error"` diagnostic, re-emits every `severity="warning"` diagnostic through `warnings.warn` under the `qp.ExecutionWarning` category, drops `severity="info"` diagnostics, and only then interprets. It accepts and ignores any `**kwargs`, so a call written for a real back-end still runs here. `ExecutionWarning` subclasses `UserWarning`, so `warnings.simplefilter("error", qp.ExecutionWarning)` turns a forced-host fallback into a failure in a test suite. A swept `set_parameter` is the common way to see the warning path: @@ -368,19 +255,9 @@ caught[0].category # qp.ExecutionWarning [warning] forced-host: Block 'Average' falls back to host-side execution: contains host-side-only sub-block 'Sweep' (parameter 'lo' is swept via set_parameter (host-side dispatch per iteration)). (at body[0]) ``` -Validation also emits an info-severity `reorderable-averaging` hint for this -program, because the average encloses a host-side sweep whose measurement -sequence could run in a real-time inner average. Because it is info severity, -`execute` drops it: reach for `qp.validate(p, platform.capabilities)` or -`platform.explain(p)` to see it, and `qp.optimize(p, platform.capabilities)` -to apply the rewrite it suggests. The hint fires only where the rewrite is -actually possible, which means an average whose sole child is a flat sweep -whose body opens with a contiguous run of host-side-only operations and -continues with real-time-capable ones, at least one of which affects averaging. +Validation also emits an info-severity `reorderable-averaging` hint for this program, because the average encloses a host-side sweep whose measurement sequence could run in a real-time inner average. Because it is info severity, `execute` drops it: reach for `qp.validate(p, platform.capabilities)` or `platform.explain(p)` to see it, and `qp.optimize(p, platform.capabilities)` to apply the rewrite it suggests. The hint fires only where the rewrite is actually possible, which means an average whose sole child is a flat sweep whose body opens with a contiguous run of host-side-only operations and continues with real-time-capable ones, at least one of which affects averaging. -`platform.validate`, `platform.plan`, and `platform.explain` are the protocol's -inherited defaults and go through `qp.validate` without interpreting anything, -so they cost a validation walk and no shots: +`platform.validate`, `platform.plan`, and `platform.explain` are the protocol's inherited defaults and go through `qp.validate` without interpreting anything, so they cost a validation walk and no shots: ```python import qprogram as qp @@ -399,44 +276,19 @@ body ### State feedback -Each measurement writes its classified state onto the shared -`MeasurementHandle` before the shot is accumulated, so a conditional later in -the same iteration reads the outcome that measurement just produced and -`with p.if_(m.state == 1): ...` branches per shot. The conditional runs the -first arm whose condition holds, or the `else` body when none does. This works -because every reference to a measurement holds the same handle instance; nothing -else is wired up. +Each measurement writes its classified state onto the shared `MeasurementHandle` before the shot is accumulated, so a conditional later in the same iteration reads the outcome that measurement just produced and `with p.if_(m.state == 1): ...` branches per shot. The conditional runs the first arm whose condition holds, or the `else` body when none does. This works because every reference to a measurement holds the same handle instance; nothing else is wired up. ## What the reference executor does not model -There is no timing simulation and no waveform physics. `play`, `wait`, `sync`, -`reset_phase`, and the bus-level `set_*` operations (`set_frequency`, -`set_phase`, `set_gain`, `set_offset`) have every `Expression` reachable from -their public attributes force-evaluated, which is what makes an unbound variable -raise `UnassignedVariableError` at execution time instead of producing nonsense -downstream, and then they do nothing. Durations, phases, amplitudes, and -alignment have no effect on the numbers that come back. Whatever structure the -results carry comes from the measurement model, not from the pulses. - -String waveform names are not resolved. A program that plays `"pi"` runs, and the -operation is a no-op, so the result is the same as it would be with the name -resolved. Real platforms need the concrete waveform, so resolve names with -`program.with_waveforms(library)` before handing a program to hardware rather -than relying on the reference platform's tolerance. - -The measurement model is consulted per shot and knows nothing about the state -the device would be in, so state preparation, decoherence, and crosstalk are -absent unless a model computes them from `env`. Vendor operations run -generically: their expressions are evaluated, a measurement operation records a -result, and everything else is a no-op unless a `vendor_op_handlers` entry gives -it an effect. Streaming is not implemented, so `platform.stream(p)` raises -`NotImplementedError("Streaming not supported by this platform")` from the -protocol's default. +There is no timing simulation and no waveform physics. `play`, `wait`, `sync`, `reset_phase`, and the bus-level `set_*` operations (`set_frequency`, `set_phase`, `set_gain`, `set_offset`) have every `Expression` reachable from their public attributes force-evaluated, which is what makes an unbound variable raise `UnassignedVariableError` at execution time instead of producing nonsense downstream, and then they do nothing. Durations, phases, amplitudes, and alignment have no effect on the numbers that come back. Whatever structure the results carry comes from the measurement model, not from the pulses. + +String waveform names are not resolved. A program that plays `"pi"` runs, and the operation is a no-op, so the result is the same as it would be with the name resolved. Real platforms need the concrete waveform, so resolve names with `program.with_waveforms(library)` before handing a program to hardware rather than relying on the reference platform's tolerance. + +The measurement model is consulted per shot and knows nothing about the state the device would be in, so state preparation, decoherence, and crosstalk are absent unless a model computes them from `env`. Vendor operations run generically: their expressions are evaluated, a measurement operation records a result, and everything else is a no-op unless a `vendor_op_handlers` entry gives it an effect. Streaming is not implemented, so `platform.stream(p)` raises `NotImplementedError("Streaming not supported by this platform")` from the protocol's default. ## Implementing a platform -`qp.PlatformProtocol` is an abstract base class, and a hardware back-end -subclasses it. Six members are abstract: +`qp.PlatformProtocol` is an abstract base class, and a hardware back-end subclasses it. Six members are abstract: | Member | Contract | |---|---| @@ -447,20 +299,9 @@ subclasses it. Six members are abstract: | `capabilities` | A `PlatformCapabilities` property: per-`(element, bus_kind)` bus profiles, a platform-level profile for blocks, expressions, and bus-less operations, and a default bus profile that raw-string buses fall back to. | | `execute(qprogram)` | Run the program and return one record per measurement. | -`validate`, `plan`, and `explain` come with working defaults that delegate to -`qp.validate` and `qp.explain` against `self.capabilities`, so a subclass gets -them for free and overrides only to prepend device-specific predicates or to -short-circuit on the first error. `validate` and `plan` each discard half of -what `qp.validate` returns; an `execute` that gates on diagnostics and then -compiles against the plan should call `qp.validate` directly rather than paying -for two walks. `stream` is optional and raises `NotImplementedError` by default. - -The convention `execute` is expected to follow is the one `ReferencePlatform` -implements: validate first, raise `UnsupportedOperationError` on any -`severity="error"` diagnostic, and surface warnings and info without raising. -Nothing enforces it, but a platform that skips the check hands its users a -vendor compiler's error text in place of a structured diagnostic that names the -offending node. +`validate`, `plan`, and `explain` come with working defaults that delegate to `qp.validate` and `qp.explain` against `self.capabilities`, so a subclass gets them for free and overrides only to prepend device-specific predicates or to short-circuit on the first error. `validate` and `plan` each discard half of what `qp.validate` returns; an `execute` that gates on diagnostics and then compiles against the plan should call `qp.validate` directly rather than paying for two walks. `stream` is optional and raises `NotImplementedError` by default. + +The convention `execute` is expected to follow is the one `ReferencePlatform` implements: validate first, raise `UnsupportedOperationError` on any `severity="error"` diagnostic, and surface warnings and info without raising. Nothing enforces it, but a platform that skips the check hands its users a vendor compiler's error text in place of a structured diagnostic that names the offending node. ```python import qprogram as qp @@ -496,15 +337,11 @@ class MyPlatform(qp.PlatformProtocol): return qp.QProgramResult() ``` -The capability descriptor is the part that takes real work, since it decides -which programs the platform accepts and which nodes fall back to host-side -dispatch. `qp.reference_capabilities()` is a readable starting point and -[Capabilities](capabilities.md) covers the token vocabulary. +The capability descriptor is the part that takes real work, since it decides which programs the platform accepts and which nodes fall back to host-side dispatch. `qp.reference_capabilities()` is a readable starting point and [Capabilities](capabilities.md) covers the token vocabulary. ## See also - [Measurements and results](measurements.md): handles, names, `QProgramResult`. - [Capabilities](capabilities.md): validation, plans, `explain()`. - [Fragments](fragments.md): composed programs expand before execution. -- [Capability protocol](../developer/capability-protocol.md): building a - `PlatformCapabilities` descriptor for a real device. +- [Capability protocol](../developer/capability-protocol.md): building a `PlatformCapabilities` descriptor for a real device. diff --git a/docs/guide/fragments.md b/docs/guide/fragments.md index 9d64cca..f989b57 100644 --- a/docs/guide/fragments.md +++ b/docs/guide/fragments.md @@ -1,19 +1,10 @@ # Fragments -A `Fragment` is a named, parameterized program template: an X gate, an echo -sequence, or a readout block defined once and instantiated wherever it is -needed. `program.call(...)` appends a `qp.operations.Call` node rather than -copying the body in, so the definition and every call site stay in the AST. A -composed program serializes as one `fragment` section per definition plus a -one-line statement per call site, and loads back the same way. -`program.expand()` produces the flat, fragment-free program that compilers and -validators consume. +A `Fragment` is a named, parameterized program template: an X gate, an echo sequence, or a readout block defined once and instantiated wherever it is needed. `program.call(...)` appends a `qp.operations.Call` node rather than copying the body in, so the definition and every call site stay in the AST. A composed program serializes as one `fragment` section per definition plus a one-line statement per call site, and loads back the same way. `program.expand()` produces the flat, fragment-free program that compilers and validators consume. ## Defining a fragment -The `@fragment` decorator records a fragment from a function. The first -parameter receives the fragment builder, every parameter after it becomes a -`Parameter`, and the function's `__name__` becomes the fragment name: +The `@fragment` decorator records a fragment from a function. The first parameter receives the fragment builder, every parameter after it becomes a `Parameter`, and the function's `__name__` becomes the fragment name: ```python import qprogram as qp @@ -24,16 +15,9 @@ def x_pulse(f, drive, amp): f.play(drive, qp.waveforms.Gaussian(amplitude=amp, duration=40, sigma=8)) ``` -The decorated name *is* the fragment: after decoration `x_pulse` is a -`Fragment` instance, not a function. The body runs once, at decoration time, to -record the AST, so a Python `if` or `for` inside it is evaluated at definition -and its outcome is baked into the recorded tree. Varying the body per call site -takes a parameter, not Python control flow. +The decorated name *is* the fragment: after decoration `x_pulse` is a `Fragment` instance, not a function. The body runs once, at decoration time, to record the AST, so a Python `if` or `for` inside it is evaluated at definition and its outcome is baked into the recorded tree. Varying the body per call site takes a parameter, not Python control flow. -The signature must be plain positional parameters with no defaults, because the -`.qp` grammar has no way to spell anything else. Defaults, `*args`, `**kwargs`, -keyword-only parameters, and a function with no builder parameter at all are -each rejected at decoration time: +The signature must be plain positional parameters with no defaults, because the `.qp` grammar has no way to spell anything else. Defaults, `*args`, `**kwargs`, keyword-only parameters, and a function with no builder parameter at all are each rejected at decoration time: ``` ValidationError: @fragment 'bad': parameter 'amp' has a default value; @@ -43,8 +27,7 @@ ValidationError: @fragment 'bad': parameter 'args' is variadic positional; only plain positional parameters are supported ``` -The explicit API records the same fragment and suits construction from data, a -fragment built per qubit for instance: +The explicit API records the same fragment and suits construction from data, a fragment built per qubit for instance: ```python xp = qp.Fragment("x_pulse") @@ -53,10 +36,7 @@ amp = xp.parameter("amp") xp.play(drive, qp.waveforms.Gaussian(amplitude=amp, duration=40, sigma=8)) ``` -The two fragments above compare equal, because `Fragment.__eq__` compares name, -parameter ids, local variables, and body, and nothing else. The name is used -verbatim as the `.qp` definition and call name, so it has to match -`[A-Za-z_][A-Za-z0-9_]*` and must not be in `qp.RESERVED_KEYWORDS`: +The two fragments above compare equal, because `Fragment.__eq__` compares name, parameter ids, local variables, and body, and nothing else. The name is used verbatim as the `.qp` definition and call name, so it has to match `[A-Za-z_][A-Za-z0-9_]*` and must not be in `qp.RESERVED_KEYWORDS`: ``` ValidationError: fragment name 'x pulse' is invalid: must match @@ -66,29 +46,17 @@ ValidationError: fragment name 'for' is a reserved keyword (see qprogram.RESERVED_KEYWORDS) ``` -`Fragment` also takes `label` and `description`, but a `.qp` fragment section -is headed by the name and parameter list alone, so neither survives -serialization. Use them for in-process tooling only. +`Fragment` also takes `label` and `description`, but a `.qp` fragment section is headed by the name and parameter list alone, so neither survives serialization. Use them for in-process tooling only. -A `Fragment` is a `QProgram` subclass, so a body is built exactly like a -program body: operations, `average`, `sweep`, `if_`, vendor namespaces -(`f..(...)`), local variables through `f.variable(...)`, and -`f.call(...)` to another fragment. +A `Fragment` is a `QProgram` subclass, so a body is built exactly like a program body: operations, `average`, `sweep`, `if_`, vendor namespaces (`f..(...)`), local variables through `f.variable(...)`, and `f.call(...)` to another fragment. ## Parameters -`parameter(id, *, label=None)` declares a placeholder and returns it. -`Parameter` subclasses `Variable`, so it takes part in expressions (`amp * 2`), -follows the same identifier rules, and serializes as a bare identifier. +`parameter(id, *, label=None)` declares a placeholder and returns it. `Parameter` subclasses `Variable`, so it takes part in expressions (`amp * 2`), follows the same identifier rules, and serializes as a bare identifier. -Parameters are untyped. One `Parameter` may stand in value, bus, or waveform -position, and the binding at the call site is what decides which it was. That -is why kind errors surface at expansion rather than at definition, and why the -builder skips its usual bus and waveform checks on a position that holds a -parameter. +Parameters are untyped. One `Parameter` may stand in value, bus, or waveform position, and the binding at the call site is what decides which it was. That is why kind errors surface at expansion rather than at definition, and why the builder skips its usual bus and waveform checks on a position that holds a parameter. -Parameters and fragment-local variables share one identifier namespace inside -the body, so a collision is rejected whichever order it happens in: +Parameters and fragment-local variables share one identifier namespace inside the body, so a collision is rejected whichever order it happens in: ``` ValidationError: Parameter 'a' is already declared on fragment 'f' @@ -96,17 +64,13 @@ ValidationError: Parameter 'a' collides with a local variable of fragment 'f' ValidationError: Variable 'a' collides with a parameter of fragment 'f' ``` -Sweep bounds are the one position a parameter cannot occupy. A sweep source -validates its numbers when it is constructed, which happens while the fragment -body is being recorded, long before any binding exists: +Sweep bounds are the one position a parameter cannot occupy. A sweep source validates its numbers when it is constructed, which happens while the fragment body is being recorded, long before any binding exists: ``` ValidationError: Range stop must be an int or float, got Parameter ``` -A loop *variable* may be a parameter, which lets a fragment sweep a variable -the host owns. The binding then has to be a `Variable`, and anything else is -caught at expansion: +A loop *variable* may be a parameter, which lets a fragment sweep a variable the host owns. The binding then has to be a `Variable`, and anything else is caught at expansion: ``` ValidationError: fragment 'scan': a loop variable must be bound to a variable, got int @@ -114,9 +78,7 @@ ValidationError: fragment 'scan': a loop variable must be bound to a variable, g ## Calling a fragment -`call(fragment, *args, **kwargs)` appends the `Call` node. Arguments bind with -the Python calling convention: positionals in parameter declaration order, then -keywords by parameter id. +`call(fragment, *args, **kwargs)` appends the `Call` node. Arguments bind with the Python calling convention: positionals in parameter declaration order, then keywords by parameter id. ```python @qp.fragment @@ -144,8 +106,7 @@ with p.average(1000), p.sweep(g, qp.Range(0, 1, 0.5)): p.call(ro, bus="readout_q1") ``` -A binding error is raised at the call site rather than deferred to expansion, -and it covers the four ways binding can go wrong: +A binding error is raised at the call site rather than deferred to expansion, and it covers the four ways binding can go wrong: ``` ValidationError: fragment 'f' takes 1 argument(s) (a) but 2 positional @@ -155,32 +116,21 @@ ValidationError: fragment 'f' got multiple values for parameter 'a' ValidationError: fragment 'f' missing argument(s) for parameter(s): b ``` -The accepted argument kinds are numbers, expressions (which covers a -`Variable`, a `Parameter` of the calling fragment, and arithmetic over them), -buses as plain strings or `BusRef`s, and waveforms. Everything else is -rejected, `bool` included even though it is an `int` subclass: +The accepted argument kinds are numbers, expressions (which covers a `Variable`, a `Parameter` of the calling fragment, and arithmetic over them), buses as plain strings or `BusRef`s, and waveforms. Everything else is rejected, `bool` included even though it is an `int` subclass: ``` ValidationError: fragment 'f' parameter 'a': unsupported argument type list; expected a number, expression/variable, bus (string or BusRef), or waveform ``` -A `BusRef` argument is validated against the calling program's schema the way a -bus argument to any operation is. A fragment built against a `BusSchema` lends -it to a program that has none; two different schema objects are an error, -because the `.qp` writer has a single `schema:` section to resolve bus paths -against: +A `BusRef` argument is validated against the calling program's schema the way a bus argument to any operation is. A fragment built against a `BusSchema` lends it to a program that has none; two different schema objects are an error, because the `.qp` writer has a single `schema:` section to resolve bus paths against: ``` ValidationError: fragment 'ro' was built against a different BusSchema than this program's; a program and its fragments must share one schema ``` -`call` also registers the fragment, and every fragment it calls, on -`program.fragments`, dependencies first, so iteration order is topological. The -registry is keyed by name, which makes two different fragments sharing a name a -build-time error rather than a silent overwrite. A fragment cannot call itself, -and a cycle is caught as soon as it is visible: +`call` also registers the fragment, and every fragment it calls, on `program.fragments`, dependencies first, so iteration order is topological. The registry is keyed by name, which makes two different fragments sharing a name a build-time error rather than a silent overwrite. A fragment cannot call itself, and a cycle is caught as soon as it is visible: ``` ValidationError: a different fragment named 'dup' is already used by this @@ -190,16 +140,11 @@ ValidationError: fragment 'selfish' cannot call itself ValidationError: fragment call cycle: a -> b -> a ``` -Registration walks the callee's body each time, because a fragment can gain -nested calls after it was first registered. A cycle that closes only after -registration is caught by `expand()` instead, with the same message. -`program.fragments` returns a copy of the registry, so mutating what it returns -changes nothing. +Registration walks the callee's body each time, because a fragment can gain nested calls after it was first registered. A cycle that closes only after registration is caught by `expand()` instead, with the same message. `program.fragments` returns a copy of the registry, so mutating what it returns changes nothing. ## On the wire -Fragment definitions are top-level sections before `body:`, and a call site is -a bare `name(args)` statement. `qp.dumps(p)` on the program above gives: +Fragment definitions are top-level sections before `body:`, and a call site is a bare `name(args)` statement. `qp.dumps(p)` on the program above gives: ``` #!QProgram 0.2 @@ -232,23 +177,11 @@ body: readout("readout_q1") ``` -Arguments are emitted positionally in parameter order, so `p.call(ro, -bus="readout_q0")` is written `readout("readout_q0")`: the keyword spelling -used at build time is not part of the wire form. The parser still accepts -`key=value` at a call site, for hand-written files, and rejects a positional -argument that follows a keyword one. +Arguments are emitted positionally in parameter order, so `p.call(ro, bus="readout_q0")` is written `readout("readout_q0")`: the keyword spelling used at build time is not part of the wire form. The parser still accepts `key=value` at a call site, for hand-written files, and rejects a positional argument that follows a keyword one. -A fragment's own `var` declarations and auto-allocated measurement names are -scoped to its section. The measurement above is `m0` inside `fragment readout` -regardless of how many measurements the host body has, because auto-naming -counts within the fragment; the uniquifying happens at expansion instead. +A fragment's own `var` declarations and auto-allocated measurement names are scoped to its section. The measurement above is `m0` inside `fragment readout` regardless of how many measurements the host body has, because auto-naming counts within the fragment; the uniquifying happens at expansion instead. -The writer computes definition order itself, depth-first over the nested `Call` -nodes, rather than trusting registration order, so a file always defines a -fragment before any fragment that calls it. Define-before-use is what the -parser enforces, which makes the file order topological by construction. A -forward reference, a name defined twice, and a definition that comes after -`body:` are each a `ParseError`: +The writer computes definition order itself, depth-first over the nested `Call` nodes, rather than trusting registration order, so a file always defines a fragment before any fragment that calls it. Define-before-use is what the parser enforces, which makes the file order topological by construction. A forward reference, a name defined twice, and a definition that comes after `body:` are each a `ParseError`: ``` ParseError: Line 4: unknown fragment 'inner'; fragments must be defined in a @@ -259,14 +192,9 @@ ParseError: Line 6: duplicate fragment definition 'f' ParseError: Line 6: fragment definitions must appear before the `body:` section ``` -A vendor operation reachable only through a call still gets its `require` line: -the writer scans fragment bodies alongside the program body when it collects -vendor requirements. A definition nothing calls also round-trips, since -`loads()` registers every `fragment` section it reads and the writer emits -every registered fragment. +A vendor operation reachable only through a call still gets its `require` line: the writer scans fragment bodies alongside the program body when it collects vendor requirements. A definition nothing calls also round-trips, since `loads()` registers every `fragment` section it reads and the writer emits every registered fragment. -A fragment cannot be serialized on its own, because the definition is a section -of a host program and carries no header, schema, or metadata of its own: +A fragment cannot be serialized on its own, because the definition is a section of a host program and carries no header, schema, or metadata of its own: ``` SerializationError: cannot serialize Fragment 'f' directly; fragments are @@ -274,13 +202,11 @@ emitted as `fragment ...:` sections of the host QProgram that calls them — serialize that program ``` -The grammar for both forms, with the argument token shapes a call statement -accepts, is in [the `.qp` format reference](../reference/qp-format.md#fragments). +The grammar for both forms, with the argument token shapes a call statement accepts, is in [the `.qp` format reference](../reference/qp-format.md#fragments). ## Expansion -`expand()` returns a new program with every call inlined; the original is -untouched. On the program above: +`expand()` returns a new program with every call inlined; the original is untouched. On the program above: ``` #!QProgram 0.2 @@ -312,30 +238,13 @@ body: play "readout_q1" "reset" ``` -Each call site becomes a plain `block:` holding a copy of the fragment body. -The block is what keeps a multi-operation fragment from being spliced into its -surroundings, and it is an ordinary `qp.blocks.Block`, so nothing downstream -needs to know a fragment was ever involved. +Each call site becomes a plain `block:` holding a copy of the fragment body. The block is what keeps a multi-operation fragment from being spliced into its surroundings, and it is an ordinary `qp.blocks.Block`, so nothing downstream needs to know a fragment was ever involved. -Three substitutions happen inside that copy. Parameters are replaced by their -bound arguments. Fragment-local variables are hoisted onto the host program -under `{fragment}_{id}`, taking the lowest free numeric suffix when the name is -taken, whether by a repeated call (`readout_n`, `readout_n_2`) or by a host -variable that already holds it. Measurement names already in use gain the same -kind of suffix (`m0`, `m0_2`), and the rename lands on the shared -`MeasurementHandle`, so the `handle.state` conditional inside the fragment body -keeps pointing at its own measurement rather than at the first call's. +Three substitutions happen inside that copy. Parameters are replaced by their bound arguments. Fragment-local variables are hoisted onto the host program under `{fragment}_{id}`, taking the lowest free numeric suffix when the name is taken, whether by a repeated call (`readout_n`, `readout_n_2`) or by a host variable that already holds it. Measurement names already in use gain the same kind of suffix (`m0`, `m0_2`), and the rename lands on the shared `MeasurementHandle`, so the `handle.state` conditional inside the fragment body keeps pointing at its own measurement rather than at the first call's. -Value substitution preserves the shape the builder would have produced. A bare -parameter in value position is replaced by the raw binding, so `f.wait(drive, -t)` bound to `40` becomes `wait "drive_q0" 40` and not a wrapped constant, -while a parameter inside an expression is wrapped: `t * 2` bound to `50` -becomes `Constant(50) * Constant(2)`. A bound host `Variable` is substituted by -identity, so a sweep over it drives the operation inside the expanded body at -run time. +Value substitution preserves the shape the builder would have produced. A bare parameter in value position is replaced by the raw binding, so `f.wait(drive, t)` bound to `40` becomes `wait "drive_q0" 40` and not a wrapped constant, while a parameter inside an expression is wrapped: `t * 2` bound to `50` becomes `Constant(50) * Constant(2)`. A bound host `Variable` is substituted by identity, so a sweep over it drives the operation inside the expanded body at run time. -Kinds are checked as the substitution lands, and each error names the fragment -and what it found: +Kinds are checked as the substitution lands, and each error names the fragment and what it found: ``` ValidationError: fragment 'f': parameter 'x' is used in an expression but @@ -348,9 +257,7 @@ ValidationError: fragment 'f': Play.waveform must be a waveform or alias after expansion, got float ``` -A bound `BusRef` also re-runs the two checks the builder had to skip while the -position held a parameter, so a fragment written for a single-channel bus and -called with an IQ bus fails here rather than in a vendor compiler: +A bound `BusRef` also re-runs the two checks the builder had to skip while the position held a parameter, so a fragment written for a single-channel bus and called with an IQ bus fails here rather than in a vendor compiler: ``` ValidationError: Bus 'q0/drive' is an IQ channel but received a single-channel @@ -360,85 +267,44 @@ ValidationError: Bus 'q0/drive' does not support acquisition (acquires=False). measure() can only be called on buses with an ADC (e.g. readout buses). ``` -Nested calls expand recursively, including calls inside conditional arms, and -expansion runs in document order, so expanding twice yields structurally equal -programs. A program with no calls at all comes back as a deep copy with the -same structure. Either way the returned program's `fragments` registry is -empty, and so is its `source_map`: expansion restructures the tree that the -recorded paths address, which would leave the map pointing at the wrong nodes. +Nested calls expand recursively, including calls inside conditional arms, and expansion runs in document order, so expanding twice yields structurally equal programs. A program with no calls at all comes back as a deep copy with the same structure. Either way the returned program's `fragments` registry is empty, and so is its `source_map`: expansion restructures the tree that the recorded paths address, which would leave the map pointing at the wrong nodes. ```python flat = p.expand() assert not flat.fragments ``` -One transform does not follow calls on its own. `with_waveforms` walks the -program body, so a string waveform alias inside a fragment body is left -unresolved while the call is still a call. Expand first when resolving against -a library: +One transform does not follow calls on its own. `with_waveforms` walks the program body, so a string waveform alias inside a fragment body is left unresolved while the call is still a call. Expand first when resolving against a library: ```python flat = p.expand().with_waveforms({"pi": qp.waveforms.Square(0.5, 40)}) ``` -`rebind` expands first whenever `program.fragments` is non-empty, so its -result always carries an empty registry. `optimize` expands only when the -program's own body holds an `average`, which is what forces the classifying -validation; a program whose only `average` sits in a fragment body comes back -as a deep copy with its calls intact. +`rebind` expands first whenever `program.fragments` is non-empty, so its result always carries an empty registry. `optimize` expands only when the program's own body holds an `average`, which is what forces the classifying validation; a program whose only `average` sits in a fragment body comes back as a deep copy with its calls intact. ## Keeping calls or expanding -Keep the calls while a program is being written, stored, or read. The composed -`.qp` text above carries one `readout` definition where the expanded text -carries two copies of it, and an edit to the definition reaches every call site -at once. Structural equality and the round trip both work on the composed form, -so it is the form to compare and to archive. - -Expand when something needs a flat tree, or when you need to hold the nodes -that will actually execute. The identity-keyed `ExecutionPlan` is the usual -reason: it is keyed on the node instances of whichever program was validated, -and validating a composed program validates a private expansion, so a `Call` -node you hold is not in the plan. Expand yourself and validate the result if -you want to look nodes up. Expansion is also the way to hand a program to code -that predates fragments, since a `Call` is an `Operation` subclass a compiler -will not recognize. +Keep the calls while a program is being written, stored, or read. The composed `.qp` text above carries one `readout` definition where the expanded text carries two copies of it, and an edit to the definition reaches every call site at once. Structural equality and the round trip both work on the composed form, so it is the form to compare and to archive. + +Expand when something needs a flat tree, or when you need to hold the nodes that will actually execute. The identity-keyed `ExecutionPlan` is the usual reason: it is keyed on the node instances of whichever program was validated, and validating a composed program validates a private expansion, so a `Call` node you hold is not in the plan. Expand yourself and validate the result if you want to look nodes up. Expansion is also the way to hand a program to code that predates fragments, since a `Call` is an `Operation` subclass a compiler will not recognize. ## Validation and execution -`qp.validate` expands the program when it finds a `Call` anywhere in the body, -then checks capabilities against the substituted bodies. No platform needs a -"call" capability token, and `Call.required_capabilities()` returns the empty -set to match. The diagnostics and the plan describe nodes of that internal -expansion: +`qp.validate` expands the program when it finds a `Call` anywhere in the body, then checks capabilities against the substituted bodies. No platform needs a "call" capability token, and `Call.required_capabilities()` returns the empty set to match. The diagnostics and the plan describe nodes of that internal expansion: ```python platform = qp.ReferencePlatform() diagnostics, plan = qp.validate(p, platform.capabilities) ``` -Looking up a node you hold in that plan raises `KeyError` on the node's repr, -`Call(x_pulse(drive='drive_q0', amp=Variable('g')))` for instance. Expand -explicitly and validate the expanded program when you need those lookups. +Looking up a node you hold in that plan raises `KeyError` on the node's repr, `Call(x_pulse(drive='drive_q0', amp=Variable('g')))` for instance. Expand explicitly and validate the expanded program when you need those lookups. -`qp.explain` expands on the same condition and says so in its header, which -reads `plan (fragments expanded)` followed by the severity counts. -`ReferencePlatform.execute` expands whenever `program.fragments` is non-empty, -on a copy, before validating and interpreting, and the platform convention asks -a vendor back end to do the same. A `Call` node is therefore not something a -compiler has to know about: every entry point removes it first. +`qp.explain` expands on the same condition and says so in its header, which reads `plan (fragments expanded)` followed by the severity counts. `ReferencePlatform.execute` expands whenever `program.fragments` is non-empty, on a copy, before validating and interpreting, and the platform convention asks a vendor back end to do the same. A `Call` node is therefore not something a compiler has to know about: every entry point removes it first. -One place the composed form is visible to a caller is `program.buses`. A `Call` -reports every string-valued argument as a bus, because a `Parameter` is untyped -and only the position it lands in inside the body decides whether the string -was a bus or a waveform alias. The set therefore over-approximates for a -composed program, and is exact once expanded. +One place the composed form is visible to a caller is `program.buses`. A `Call` reports every string-valued argument as a bus, because a `Parameter` is untyped and only the position it lands in inside the body decides whether the string was a bus or a waveform alias. The set therefore over-approximates for a composed program, and is exact once expanded. ## Related pages -- [Running programs](execution.md): what `execute` does with a program, and the - diagnostics it raises. -- [Capabilities and validation](capabilities.md): `Diagnostic`s, domains, and - the `ExecutionPlan`. -- [Measurements and results](measurements.md): handles, names, and - `QProgramResult`. +- [Running programs](execution.md): what `execute` does with a program, and the diagnostics it raises. +- [Capabilities and validation](capabilities.md): `Diagnostic`s, domains, and the `ExecutionPlan`. +- [Measurements and results](measurements.md): handles, names, and `QProgramResult`. diff --git a/docs/guide/index.md b/docs/guide/index.md index 474c01f..71f2950 100644 --- a/docs/guide/index.md +++ b/docs/guide/index.md @@ -1,10 +1,6 @@ # User guide -These pages are ordered the way most readers need them: the vocabulary a -program is written in first, then the operations and blocks built out of it, -then what a platform makes of the finished tree. Each page stands on its own, so -starting in the middle costs only the terms it borrows, and those are linked -where they are used. +These pages are ordered the way most readers need them: the vocabulary a program is written in first, then the operations and blocks built out of it, then what a platform makes of the finished tree. Each page stands on its own, so starting in the middle costs only the terms it borrows, and those are linked where they are used. | Page | What it documents | |---|---| @@ -21,12 +17,4 @@ where they are used. | [Plotting results](plotting.md) | `QProgramResult.plot`: the figure a result's shape asks for, the `channels` argument that decides what becomes of the `IQ` dimension, where an axis label comes from, the `Quantity` that restates a coordinate in the units you want to read it in, the `Style` and `Theme` dataclasses, and registering a renderer of your own. | | [Saving and loading](serialization.md) | `dumps`, `loads`, `save`, and `load`: what the round trip preserves and what it drops, the format version and `require` lines, how a file from an earlier version migrates on the way in, vendor activation at parse time, the normalizations the writer applies, and the `WaveformLibrary` that quoted aliases resolve through, with its own `.wfl` file. | -Two worked programs, each given in full from the builder calls to the result -array, are in [Examples](../examples/index.md). The grammar behind the wire -forms quoted on these pages is in [.qp file format](../reference/qp-format.md), -and the exceptions they raise are cataloged in -[Errors](../reference/errors.md). For adding a whole instrument vocabulary to -the language, [Building a vendor extension](../developer/vendor-extensions.md) -works through a package end to end, and the -[developer guide](../developer/index.md) has the in-tree recipes for a core -operation and for a new waveform. +Two worked programs, each given in full from the builder calls to the result array, are in [Examples](../examples/index.md). The grammar behind the wire forms quoted on these pages is in [.qp file format](../reference/qp-format.md), and the exceptions they raise are cataloged in [Errors](../reference/errors.md). For adding a whole instrument vocabulary to the language, [Building a vendor extension](../developer/vendor-extensions.md) works through a package end to end, and the [developer guide](../developer/index.md) has the in-tree recipes for a core operation and for a new waveform. diff --git a/docs/guide/measurements.md b/docs/guide/measurements.md index cef21b9..a8d5d87 100644 --- a/docs/guide/measurements.md +++ b/docs/guide/measurements.md @@ -1,12 +1,6 @@ # Measurements and results -`measure` appends a `Measure` operation to the program and returns a -`MeasurementHandle`. The handle holds a name and nothing else, and that name is -what ties the stages of a measurement together: it goes into the `.qp` file, the -platform tags the record it produces with it, and `result.get(handle)` finds -that record again afterwards. Vendor measurement operations reached through a -`program..*` namespace return a handle the same way, because they derive -from the same `MeasurementOperation` base class. +`measure` appends a `Measure` operation to the program and returns a `MeasurementHandle`. The handle holds a name and nothing else, and that name is what ties the stages of a measurement together: it goes into the `.qp` file, the platform tags the record it produces with it, and `result.get(handle)` finds that record again afterwards. Vendor measurement operations reached through a `program..*` namespace return a handle the same way, because they derive from the same `MeasurementOperation` base class. ```python import qprogram as qp @@ -25,11 +19,7 @@ result = qp.simulate(program) data = result.get(m0) # xarray.DataArray with dims ("IQ",) ``` -A handle has two public attributes and no more: `name`, the string, and `state`, -a proxy for referencing the measurement's classified outcome in a conditional. -It is `__slots__`-based, so nothing else can be attached to it, and -`qp.MeasurementHandle(name)` raises `ValidationError` unless `name` is a -non-empty string. +A handle has two public attributes and no more: `name`, the string, and `state`, a proxy for referencing the measurement's classified outcome in a conditional. It is `__slots__`-based, so nothing else can be attached to it, and `qp.MeasurementHandle(name)` raises `ValidationError` unless `name` is a non-empty string. ## The `measure` signature @@ -39,15 +29,9 @@ non-empty string. program.measure(bus, waveform, weights, *, name=None, fields=(qp.MeasurementField.IQ,)) ``` -`bus` is the bus the readout runs on, either a `BusRef` taken from the program's -schema or a raw string. `waveform` is the readout pulse and `weights` the -integration weights; each is a concrete `IQWaveform` or a string alias that -`program.with_waveforms(library)` resolves later. `name` and `fields` are -keyword-only. +`bus` is the bus the readout runs on, either a `BusRef` taken from the program's schema or a raw string. `waveform` is the readout pulse and `weights` the integration weights; each is a concrete `IQWaveform` or a string alias that `program.with_waveforms(library)` resolves later. `name` and `fields` are keyword-only. -Every precondition `measure` can check, it checks at the call site rather than -during validation, so the traceback points at the line that built the -measurement: +Every precondition `measure` can check, it checks at the call site rather than during validation, so the traceback points at the line that built the measurement: ```python program.measure(q[0].drive, readout, readout) @@ -59,17 +43,11 @@ program.measure(q[0].readout, qp.waveforms.Square(amplitude=1.0, duration=200), # Waveform (Square). Use an IQWaveform (e.g. IQPair, IQDrag) instead. ``` -A `BusRef` produced by a different `BusSchema` than the one attached to the -program is rejected too, with the message described in -[Buses and schemas](buses.md). Raw-string buses carry no metadata, so neither -the acquisition check nor the channel check applies to them: they are deferred -to the platform. +A `BusRef` produced by a different `BusSchema` than the one attached to the program is rejected too, with the message described in [Buses and schemas](buses.md). Raw-string buses carry no metadata, so neither the acquisition check nor the channel check applies to them: they are deferred to the platform. ## The `fields` argument -`fields` names the data the platform should produce for this measurement. The -values come from `qp.MeasurementField`, whose declaration order is also the -canonical order fields are stored and serialized in. +`fields` names the data the platform should produce for this measurement. The values come from `qp.MeasurementField`, whose declaration order is also the canonical order fields are stored and serialized in. | Member | Wire name | What comes back | |---|---|---| @@ -77,11 +55,7 @@ canonical order fields are stored and serialized in. | `qp.MeasurementField.IQ` | `iq` | The demodulated, integrated I and Q pair. Array dims `(*sweeps, "IQ")`. The default, and the field `result.get` returns unless told otherwise. | | `qp.MeasurementField.RAW` | `raw` | The raw ADC trace, one I/Q pair per time sample. Array dims `(*sweeps, "time", "IQ")`. | -`MeasurementField` is a `StrEnum`, so `qp.MeasurementField.IQ == "iq"` and the -members are strings wherever a field name travels: capability tokens, `.qp` -lines, and the keys of `MeasurementResult.fields`. The enum exists so an editor -can list the options and a type checker can catch a bad one; plain strings work -just as well. +`MeasurementField` is a `StrEnum`, so `qp.MeasurementField.IQ == "iq"` and the members are strings wherever a field name travels: capability tokens, `.qp` lines, and the keys of `MeasurementResult.fields`. The enum exists so an editor can list the options and a type checker can catch a bad one; plain strings work just as well. ```python program.measure(q[0].readout, readout, readout) # fields=(qp.MeasurementField.IQ,) @@ -94,9 +68,7 @@ program.measure( program.measure(q[0].readout, readout, readout, fields=["iq", "raw"]) # the same ``` -Whatever you pass goes through `normalize_fields`, which is strict about three -things. The value must be an iterable and not a bare string, because iterating a -string would yield its characters and there is no comma-separated spelling: +Whatever you pass goes through `normalize_fields`, which is strict about three things. The value must be an iterable and not a bare string, because iterating a string would yield its characters and there is no comma-separated spelling: ```python program.measure(q[0].readout, readout, readout, fields="iq,raw") @@ -106,9 +78,7 @@ program.measure(q[0].readout, readout, readout, fields="iq,raw") # fields=("iq", "raw") ``` -It must request at least one field; an empty iterable raises rather than -defaulting back to `iq`. And every name is checked against the registered -`measure.fields.*` capability tokens right there at the call: +It must request at least one field; an empty iterable raises rather than defaulting back to `iq`. And every name is checked against the registered `measure.fields.*` capability tokens right there at the call: ```python program.measure(q[0].readout, readout, readout, fields=()) @@ -122,39 +92,17 @@ program.measure(q[0].readout, readout, readout, fields=("stat",)) # qprogram.protocol.register_capability_tokens. ``` -Order and duplicates carry no meaning. The stored tuple is deduplicated and -sorted into canonical order, core fields first in declaration order and vendor -names alphabetically after them, so `fields=("iq", "state")` and -`fields=("state", "iq")` build the same AST node, hash the same, and write the -same `.qp` line, `fields=["state", "iq"]`. +Order and duplicates carry no meaning. The stored tuple is deduplicated and sorted into canonical order, core fields first in declaration order and vendor names alphabetically after them, so `fields=("iq", "state")` and `fields=("state", "iq")` build the same AST node, hash the same, and write the same `.qp` line, `fields=["state", "iq"]`. -The registry that catches the typo is also the extension point. A vendor -package that calls `qp.register_capability_tokens("measure.fields.counts")` -makes `fields=("counts",)` legal with no change to core qprogram. Whether a -given platform can deliver a field is a separate question: each requested field -contributes a `measure.fields.` token to the operation's required -capabilities, and a platform that does not declare the token fails validation. -See [Capabilities](capabilities.md). +The registry that catches the typo is also the extension point. A vendor package that calls `qp.register_capability_tokens("measure.fields.counts")` makes `fields=("counts",)` legal with no change to core qprogram. Whether a given platform can deliver a field is a separate question: each requested field contributes a `measure.fields.` token to the operation's required capabilities, and a platform that does not declare the token fails validation. See [Capabilities](capabilities.md). ## Name allocation -When you pass `name=`, it must be a non-empty string that no other measurement -in the program already uses, and it is used verbatim. When you omit it, the name -is `prefix` plus the lowest integer from 0 upwards that is still free, where the -prefix depends on the bus: a `BusRef` gives `f"{bus}/m"` and a raw string gives -the bare `"m"`. +When you pass `name=`, it must be a non-empty string that no other measurement in the program already uses, and it is used verbatim. When you omit it, the name is `prefix` plus the lowest integer from 0 upwards that is still free, where the prefix depends on the bus: a `BusRef` gives `f"{bus}/m"` and a raw string gives the bare `"m"`. -The `BusRef` prefix uses the bus's rendered string form, which is what the -schema's `BusNaming` pattern produces and what `MeasurementResult.bus` reports. -With the default pattern `{element}{index}/{kind}`, a measurement on -`q[0].readout` is named `q0/readout/m0`; a tuple index joins with an underscore, -so `c[0, 1]` yields names under `c0_1//m`; and a schema built with -`qp.BusNaming("{kind}_{element}{index}_bus")` yields `readout_q0_bus/m0`. +The `BusRef` prefix uses the bus's rendered string form, which is what the schema's `BusNaming` pattern produces and what `MeasurementResult.bus` reports. With the default pattern `{element}{index}/{kind}`, a measurement on `q[0].readout` is named `q0/readout/m0`; a tuple index joins with an underscore, so `c[0, 1]` yields names under `c0_1//m`; and a schema built with `qp.BusNaming("{kind}_{element}{index}_bus")` yields `readout_q0_bus/m0`. -Each prefix carries its own counter, so measuring `q[0].readout` and then -`q[1].readout` gives `q0/readout/m0` and `q1/readout/m0`. Every raw-string bus -in a program shares the single `m0`, `m1`, ... sequence, since there is no -metadata to scope it by. +Each prefix carries its own counter, so measuring `q[0].readout` and then `q[1].readout` gives `q0/readout/m0` and `q1/readout/m0`. Every raw-string bus in a program shares the single `m0`, `m1`, ... sequence, since there is no metadata to scope it by. ```python program = qp.QProgram(schema=schema) @@ -165,19 +113,9 @@ program.measure("readout_q9", readout, readout) # m0 program.measure("readout_q9", readout, readout) # m1 ``` -Because the counter is "lowest free integer" rather than "number of -measurements so far", an explicit name that looks like an auto-name is stepped -over instead of collided with. A program that starts with -`name="q0/readout/m1"` allocates `q0/readout/m0` next, then `q0/readout/m2`. +Because the counter is "lowest free integer" rather than "number of measurements so far", an explicit name that looks like an auto-name is stepped over instead of collided with. A program that starts with `name="q0/readout/m1"` allocates `q0/readout/m0` next, then `q0/readout/m2`. -The used-name set is recomputed by walking the AST on every allocation. Nothing -about naming is stored on the program, which costs one walk per measurement and -buys freedom from hidden state: `copy.deepcopy`, `program.with_waveforms(...)` -and `qp.loads(qp.dumps(program))` all produce a program that carries on naming -exactly where the original left off. A derived program does carry its own -`BusSchema` instance, and a program accepts bus references only from the schema -attached to it, so reach for `derived.schema.q[0].readout` rather than a -reference built from the original schema, which raises `ValidationError`. +The used-name set is recomputed by walking the AST on every allocation. Nothing about naming is stored on the program, which costs one walk per measurement and buys freedom from hidden state: `copy.deepcopy`, `program.with_waveforms(...)` and `qp.loads(qp.dumps(program))` all produce a program that carries on naming exactly where the original left off. A derived program does carry its own `BusSchema` instance, and a program accepts bus references only from the schema attached to it, so reach for `derived.schema.q[0].readout` rather than a reference built from the original schema, which raises `ValidationError`. A name collision is reported when the measurement is built, not later: @@ -188,17 +126,11 @@ program.measure(q[1].readout, readout, readout, name="t1_ref") # in this program ``` -`program.rebind(...)` re-derives auto-allocated names from each operation's new -bus and leaves user-supplied names alone, which is why the handle records which -of the two it was. That flag is in-memory state and is not serialized, so a -handle reconstructed from a `.qp` file counts as user-supplied and keeps its name -through a rebind. Rebind before dumping, not after loading, if you want the -names to follow the new buses. +`program.rebind(...)` re-derives auto-allocated names from each operation's new bus and leaves user-supplied names alone, which is why the handle records which of the two it was. That flag is in-memory state and is not serialized, so a handle reconstructed from a `.qp` file counts as user-supplied and keeps its name through a rebind. Rebind before dumping, not after loading, if you want the names to follow the new buses. ### How a name survives a `.qp` round-trip -The writer always emits the measurement name as a `name=` keyword, so nothing is -inferred on the way back in: +The writer always emits the measurement name as a `name=` keyword, so nothing is inferred on the way back in: ``` #!QProgram 0.2 @@ -213,22 +145,13 @@ body: measure "readout_q9" "readout" "weights" name="m0" fields=["state", "iq"] ``` -Note the asymmetry on the first line. A schema-backed bus is written as a bus -*path*, `q[0].readout`, because the path is what survives a change of naming -pattern, while the name holds the rendered string form the pattern produced at -construction time. +Note the asymmetry on the first line. A schema-backed bus is written as a bus *path*, `q[0].readout`, because the path is what survives a change of naming pattern, while the name holds the rendered string form the pattern produced at construction time. -The parser resolves every `name=` through one table per parse, so a measurement -operation and every conditional that refers to the same name end up sharing a -single `MeasurementHandle` instance. A hand-written line that carries no `name=` -gets one allocated by the same rule the builder uses, computed against the part -of the program parsed so far. +The parser resolves every `name=` through one table per parse, so a measurement operation and every conditional that refers to the same name end up sharing a single `MeasurementHandle` instance. A hand-written line that carries no `name=` gets one allocated by the same rule the builder uses, computed against the part of the program parsed so far. ## Recovering handles after a round-trip -`measure` returns a handle, but the Python local that captured it is gone after -a `.qp` reload. `program.measurement_handles()` returns one handle per -measurement in declaration order: +`measure` returns a handle, but the Python local that captured it is gone after a `.qp` reload. `program.measurement_handles()` returns one handle per measurement in declaration order: ```python program = qp.load("rabi.qp") @@ -236,30 +159,19 @@ handles = program.measurement_handles() data = qp.simulate(program).get(handles[0]) ``` -These are the same Python instances the AST holds inside its measurement -operations and inside any `MeasurementRef` in a conditional, so a value written -onto one is visible everywhere the measurement is referenced. That is also true -in the other direction: the executor writes the classified state onto the -handle as each shot completes, which is what makes state feedback work without -any wiring between the conditional and the measurement. +These are the same Python instances the AST holds inside its measurement operations and inside any `MeasurementRef` in a conditional, so a value written onto one is visible everywhere the measurement is referenced. That is also true in the other direction: the executor writes the classified state onto the handle as each shot completes, which is what makes state feedback work without any wiring between the conditional and the measurement. -Handles compare by name, so a freshly constructed one is a usable key even -without the original variable: +Handles compare by name, so a freshly constructed one is a usable key even without the original variable: ```python qp.MeasurementHandle("q0/readout/m0") == program.measurement_handles()[0] # True ``` -Beyond a reload, the cases that call for `measurement_handles()` are test code -asserting against named measurements, and code that serializes a program, hands -it to another process, and has to match records back to operations there. -Everywhere else, hold on to what `measure` returned. +Beyond a reload, the cases that call for `measurement_handles()` are test code asserting against named measurements, and code that serializes a program, hands it to another process, and has to match records back to operations there. Everywhere else, hold on to what `measure` returned. ## Referencing a measurement in a conditional -`handle.state` is a proxy whose `==` and `!=` build a `Comparison` over the -measurement's classified outcome. The measurement has to have asked for the -classification: +`handle.state` is a proxy whose `==` and `!=` build a `Comparison` over the measurement's classified outcome. The measurement has to have asked for the classification: ```python m = program.measure(q[0].readout, readout, readout, fields=(qp.MeasurementField.STATE,)) @@ -275,28 +187,13 @@ measurement does not request state classification (add MeasurementField.STATE to fields=) (at body[1]) ``` -On the wire the reference is the unquoted token `q0/readout/m0.state`, which -constrains the name: whitespace, a quote, `#`, a comma, a dot, a bracket, a -brace, or a parenthesis in the name has no unquoted spelling, and dumping such a -program raises `SerializationError`. Slashes are safe, so auto-names under the -default `BusNaming` pattern are too; a pattern that puts a dot or a bracket in -the bus name makes them unsafe. Pass a token-safe `name=` when you intend to -branch on a result. See -[Control flow](control-flow.md). +On the wire the reference is the unquoted token `q0/readout/m0.state`, which constrains the name: whitespace, a quote, `#`, a comma, a dot, a bracket, a brace, or a parenthesis in the name has no unquoted spelling, and dumping such a program raises `SerializationError`. Slashes are safe, so auto-names under the default `BusNaming` pattern are too; a pattern that puts a dot or a bracket in the bus name makes them unsafe. Pass a token-safe `name=` when you intend to branch on a result. See [Control flow](control-flow.md). ## The result object -`platform.execute(program)` and `qp.simulate(program)` return a -`QProgramResult`: a list of `MeasurementResult` records in construction order, -one per measurement operation in the AST. `len(result)` counts them, -`result.measurements` is the list itself, and `repr(result)` prints the count and -the names, which is usually enough to see why a lookup missed. +`platform.execute(program)` and `qp.simulate(program)` return a `QProgramResult`: a list of `MeasurementResult` records in construction order, one per measurement operation in the AST. `len(result)` counts them, `result.measurements` is the list itself, and `repr(result)` prints the count and the names, which is usually enough to see why a lookup missed. -A `MeasurementResult` is a dataclass of four fields. `bus` is the bus the -measurement ran on, as a plain string. `name` is the handle's name. `fields` is -the mapping from field name to `xarray.DataArray`, one entry per requested -field. `data` is the primary array: the `iq` field when the measurement -requested it, otherwise the first requested field in canonical order. +A `MeasurementResult` is a dataclass of four fields. `bus` is the bus the measurement ran on, as a plain string. `name` is the handle's name. `fields` is the mapping from field name to `xarray.DataArray`, one entry per requested field. `data` is the primary array: the `iq` field when the measurement requested it, otherwise the first requested field in canonical order. `result.get` reads out of `fields`. Its full signature is @@ -313,15 +210,9 @@ result.get(0) # by position in declaration order result.get(0, bus="q0/readout") # position within one bus ``` -The handle and the name are the same lookup, since a handle is looked up by its -name. The integer form is positional sugar; a handle or a name says what it -means and survives a reordering of the program, so prefer either. `bus` filters -the candidate records before the lookup, which is what makes a position within -one bus meaningful. +The handle and the name are the same lookup, since a handle is looked up by its name. The integer form is positional sugar; a handle or a name says what it means and survives a reordering of the program, so prefer either. `bus` filters the candidate records before the lookup, which is what makes a position within one bus meaningful. -A lookup that finds nothing raises rather than returning `None`. A handle or -name with no matching record raises `KeyError`. That is what a handle whose -program was never run gives you, and a handle from a different program too: +A lookup that finds nothing raises rather than returning `None`. A handle or name with no matching record raises `KeyError`. That is what a handle whose program was never run gives you, and a handle from a different program too: ```python result.get(qp.MeasurementHandle("q0/readout/m9")) @@ -334,18 +225,11 @@ result.get(5, bus="q0/readout") # IndexError: Measurement index 5 out of range for bus 'q0/readout' (1 measurements) ``` -A record is only ever added by the platform, through -`result.append_measurement(bus, name, data, fields=None)`. Omitting `fields` -records `data` as the `iq` field, so a platform that produces one array per -measurement gets the common case right by default; a platform whose primary -array is something else has to pass the mapping, which keeps `get` from handing -back an array under the wrong field name. +A record is only ever added by the platform, through `result.append_measurement(bus, name, data, fields=None)`. Omitting `fields` records `data` as the `iq` field, so a platform that produces one array per measurement gets the common case right by default; a platform whose primary array is something else has to pass the mapping, which keeps `get` from handing back an array under the wrong field name. ### Picking a field -A measurement that requested several fields produces one array per field, and -they have different shapes. `field=` says which one you want, as a -`MeasurementField` member or a registered field name: +A measurement that requested several fields produces one array per field, and they have different shapes. `field=` says which one you want, as a `MeasurementField` member or a registered field name: ```python m = program.measure( @@ -362,11 +246,7 @@ result.get(m, field=qp.MeasurementField.RAW) # dims (*sweeps, "time", "IQ") result.get(m, field="state") # identical to field=qp.MeasurementField.STATE ``` -`field` defaults to `qp.MeasurementField.IQ`, matching the default of `measure`, -so a measurement you never passed `fields=` to reads back with a bare -`result.get(m)`. The default is a real field name and not "whatever this -measurement produced", so asking for a field the measurement did not request -raises, the default included: +`field` defaults to `qp.MeasurementField.IQ`, matching the default of `measure`, so a measurement you never passed `fields=` to reads back with a bare `result.get(m)`. The default is a real field name and not "whatever this measurement produced", so asking for a field the measurement did not request raises, the default included: ```python m = program.measure(q[0].readout, readout, readout, fields=(qp.MeasurementField.STATE,)) @@ -377,32 +257,19 @@ result.get(m) result.get(m, field=qp.MeasurementField.STATE) # correct ``` -The alternative, returning the primary array whenever `iq` is missing, would -hand a state array to every reader downstream that expected IQ data. There is -no spelling of "give me the primary array" in `get` at all: `field=None` raises -`ValidationError` pointing at `MeasurementResult.data`, which is where the -primary array lives. +The alternative, returning the primary array whenever `iq` is missing, would hand a state array to every reader downstream that expected IQ data. There is no spelling of "give me the primary array" in `get` at all: `field=None` raises `ValidationError` pointing at `MeasurementResult.data`, which is where the primary array lives. ```python result.measurements[0].data # the "iq" field if requested, else the first in canonical order ``` -`result.plot` takes the same measurement, `bus`, and `field` arguments and -draws the array rather than returning it, working the figure out from the -dimensions below. [Plotting results](plotting.md) covers what it makes of each -shape. +`result.plot` takes the same measurement, `bus`, and `field` arguments and draws the array rather than returning it, working the figure out from the dimensions below. [Plotting results](plotting.md) covers what it makes of each shape. ## Result dimensions -The dimensions of every returned array are the enclosing `sweep` blocks, -outermost first, each named after its loop variable's id and carrying the swept -values as its coordinate. The trailing dimensions belong to the field: `"IQ"` -with coordinates `["I", "Q"]` for `iq`, `"time"` with an integer coordinate for -`raw`, and nothing for `state`. The full order is `(*sweeps, *field dims)`. +The dimensions of every returned array are the enclosing `sweep` blocks, outermost first, each named after its loop variable's id and carrying the swept values as its coordinate. The trailing dimensions belong to the field: `"IQ"` with coordinates `["I", "Q"]` for `iq`, `"time"` with an integer coordinate for `raw`, and nothing for `state`. The full order is `(*sweeps, *field dims)`. -A sweep coordinate also carries whatever `label` and `units` its variable -declared, as the `long_name` and `units` attributes. The field dimensions carry -none: neither comes from a variable. +A sweep coordinate also carries whatever `label` and `units` its variable declared, as the `long_name` and `units` attributes. The field dimensions carry none: neither comes from a variable. ```python program = qp.QProgram(label="spectroscopy", schema=schema) @@ -422,16 +289,9 @@ result.get(0, field="raw").dims # ("freq", "gain", "time", "IQ"), shape (3, 5, result.get(0).coords["IQ"].values # array(['I', 'Q'], dtype='(...)` call -builds one typed `Operation` instance and appends it to whichever block is -currently active, so the order of the calls is the order of the program. Most -builders return `None`; `measure` returns a `MeasurementHandle` and -`get_parameter` returns a `Variable`. +Operations are the leaves of a QProgram's AST. Each `program.(...)` call builds one typed `Operation` instance and appends it to whichever block is currently active, so the order of the calls is the order of the program. Most builders return `None`; `measure` returns a `MeasurementHandle` and `get_parameter` returns a `Variable`. -Blocks are the containers, and are covered elsewhere: loops, averaging, -conditionals, and parallel composition are in -[Control flow](control-flow.md). +Blocks are the containers, and are covered elsewhere: loops, averaging, conditionals, and parallel composition are in [Control flow](control-flow.md). -Every example below builds on this setup, and declares its own variables where -it needs them: +Every example below builds on this setup, and declares its own variables where it needs them: ```python import qprogram as qp @@ -23,20 +16,9 @@ program = qp.QProgram(schema=schema) ## Arguments every operation shares -A `bus` argument is either a plain string or a `BusRef` obtained from a -`BusSchema`. `BusRef` is a `str` subclass, so both spellings travel through -the AST as the same kind of value; what differs is how much the builder can -check and how the bus is written to `.qp`. A schema-backed ref carries -`element`, `idx`, `kind`, `channel`, `acquires`, and the producing schema, and -the builder uses them: it rejects a ref from a different `BusSchema` than the -program's, rejects `measure` on a bus with `acquires=False`, and rejects a -waveform whose channel count does not match the bus's. A plain string carries -none of that, so none of those checks run. On the wire, a schema-backed ref -serializes as a path (`q[0].drive`) and a plain string as a quoted name -(`"drive_q0"`). +A `bus` argument is either a plain string or a `BusRef` obtained from a `BusSchema`. `BusRef` is a `str` subclass, so both spellings travel through the AST as the same kind of value; what differs is how much the builder can check and how the bus is written to `.qp`. A schema-backed ref carries `element`, `idx`, `kind`, `channel`, `acquires`, and the producing schema, and the builder uses them: it rejects a ref from a different `BusSchema` than the program's, rejects `measure` on a bus with `acquires=False`, and rejects a waveform whose channel count does not match the bus's. A plain string carries none of that, so none of those checks run. On the wire, a schema-backed ref serializes as a path (`q[0].drive`) and a plain string as a quoted name (`"drive_q0"`). -A program may use exactly one schema. Passing a ref from a second one raises -`ValidationError` at the builder call: +A program may use exactly one schema. Passing a ref from a second one raises `ValidationError` at the builder call: ``` BusRef 'q0/drive' (element='q', kind='drive') comes from a different BusSchema @@ -45,23 +27,13 @@ a plain string bus name if you need to reference a bus that lives outside the schema. ``` -A numeric argument accepts an `int` or `float`, a `Variable`, or any -`Expression` built from them. Nothing is evaluated at build time: the -expression tree is stored on the operation, and the tokens it needs are -reported alongside the operation's own (see -[Variables and expressions](variables.md)). Durations are nanoseconds, -frequencies hertz, phases radians; gain and offset are dimensionless. +A numeric argument accepts an `int` or `float`, a `Variable`, or any `Expression` built from them. Nothing is evaluated at build time: the expression tree is stored on the operation, and the tokens it needs are reported alongside the operation's own (see [Variables and expressions](variables.md)). Durations are nanoseconds, frequencies hertz, phases radians; gain and offset are dimensionless. -A waveform argument accepts a concrete `Waveform` or `IQWaveform`, or a string -alias to be resolved later by `with_waveforms`. An alias defers the choice of -samples to the platform's calibration store; see -[Waveforms](waveforms.md). +A waveform argument accepts a concrete `Waveform` or `IQWaveform`, or a string alias to be resolved later by `with_waveforms`. An alias defers the choice of samples to the platform's calibration store; see [Waveforms](waveforms.md). ## What a builder call appends -The appended object is an ordinary Python instance with the constructor -arguments as public attributes. Equality and hashing are structural, and each -class reports the capability tokens it needs: +The appended object is an ordinary Python instance with the constructor arguments as public attributes. Equality and hashing are structural, and each class reports the capability tokens it needs: ```python program.play("drive_q0", "pi_pulse") @@ -74,23 +46,14 @@ op.required_capabilities() # {'op.play', 'waveform.alias'} op.buses() # {'drive_q0'} ``` -Every core operation but `call` carries an identity token spelled -`op.`, and adds refinement tokens for the state it holds: a waveform -contributes its channel kind and its per-class token, an `Expression` -contributes one token per node type it contains, and a measurement contributes -one per field it requests. Validation routes a bus-scoped operation to that -bus's slot, a slot being a `(bus, domain)` pair, and reports a missing token as -a `missing-capability` error naming the operation, the token, and the profile -that lacks it: +Every core operation but `call` carries an identity token spelled `op.`, and adds refinement tokens for the state it holds: a waveform contributes its channel kind and its per-class token, an `Expression` contributes one token per node type it contains, and a measurement contributes one per field it requests. Validation routes a bus-scoped operation to that bus's slot, a slot being a `(bus, domain)` pair, and reports a missing token as a `missing-capability` error naming the operation, the token, and the profile that lacks it: ``` 'Wait' requires capability 'op.wait' which is not supported by 'empty-profile' (rt) / 'empty-profile' (host) ``` -Constructing operation classes directly is rarely needed, but -`qp.operations` exports every one (`qp.operations.Play`, -`qp.operations.Measure`, ...) for tests and program transformations. +Constructing operation classes directly is rarely needed, but `qp.operations` exports every one (`qp.operations.Play`, `qp.operations.Measure`, ...) for tests and program transformations. ## Every core operation @@ -109,19 +72,11 @@ Constructing operation classes directly is rarely needed, but | `get_parameter(bus, parameter)` | `get_parameter "" -> ` | `op.get_parameter` | | `call(fragment, *args, **kwargs)` | `()` | none; validation expands calls first | -The `.qp` statement of an operation with no custom serializer is derived from -its constructor signature: the keyword, then the required parameters -positionally in declaration order, then any optional parameter whose value -differs from its default, as `name=value`. That is why `set_offset` writes its -second path as a keyword and `measure` writes `fields=[...]` only when the -program asked for something other than `("iq",)`. `sync`, `measure`, and -`get_parameter` have hand-written serializers, for the variadic bus list, the -`name=` kwarg, and the `->` arrow respectively. +The `.qp` statement of an operation with no custom serializer is derived from its constructor signature: the keyword, then the required parameters positionally in declaration order, then any optional parameter whose value differs from its default, as `name=value`. That is why `set_offset` writes its second path as a keyword and `measure` writes `fields=[...]` only when the program asked for something other than `("iq",)`. `sync`, `measure`, and `get_parameter` have hand-written serializers, for the variadic bus list, the `name=` kwarg, and the `->` arrow respectively. ## Pulse operations -These four put something on a bus's timeline: a waveform, an acquisition, an -idle, or an alignment with other buses. +These four put something on a bus's timeline: a waveform, an acquisition, an idle, or an alignment with other buses. ### `play(bus, waveform)` @@ -138,16 +93,9 @@ program.play( program.play(q[0].flux, qp.waveforms.Gaussian(amplitude=amp, duration=40, sigma=8)) ``` -The tokens depend on what the waveform argument is. A string alias adds -`waveform.alias` and nothing else, since the samples are not known yet. A -concrete waveform adds `waveform.iq` or `waveform.single` for its channel -kind, plus the per-class token registered for its type -(`waveform.gaussian`, `waveform.iq_drag`, and so on) when there is one; an -unregistered vendor class contributes no per-class token, and the validator -skips that refinement for it. +The tokens depend on what the waveform argument is. A string alias adds `waveform.alias` and nothing else, since the samples are not known yet. A concrete waveform adds `waveform.iq` or `waveform.single` for its channel kind, plus the per-class token registered for its type (`waveform.gaussian`, `waveform.iq_drag`, and so on) when there is one; an unregistered vendor class contributes no per-class token, and the validator skips that refinement for it. -Channel mismatch is caught at the call site whenever the bus is schema-backed -and the waveform is concrete, in both directions: +Channel mismatch is caught at the call site whenever the bus is schema-backed and the waveform is concrete, in both directions: ``` Bus 'q0/flux' is a single channel but received an IQWaveform (IQPair). @@ -159,14 +107,11 @@ Bus 'q0/drive' is an IQ channel but received a single-channel Waveform (Square). Use an IQWaveform (e.g. IQPair, IQDrag) instead. ``` -A raw-string bus or a string alias skips the check, because neither side has -the metadata to compare. +A raw-string bus or a string alias skips the check, because neither side has the metadata to compare. ### `measure(bus, waveform, weights, *, name=None, fields=(MeasurementField.IQ,))` -Play a readout pulse, acquire the response, and return a `MeasurementHandle`. -`waveform` is the readout pulse and `weights` the integration weights; both -take a concrete `IQWaveform` or a string alias. +Play a readout pulse, acquire the response, and return a `MeasurementHandle`. `waveform` is the readout pulse and `weights` the integration weights; both take a concrete `IQWaveform` or a string alias. ```python m0 = program.measure(q[0].readout, "readout", "weights") @@ -179,13 +124,7 @@ m2 = program.measure( ) ``` -`fields` names which data the platform should produce: an iterable of -`MeasurementField` members (`STATE`, `IQ`, `RAW`) or of registered field-name -strings, defaulting to `(MeasurementField.IQ,)`. The tuple is stored -deduplicated and sorted into enum declaration order, so `(IQ, STATE)` and -`(STATE, IQ)` build equal, equally-hashing, identically-serializing programs. -A bare string is rejected, and so is an unknown name, both at the call site -rather than later: +`fields` names which data the platform should produce: an iterable of `MeasurementField` members (`STATE`, `IQ`, `RAW`) or of registered field-name strings, defaulting to `(MeasurementField.IQ,)`. The tuple is stored deduplicated and sorted into enum declaration order, so `(IQ, STATE)` and `(STATE, IQ)` build equal, equally-hashing, identically-serializing programs. A bare string is rejected, and so is an unknown name, both at the call site rather than later: ``` `fields` must be an iterable of MeasurementField values, not the bare string @@ -199,27 +138,11 @@ unknown measurement field(s) ['iqq']. Did you mean 'iq'? Known fields: `measure.fields.` via qprogram.protocol.register_capability_tokens. ``` -An empty `fields` is refused too, since a measurement that produces nothing is -almost certainly a mistake. See -[Measurements and results](measurements.md#the-fields-argument) for the -complete rules. - -Two further checks run at the call site. A schema-backed bus with -`acquires=False` is refused (`measure() can only be called on buses with an -ADC (e.g. readout buses).`), and an explicit `name` that another measurement -in the program already uses is refused as well. Omit `name` and one is -allocated: schema-backed buses get `{bus}/m` with a per-bus counter -(`q0/readout/m0`, `q0/readout/m1`, `q1/readout/m0`), raw-string buses share a -global `m`. The counters are recomputed from the AST on each call -rather than stored on the program, which is what keeps `deepcopy`, -`with_waveforms`, and `dumps`/`loads` round-trips free of hidden state. - -What an `average` block accumulates is measurement results, so only -measurements decide whether the averaging itself can run as a real-time -feature. `MeasurementOperation` sets `AFFECTS_AVERAGING = True` and the other -core operations leave it `False`, which is why an `average` block's execution -domain is fixed by the measurements in its body and not by the pulses around -them. +An empty `fields` is refused too, since a measurement that produces nothing is almost certainly a mistake. See [Measurements and results](measurements.md#the-fields-argument) for the complete rules. + +Two further checks run at the call site. A schema-backed bus with `acquires=False` is refused (`measure() can only be called on buses with an ADC (e.g. readout buses).`), and an explicit `name` that another measurement in the program already uses is refused as well. Omit `name` and one is allocated: schema-backed buses get `{bus}/m` with a per-bus counter (`q0/readout/m0`, `q0/readout/m1`, `q1/readout/m0`), raw-string buses share a global `m`. The counters are recomputed from the AST on each call rather than stored on the program, which is what keeps `deepcopy`, `with_waveforms`, and `dumps`/`loads` round-trips free of hidden state. + +What an `average` block accumulates is measurement results, so only measurements decide whether the averaging itself can run as a real-time feature. `MeasurementOperation` sets `AFFECTS_AVERAGING = True` and the other core operations leave it `False`, which is why an `average` block's execution domain is fixed by the measurements in its body and not by the pulses around them. ### `wait(bus, duration)` @@ -232,22 +155,17 @@ program.wait("drive_q0", 100) program.wait("drive_q0", 100 + t) ``` -A platform that declares a `min_wait_duration_ns` limit on the bus slot has it -checked against the stored duration, producing a `limit-exceeded` error: +A platform that declares a `min_wait_duration_ns` limit on the bus slot has it checked against the stored duration, producing a `limit-exceeded` error: ``` Wait duration 2 ns is shorter than min_wait_duration_ns=4 ``` -The check only applies when `duration` is a plain `int`. An `Expression` has -no static value to compare, so it is left to the platform. +The check only applies when `duration` is a plain `int`. An `Expression` has no static value to compare, so it is left to the platform. ### `sync(buses=None)` -Bring the listed buses to a common point in time. `None`, which is the -default, means every bus active in the program: the operation declares -`BROADCASTS_WHEN_NO_BUS = True`, so validation intersects the capabilities of -every bus the program touches instead of consulting one slot. +Bring the listed buses to a common point in time. `None`, which is the default, means every bus active in the program: the operation declares `BROADCASTS_WHEN_NO_BUS = True`, so validation intersects the capabilities of every bus the program touches instead of consulting one slot. ```python program.sync() @@ -260,15 +178,11 @@ An empty list raises rather than being read as either extreme: sync([]) is ambiguous; pass None (or no argument) to sync all buses ``` -On the AST node the attribute is `targets`, not `buses`, so that a list -attribute does not shadow the `Operation.buses()` introspection method. The -builder keeps the readable `buses=` keyword. +On the AST node the attribute is `targets`, not `buses`, so that a list attribute does not shadow the `Operation.buses()` introspection method. The builder keeps the readable `buses=` keyword. ## Real-time parameter control -These five write the per-bus registers of the signal chain. Whether they run -in the real-time sequencer or host-side is the platform's call, declared per -slot; nothing about the operations themselves forces one domain. +These five write the per-bus registers of the signal chain. Whether they run in the real-time sequencer or host-side is the platform's call, declared per slot; nothing about the operations themselves forces one domain. ### `set_frequency(bus, frequency)` @@ -294,8 +208,7 @@ program.set_phase("drive_q0", phi) ### `reset_phase(bus)` -Zero the oscillator phase on the bus. It takes no value, so `op.reset_phase` -is the only token it needs. +Zero the oscillator phase on the bus. It takes no value, so `op.reset_phase` is the only token it needs. ```python program.reset_phase("drive_q0") @@ -314,10 +227,7 @@ program.set_gain("drive_q0", gain) ### `set_offset(bus, offset_path0, offset_path1=None)` -Set the DC offset on one or both signal paths. `offset_path0` is the only path -on a single-channel bus and I on an IQ bus; `offset_path1` is Q, and `None` -leaves that path's offset alone rather than zeroing it. An unset -`offset_path1` contributes no expression tokens. +Set the DC offset on one or both signal paths. `offset_path0` is the only path on a single-channel bus and I on an IQ bus; `offset_path1` is Q, and `None` leaves that path's offset alone rather than zeroing it. An unset `offset_path1` contributes no expression tokens. ```python program.set_offset(q[0].flux, 0.1) @@ -326,18 +236,9 @@ program.set_offset(q[0].drive, 0.1, 0.0) ## Host-side platform parameters -`set_parameter` and `get_parameter` are the two core operations that are -host-side only. They name a parameter by string and target a bus, so they -route to that bus's slot like the real-time `set_*` operations do, but writing -or reading a named parameter is an action against the platform's configuration -layer rather than an instruction the sequencer can hold. Platforms therefore -list `op.set_parameter` and `op.get_parameter` in a bus slot's `host` half and -omit them from its `rt` half. `QPROGRAM_BASE_V1`, the platform-level base -profile, does not carry them at all: they are bus-scoped, and it covers only -the non-bus capabilities. +`set_parameter` and `get_parameter` are the two core operations that are host-side only. They name a parameter by string and target a bus, so they route to that bus's slot like the real-time `set_*` operations do, but writing or reading a named parameter is an action against the platform's configuration layer rather than an instruction the sequencer can hold. Platforms therefore list `op.set_parameter` and `op.get_parameter` in a bus slot's `host` half and omit them from its `rt` half. `QPROGRAM_BASE_V1`, the platform-level base profile, does not carry them at all: they are bus-scoped, and it covers only the non-bus capabilities. -The parameter vocabulary is the platform's, not QProgram's. Nothing in the -core validates a parameter name. +The parameter vocabulary is the platform's, not QProgram's. Nothing in the core validates a parameter name. ### `set_parameter(bus, parameter, value)` @@ -348,10 +249,7 @@ program.set_parameter("drive_q0", "lo_frequency", 5e9) program.set_parameter(q[0].drive, "lo_frequency", 5e9) ``` -Sweeping a parameter is allowed, and it constrains the loop rather than the -operation. When `value` is a swept `Variable`, a validation predicate excludes -`rt` from the loop that binds it, with the reason `parameter '' is swept -via set_parameter (host-side dispatch per iteration)`: +Sweeping a parameter is allowed, and it constrains the loop rather than the operation. When `value` is a swept `Variable`, a validation predicate excludes `rt` from the loop that binds it, with the reason `parameter '' is swept via set_parameter (host-side dispatch per iteration)`: ```python sweep_demo = qp.QProgram(schema=schema) @@ -373,13 +271,11 @@ body └─ measure q[0].readout "ro" "w" name="q0/readout/m0" [rt|host] ``` -Targeting the loop and not the operation is what lets the operations inside -stay real-time capable while the iteration is dispatched from the host. +Targeting the loop and not the operation is what lets the operations inside stay real-time capable while the iteration is dispatched from the host. ### `get_parameter(bus, parameter) -> Variable` -Read a bus-scoped platform parameter into a fresh `Variable`. The variable is -declared on the program and returned, so later operations can use it: +Read a bus-scoped platform parameter into a fresh `Variable`. The variable is declared on the program and returned, so later operations can use it: ```python detuning = program.variable("detuning", units="Hz") @@ -388,41 +284,21 @@ lo_freq = program.get_parameter(q[0].drive, "lo_frequency") program.set_frequency(q[0].drive, lo_freq + detuning) ``` -The variable's id is derived from `f"{bus}_{parameter}"` with every non-word -character replaced by an underscore, and a `_2`, `_3`, ... suffix appended on -collision, so reading `lo_frequency` on `q[0].drive` twice gives -`q0_drive_lo_frequency` and `q0_drive_lo_frequency_2`. The `bus.parameter` -form is kept as the variable's label (`q0/drive.lo_frequency`) so a result -axis can be traced back to what produced it. A bus or parameter name with -letters or digits outside ASCII sanitizes to something `Variable` refuses, and -the resulting `ValidationError` surfaces here. +The variable's id is derived from `f"{bus}_{parameter}"` with every non-word character replaced by an underscore, and a `_2`, `_3`, ... suffix appended on collision, so reading `lo_frequency` on `q[0].drive` twice gives `q0_drive_lo_frequency` and `q0_drive_lo_frequency_2`. The `bus.parameter` form is kept as the variable's label (`q0/drive.lo_frequency`) so a result axis can be traced back to what produced it. A bus or parameter name with letters or digits outside ASCII sanitizes to something `Variable` refuses, and the resulting `ValidationError` surfaces here. ### What a vendor extension handles instead -Addressing an instrument by alias, with an optional channel selector, instead -of by bus is specific to a hardware stack, so it belongs in a vendor -extension. A vendor named `fake_inst` would expose it as -`program.fake_inst.set_parameter("cluster", "lo_frequency", 5e9, channel_id=3)` -and `program.fake_inst.get_parameter(...)` under its own namespace, and the -`.qp` file would then carry a `require fake_inst .` line. +Addressing an instrument by alias, with an optional channel selector, instead of by bus is specific to a hardware stack, so it belongs in a vendor extension. A vendor named `fake_inst` would expose it as `program.fake_inst.set_parameter("cluster", "lo_frequency", 5e9, channel_id=3)` and `program.fake_inst.get_parameter(...)` under its own namespace, and the `.qp` file would then carry a `require fake_inst .` line. -Crosstalk correction is likewise hardware-stack specific. A vendor extension -that needs it ships its own correction type and its own operation under its -namespace, as `program..set_crosstalk(...)`. +Crosstalk correction is likewise hardware-stack specific. A vendor extension that needs it ships its own correction type and its own operation under its namespace, as `program..set_crosstalk(...)`. ## Fragment calls -A fragment is a reusable, parameterized piece of program body. Calling one -appends a single leaf naming the fragment and the arguments bound at that site, -which is what lets a `.qp` file carry the definition once and the call sites -separately. +A fragment is a reusable, parameterized piece of program body. Calling one appends a single leaf naming the fragment and the arguments bound at that site, which is what lets a `.qp` file carry the definition once and the call sites separately. ### `call(fragment, *args, **kwargs)` -Instantiate a `Fragment` at the current position. Arguments bind to the -fragment's parameters with the Python calling convention, positional in -declaration order and then by keyword, and may be numbers, variables or -expressions, buses, or waveforms. +Instantiate a `Fragment` at the current position. Arguments bind to the fragment's parameters with the Python calling convention, positional in declaration order and then by keyword, and may be numbers, variables or expressions, buses, or waveforms. ```python @qp.fragment @@ -435,17 +311,9 @@ with program.sweep(a, qp.Range(0, 1, 0.1)): program.call(x_pulse, "drive_q0", a) ``` -`Call` is an `Operation` subclass, and departs from the other leaves in three -ways. It reports no capability tokens, because `validate` expands every call -before it checks anything. Its `buses()` over-approximates: a `Parameter` is -untyped, so every string-valued argument is reported as a possible bus, and the -buses named inside the fragment body only become visible after `expand`. And -its `.qp` form is the one that is not keyword-led, written as -`x_pulse("drive_q0", a)` against a `fragment x_pulse(drive, amp):` section -emitted earlier in the file. +`Call` is an `Operation` subclass, and departs from the other leaves in three ways. It reports no capability tokens, because `validate` expands every call before it checks anything. Its `buses()` over-approximates: a `Parameter` is untyped, so every string-valued argument is reported as a possible bus, and the buses named inside the fragment body only become visible after `expand`. And its `.qp` form is the one that is not keyword-led, written as `x_pulse("drive_q0", a)` against a `fragment x_pulse(drive, amp):` section emitted earlier in the file. -Binding errors raise at the call site, naming the fragment and what went -wrong: +Binding errors raise at the call site, naming the fragment and what went wrong: ``` fragment 'x_pulse' missing argument(s) for parameter(s): amp @@ -455,11 +323,7 @@ fragment 'x_pulse' missing argument(s) for parameter(s): amp fragment 'x_pulse' takes 2 argument(s) (drive, amp) but 3 positional argument(s) were given ``` -Passing something that is not a `Fragment` raises -`call() expects a Fragment, got str`. A fragment cannot call itself, a call -cycle is refused, and a fragment whose schema differs from the program's is -refused on the same grounds a mismatched `BusRef` is. See -[Fragments](fragments.md). +Passing something that is not a `Fragment` raises `call() expects a Fragment, got str`. A fragment cannot call itself, a call cycle is refused, and a fragment whose schema differs from the program's is refused on the same grounds a mismatched `BusRef` is. See [Fragments](fragments.md). ## Vendor operations @@ -471,29 +335,14 @@ Vendor-specific operations live behind a namespace, always spelled program..(...) ``` -A vendor named `fake_inst` exposing a `beep` operation appears as -`program.fake_inst.beep(bus, duration)`, needs the capability token -`vendor.fake_inst.beep`, and writes to `.qp` as -`fake_inst.beep ` below a `require fake_inst .` -line, which the writer emits directly after the header. To use a vendor, -install its package and import it once at the top of the script; the import -registers the namespace. +A vendor named `fake_inst` exposing a `beep` operation appears as `program.fake_inst.beep(bus, duration)`, needs the capability token `vendor.fake_inst.beep`, and writes to `.qp` as `fake_inst.beep ` below a `require fake_inst .` line, which the writer emits directly after the header. To use a vendor, install its package and import it once at the top of the script; the import registers the namespace. -[Building a vendor extension](../developer/vendor-extensions.md) covers adding -a namespace of your own. +[Building a vendor extension](../developer/vendor-extensions.md) covers adding a namespace of your own. ## Control flow is not an operation -Loops, averaging, conditionals, and grouping are blocks: context managers that -contain other nodes, rather than builder calls that append a leaf. `sweep`, -`average`, `block`, the `if_` / `elif_` / `else_` chain, and parallel -composition with `|` are all in [Control flow](control-flow.md). +Loops, averaging, conditionals, and grouping are blocks: context managers that contain other nodes, rather than builder calls that append a leaf. `sweep`, `average`, `block`, the `if_` / `elif_` / `else_` chain, and parallel composition with `|` are all in [Control flow](control-flow.md). ## What is out of scope -Mid-circuit classification beyond the `state` measurement field is -platform-specific and belongs in a vendor extension. So do timing and -scheduling primitives past `wait` and `sync`: a QProgram records intent, and -the platform's compiler owns the schedule. A handful of identifiers (`while`, -`repeat`, `match`, `gate`, ...) are held back for future syntax and rejected -as variable ids, listed in [Reserved keywords](../reference/reserved.md). +Mid-circuit classification beyond the `state` measurement field is platform-specific and belongs in a vendor extension. So do timing and scheduling primitives past `wait` and `sync`: a QProgram records intent, and the platform's compiler owns the schedule. A handful of identifiers (`while`, `repeat`, `match`, `gate`, ...) are held back for future syntax and rejected as variable ids, listed in [Reserved keywords](../reference/reserved.md). diff --git a/docs/guide/plotting.md b/docs/guide/plotting.md index f9e879c..5506389 100644 --- a/docs/guide/plotting.md +++ b/docs/guide/plotting.md @@ -1,8 +1,6 @@ # Plotting results -`QProgramResult.plot` draws one measurement. It looks the array up exactly the -way `get` does, works out what kind of figure its shape asks for, and hands the -drawing to a renderer: +`QProgramResult.plot` draws one measurement. It looks the array up exactly the way `get` does, works out what kind of figure its shape asks for, and hands the drawing to a renderer: ```python result = qp.simulate(program) @@ -12,11 +10,7 @@ result.plot(m0, channels="magnitude") # hypot(I, Q) result.plot(m0, field="state") # the classified outcome ``` -The default renderer is matplotlib, which comes with the `viz` extra and is -imported the first time something is drawn, so `import qprogram` never pulls in -a plotting library. It returns the `Axes` it drew on, which is the point: a -figure is a starting position, not a finished picture, and everything the call -does not decide is one method away on the object that comes back. +The default renderer is matplotlib, which comes with the `viz` extra and is imported the first time something is drawn, so `import qprogram` never pulls in a plotting library. It returns the `Axes` it drew on, which is the point: a figure is a starting position, not a finished picture, and everything the call does not decide is one method away on the object that comes back. ```python ax = result.plot(m0) @@ -24,22 +18,13 @@ ax.axvline(0.5, linestyle="--") ax.set_ylabel("Readout response") ``` -In a notebook that axes is also the cell's value, so a `result.plot(m0)` on a -line of its own shows `` beside the figure. Bind it the way the -snippet above does, or end the call with a semicolon. +In a notebook that axes is also the cell's value, so a `result.plot(m0)` on a line of its own shows `` beside the figure. Bind it the way the snippet above does, or end the call with a semicolon. -Behind that call are two halves that never meet. `qp.plotting.build_figure` -reads the array and returns a `Figure`: marks holding numpy arrays, two axis -labels, and nothing about colour or canvas. A renderer takes that figure and a -`Style` and draws it. The seam is what lets a second backend exist, and what -lets a test check the shape of a figure without a display attached. +Behind that call are two halves that never meet. `qp.plotting.build_figure` reads the array and returns a `Figure`: marks holding numpy arrays, two axis labels, and nothing about colour or canvas. A renderer takes that figure and a `Style` and draws it. The seam is what lets a second backend exist, and what lets a test check the shape of a figure without a display attached. ## What the shape decides -Every dimension except `IQ` is a plot dimension. `IQ` is the one that never -becomes an axis: it holds the two quadratures of a single measured point, so it -becomes the series of a line figure or the two axes of a scatter. `time` is an -ordinary plot dimension, which is why a raw trace draws against it. +Every dimension except `IQ` is a plot dimension. `IQ` is the one that never becomes an axis: it holds the two quadratures of a single measured point, so it becomes the series of a line figure or the two axes of a scatter. `time` is an ordinary plot dimension, which is why a raw trace draws against it. | Plot dimensions | Figure | Example | |-----------------|-----------------|---------------------------------------------| @@ -48,18 +33,13 @@ ordinary plot dimension, which is why a raw trace draws against it. | none | `ValidationError` | an unswept measurement, `("IQ",)` | | three or more | `ValidationError` | select one down with `data.sel()` first | -`kind=` overrides the inference, and `kind="scatter"` is the one shape that is -never inferred: plotting I against Q is a choice no dimension count implies. It -puts I on one axis and Q on the other and flattens every other dimension into -the cloud. +`kind=` overrides the inference, and `kind="scatter"` is the one shape that is never inferred: plotting I against Q is a choice no dimension count implies. It puts I on one axis and Q on the other and flattens every other dimension into the cloud. ```python result.plot(shots, kind="scatter") ``` -Its axes are settled by what they are, so `x`, `y`, and `channels` all raise -there rather than being quietly ignored. `y` raises on a line figure for the -same reason: only a heatmap has a second dimension to put on an axis. +Its axes are settled by what they are, so `x`, `y`, and `channels` all raise there rather than being quietly ignored. `y` raises on a line figure for the same reason: only a heatmap has a second dimension to put on an axis. ## The two quadratures @@ -72,40 +52,25 @@ same reason: only a heatmap has a second dimension to put on an axis. | `"magnitude"` | `hypot(I, Q)`, the reading a rotation cannot change | | `"phase"` | `arctan2(Q, I)`, in radians | -A line figure takes both quadratures by default, since that is the pair the -measurement produced. A heatmap colours one surface and has to reduce them, so -it takes the magnitude instead; `channels="iq"` on a heatmap raises rather than -picking a quadrature for you. An array with no `IQ` dimension, a `state` field -for instance, is already one number per point and rejects `channels` outright. +A line figure takes both quadratures by default, since that is the pair the measurement produced. A heatmap colours one surface and has to reduce them, so it takes the magnitude instead; `channels="iq"` on a heatmap raises rather than picking a quadrature for you. An array with no `IQ` dimension, a `state` field for instance, is already one number per point and rejects `channels` outright. ## Axis labels and which axis is which -An axis labels itself from the coordinate. A variable declared with a `label` -and `units` carries both onto its coordinate, and the axis reads -`Drive amplitude (V)` with nothing typed out: +An axis labels itself from the coordinate. A variable declared with a `label` and `units` carries both onto its coordinate, and the axis reads `Drive amplitude (V)` with nothing typed out: ```python gain = program.variable("gain", label="Drive amplitude", units="V") ``` -Without a label the axis falls back to the variable id, and without units it is -the label alone. -[Variables and expressions](variables.md#label-units-and-description) has what -the two strings are and where else they travel. +Without a label the axis falls back to the variable id, and without units it is the label alone. [Variables and expressions](variables.md#label-units-and-description) has what the two strings are and where else they travel. -The other axis is the measured quantity, and there the result has less to go -on: a demodulated point is whatever the readout chain makes of it, and no unit -follows from the program. It is labeled from the channel by default, `Signal` -for a pair of quadratures and `Magnitude` for their hypotenuse, and `value=` -says what it really is. The same words label the colour bar of a heatmap. +The other axis is the measured quantity, and there the result has less to go on: a demodulated point is whatever the readout chain makes of it, and no unit follows from the program. It is labeled from the channel by default, `Signal` for a pair of quadratures and `Magnitude` for their hypotenuse, and `value=` says what it really is. The same words label the colour bar of a heatmap. ```python result.plot(m0, value=qp.plotting.Quantity("Readout response")) ``` -For a heatmap the innermost sweep runs along the x axis and the outermost up -the y axis, matching the loop nesting: the variable that changes fastest goes -left to right. `x=` and `y=` override that, and naming one settles the other: +For a heatmap the innermost sweep runs along the x axis and the outermost up the y axis, matching the loop nesting: the variable that changes fastest goes left to right. `x=` and `y=` override that, and naming one settles the other: ```python result.plot(m0) # x is "dur", the inner sweep @@ -114,13 +79,7 @@ result.plot(m0, x="amp") # x is "amp", so "dur" moves to y ## Two variables on one axis -A dimension built by a parallel composition carries one coordinate per composed -variable and none of its own, so there are two readings of every sample and no -reason to throw one away. Both are drawn: the first goes on the axis and the -second on a twin scale opposite it, which is matplotlib's `secondary_xaxis`, -the position-tracking form of `twiny`. The order is the order the dimension -name gives, which is the order the loops were written in, so `"freq|time"` -draws frequency along the bottom and time along the top. +A dimension built by a parallel composition carries one coordinate per composed variable and none of its own, so there are two readings of every sample and no reason to throw one away. Both are drawn: the first goes on the axis and the second on a twin scale opposite it, which is matplotlib's `secondary_xaxis`, the position-tracking form of `twiny`. The order is the order the dimension name gives, which is the order the loops were written in, so `"freq|time"` draws frequency along the bottom and time along the top. ```python with program.sweep(freq, qp.Range(4e9, 5e9, 25e6)) | program.sweep(time, qp.Range(0, 400, 10)): @@ -129,14 +88,9 @@ with program.sweep(freq, qp.Range(4e9, 5e9, 25e6)) | program.sweep(time, qp.Rang result.plot(m0) # freq along the bottom, time along the top ``` -The twin ticks at samples rather than at round numbers. The two loops advanced -in lockstep, so tick *i* and sample *i* are the same measurement, and putting a -tick anywhere else would mean interpolating between measured points to label a -position nothing was measured at. `Style(twin_ticks=...)` is how many to aim -for; a sweep shorter than that gets one per sample. +The twin ticks at samples rather than at round numbers. The two loops advanced in lockstep, so tick *i* and sample *i* are the same measurement, and putting a tick anywhere else would mean interpolating between measured points to label a position nothing was measured at. `Style(twin_ticks=...)` is how many to aim for; a sweep shorter than that gets one per sample. -`x=` and `y=` name an axis to draw on its own, which is how an axis with -nothing above it is asked for: +`x=` and `y=` name an axis to draw on its own, which is how an axis with nothing above it is asked for: ```python result.plot(m0, x="freq") # frequency along the bottom, nothing on top @@ -144,19 +98,11 @@ result.plot(m0, x="time") # time along the bottom instead result.plot(m0, x="freq|time") # the sweep index, if that is what you meant ``` -A heatmap twins each axis separately, so a chevron whose inner loop is a -composition reads its second variable across the top and a composition on the -outer loop reads up the right-hand side. A composition of three or more -variables draws the first two and leaves the rest: two scales on one axis is -already the most a reader can follow, and the coordinates are all still on the -array for a caller who wants a different one. +A heatmap twins each axis separately, so a chevron whose inner loop is a composition reads its second variable across the top and a composition on the outer loop reads up the right-hand side. A composition of three or more variables draws the first two and leaves the rest: two scales on one axis is already the most a reader can follow, and the coordinates are all still on the array for a caller who wants a different one. ## Restating a quantity -A result carries hertz because the instrument takes hertz, and the figure of it -wants gigahertz. That is two changes at once, arithmetic on the numbers and a -new unit on the axis, and `Quantity` carries the pair so that neither can -travel without the other: +A result carries hertz because the instrument takes hertz, and the figure of it wants gigahertz. That is two changes at once, arithmetic on the numbers and a new unit on the axis, and `Quantity` carries the pair so that neither can travel without the other: ```python from qprogram.plotting import Quantity @@ -169,13 +115,7 @@ result.plot( ) ``` -`coords=` is keyed by the name the axis resolved to, which is the same string -`x=` takes: the coordinate on the axis, or the dimension when no coordinate is. -A twin scale is keyed by its own coordinate the same way. -A key that reaches no axis raises rather than doing nothing, since a figure -that ignored it would print the axis it was asked to change. `value=` is the -measured quantity wherever it lands: the y axis of a line, the colour bar of a -heatmap, both axes of a scatter. +`coords=` is keyed by the name the axis resolved to, which is the same string `x=` takes: the coordinate on the axis, or the dimension when no coordinate is. A twin scale is keyed by its own coordinate the same way. A key that reaches no axis raises rather than doing nothing, since a figure that ignored it would print the axis it was asked to change. `value=` is the measured quantity wherever it lands: the y axis of a line, the colour bar of a heatmap, both axes of a scatter. | Field | What it does | |---|---| @@ -189,17 +129,11 @@ Read positionally the three are the sentence the axis makes: result.plot(m0, coords={"freq": Quantity("Detuning", "MHz", lambda f: (f - 5e9) / 1e6)}) ``` -A `Quantity` describes presentation only. `result.get(m0)` is still in hertz -after the figure of it has been drawn in gigahertz, which is what you want when -the next line fits a peak, and what to remember when the line after that draws -on the axes: everything you hand the returned `Axes` is in the figure's units, -so a frequency read back off the array needs the same `/ 1e9` the figure got. +A `Quantity` describes presentation only. `result.get(m0)` is still in hertz after the figure of it has been drawn in gigahertz, which is what you want when the next line fits a peak, and what to remember when the line after that draws on the axes: everything you hand the returned `Axes` is in the figure's units, so a frequency read back off the array needs the same `/ 1e9` the figure got. ### One rule, in both directions -A change of unit and a change of numbers travel together. Rescaling values that -carry a unit has to say what the unit is now, and a unit that contradicts the -one already there has to come with the arithmetic that earns it: +A change of unit and a change of numbers travel together. Rescaling values that carry a unit has to say what the unit is now, and a unit that contradicts the one already there has to come with the arithmetic that earns it: ```python # On a coordinate that declares units="Hz": @@ -209,39 +143,17 @@ Quantity(units="GHz", transform=lambda v: v / 1e9) # both halves, and the figur Quantity(units="Hz", transform=lambda v: v - v[0]) # a shift keeps its unit, and says so ``` -Both fire only where there is a claim to falsify, so a coordinate that declared -no unit, or a demodulated magnitude that has none to declare, takes either half -alone. That is also how you correct a unit the program never recorded: -`Quantity(units="V")` on an unlabeled coordinate is a statement, not a -contradiction. - -A transform is checked for the things that produce a broken figure rather than -a wrong one: it must not raise, must return the shape it was given, must return -real numbers, and must not turn a finite value into an infinity or a NaN. A NaN -the measurement itself carries, from a grid point a conditional arm never -reached, passes through untouched. What cannot be checked is whether the -arithmetic matches the unit — `Quantity(units="GHz", transform=lambda v: v / 1e6)` -is a lie no check here can catch, because `Variable.units` is free-form text -that legitimately holds `arb`, `counts` and `shots`. +Both fire only where there is a claim to falsify, so a coordinate that declared no unit, or a demodulated magnitude that has none to declare, takes either half alone. That is also how you correct a unit the program never recorded: `Quantity(units="V")` on an unlabeled coordinate is a statement, not a contradiction. + +A transform is checked for the things that produce a broken figure rather than a wrong one: it must not raise, must return the shape it was given, must return real numbers, and must not turn a finite value into an infinity or a NaN. A NaN the measurement itself carries, from a grid point a conditional arm never reached, passes through untouched. What cannot be checked is whether the arithmetic matches the unit — `Quantity(units="GHz", transform=lambda v: v / 1e6)` is a lie no check here can catch, because `Variable.units` is free-form text that legitimately holds `arb`, `counts` and `shots`. ### Why this moves the data, not the tick labels -matplotlib would let a formatter rewrite the tick text and leave the numbers -alone, and that is what `EngFormatter` and `FuncFormatter` do. This does not, -for three reasons. The figure model is numpy and xarray only, so a formatter -would be a rendering contract smuggled into the description. A transform like -`v - v[0]` or `v / v.max()` reads the whole array, which no per-tick formatter -can see. And a ticks-only rescale leaves `ax.get_xlim()`, a fit, and any -`axvline` in the old unit while the axis reads the new one, which is the -mismatch this page spends its rules preventing. The numbers on the axis are the -numbers drawn. +matplotlib would let a formatter rewrite the tick text and leave the numbers alone, and that is what `EngFormatter` and `FuncFormatter` do. This does not, for three reasons. The figure model is numpy and xarray only, so a formatter would be a rendering contract smuggled into the description. A transform like `v - v[0]` or `v / v.max()` reads the whole array, which no per-tick formatter can see. And a ticks-only rescale leaves `ax.get_xlim()`, a fit, and any `axvline` in the old unit while the axis reads the new one, which is the mismatch this page spends its rules preventing. The numbers on the axis are the numbers drawn. ## Themes -A `Style` is a palette plus the handful of settings that decide how heavy the -marks are. Two themes ship, `qp.plotting.LIGHT` and `qp.plotting.DARK`, and -both are frozen dataclasses, so a variant is one `dataclasses.replace` away and -a palette of your own is a constructor call. +A `Style` is a palette plus the handful of settings that decide how heavy the marks are. Two themes ship, `qp.plotting.LIGHT` and `qp.plotting.DARK`, and both are frozen dataclasses, so a variant is one `dataclasses.replace` away and a palette of your own is a constructor call. ```python from dataclasses import replace @@ -255,30 +167,15 @@ house = replace(qp.plotting.LIGHT, series=("#3b6ea5", "#c1554a")) result.plot(m0, style=qp.plotting.Style(theme=house)) ``` -`Style` carries `size`, `linewidth`, `markers`, `markersize`, `point_size`, -`point_alpha`, `grid`, `legend`, `colorbar`, and `twin_ticks` alongside `theme`. -`markers` is worth turning on for a coarse sweep, where the points are the -measurement and the line between them is interpolation. - -`size` is the one field with no default of its own. `None` means the size that -suits what is being drawn, which is `qp.plotting.DEFAULT_SIZE` for a -measurement and `ENVELOPE_SIZE` or `IQ_ENVELOPE_SIZE` for a waveform, and it is -read only when the figure is made here: axes you pass as `target=` keep the size -they came with. - -`Waveform.plot` takes the same `style`, `renderer` and `target`, which is most -of why the palette and the registry are objects of their own: a pi pulse and the -Rabi sweep it produced are one experiment, and a pair that speaks two visual -languages is a papercut. Its style defaults to `Style()` the way this one does, -and the only differences are the size a figure of a pulse comes out at and the -`(I, Q)` pair of panels an IQ shape wants for a `target`. -[Waveforms](waveforms.md) has the rest. +`Style` carries `size`, `linewidth`, `markers`, `markersize`, `point_size`, `point_alpha`, `grid`, `legend`, `colorbar`, and `twin_ticks` alongside `theme`. `markers` is worth turning on for a coarse sweep, where the points are the measurement and the line between them is interpolation. + +`size` is the one field with no default of its own. `None` means the size that suits what is being drawn, which is `qp.plotting.DEFAULT_SIZE` for a measurement and `ENVELOPE_SIZE` or `IQ_ENVELOPE_SIZE` for a waveform, and it is read only when the figure is made here: axes you pass as `target=` keep the size they came with. + +`Waveform.plot` takes the same `style`, `renderer` and `target`, which is most of why the palette and the registry are objects of their own: a pi pulse and the Rabi sweep it produced are one experiment, and a pair that speaks two visual languages is a papercut. Its style defaults to `Style()` the way this one does, and the only differences are the size a figure of a pulse comes out at and the `(I, Q)` pair of panels an IQ shape wants for a `target`. [Waveforms](waveforms.md) has the rest. ## Another renderer -A renderer is any callable taking a figure, a `Style`, and a surface to draw -on. Registering one works the way `register_sweep_source` works: one name, one -implementation, and a different object under a name already taken raises. +A renderer is any callable taking a figure, a `Style`, and a surface to draw on. Registering one works the way `register_sweep_source` works: one name, one implementation, and a different object under a name already taken raises. ```python import qprogram as qp @@ -298,27 +195,12 @@ result.plot(m0, renderer="text") qp.waveforms.Square(0.5, 100).plot(renderer="text") ``` -`build_figure` is the half worth reading first when writing one. It returns a -`Figure` holding `Line`, `Points`, and `Mesh` marks, each a small frozen -dataclass of numpy arrays, and a renderer dispatches on their types. Nothing in -that half imports a plotting library, so a renderer for any backend reads the -same description. +`build_figure` is the half worth reading first when writing one. It returns a `Figure` holding `Line`, `Points`, and `Mesh` marks, each a small frozen dataclass of numpy arrays, and a renderer dispatches on their types. Nothing in that half imports a plotting library, so a renderer for any backend reads the same description. -A figure hands over everything a renderer needs to draw it and nothing about -how: the marks, the two labels, a title, either `Twin` scale, and `series`, the -palette slot its first mark takes. That last one is only ever set when a figure -is one panel of several that should not repeat a colour, which is what the `Q` -panel of an IQ envelope is; a renderer drawing in one colour ignores it. +A figure hands over everything a renderer needs to draw it and nothing about how: the marks, the two labels, a title, either `Twin` scale, and `series`, the palette slot its first mark takes. That last one is only ever set when a figure is one panel of several that should not repeat a colour, which is what the `Q` panel of an IQ envelope is; a renderer drawing in one colour ignores it. ## What it does not draw -`plot` returns composable axes rather than trying to be the whole figure. A -layout of several panels, a fit drawn over the data, an annotation pointing at -a peak: none of those follow from anything the result knows, and all of them -are ordinary calls on the axes that come back. The example pages that build one -keep their own plotting code for exactly that reason. +`plot` returns composable axes rather than trying to be the whole figure. A layout of several panels, a fit drawn over the data, an annotation pointing at a peak: none of those follow from anything the result knows, and all of them are ordinary calls on the axes that come back. The example pages that build one keep their own plotting code for exactly that reason. -A result does not draw itself in a Jupyter cell the way a waveform does. A -waveform is one shape and has one picture; a result holds every measurement of -a run, and they need not share a field, a shape, or an axis. `repr` stays the -list of what is in there, and `plot` draws the one you name. +A result does not draw itself in a Jupyter cell the way a waveform does. A waveform is one shape and has one picture; a result holds every measurement of a run, and they need not share a field, a shape, or an axis. `repr` stays the list of what is in there, and `plot` draws the one you name. diff --git a/docs/guide/serialization.md b/docs/guide/serialization.md index 06818a6..3a3e881 100644 --- a/docs/guide/serialization.md +++ b/docs/guide/serialization.md @@ -1,10 +1,6 @@ # Saving and loading -A program serializes to `.qp`, a line-oriented text format whose parser and -writer live in `qprogram.serialization`. Both are hand-written Python: the -parser imports nothing outside the standard library, and the writer adds numpy -for array values. There is no JSON, YAML, or pickle layer underneath, so the -text in the file is the whole representation. +A program serializes to `.qp`, a line-oriented text format whose parser and writer live in `qprogram.serialization`. Both are hand-written Python: the parser imports nothing outside the standard library, and the writer adds numpy for array values. There is no JSON, YAML, or pickle layer underneath, so the text in the file is the whole representation. ## The four functions @@ -18,39 +14,21 @@ qp.save(program, "experiment.qp") program = qp.load("experiment.qp") ``` -`dumps` and `loads` hold the implementation. `save` is `dumps` followed by a -`write_text(..., encoding="utf-8")`, and `load` is the matching read, so the -encoding is fixed rather than taken from the platform locale and a file -written on one machine parses identically on another. `save` writes one file -and does nothing else; it will not create a missing parent directory. +`dumps` and `loads` hold the implementation. `save` is `dumps` followed by a `write_text(..., encoding="utf-8")`, and `load` is the matching read, so the encoding is fixed rather than taken from the platform locale and a file written on one machine parses identically on another. `save` writes one file and does nothing else; it will not create a missing parent directory. -`loads` and `load` each take one keyword argument, `auto_activate`, which -defaults to `True` and is described under -[vendor activation at parse time](#vendor-activation-at-parse-time). +`loads` and `load` each take one keyword argument, `auto_activate`, which defaults to `True` and is described under [vendor activation at parse time](#vendor-activation-at-parse-time). -On the way out, `dumps` raises `qp.SerializationError` rather than emitting -output it cannot read back: an operation or block class that is not registered, -a vendor with no registered version, a value type the format has no -representation for, an array of rank other than one, a dict with non-string -keys, a fragment call cycle, or two different fragments under one name. A -`Fragment` passed directly is refused for the same reason, since a fragment is -emitted as a section of the program that calls it: +On the way out, `dumps` raises `qp.SerializationError` rather than emitting output it cannot read back: an operation or block class that is not registered, a vendor with no registered version, a value type the format has no representation for, an array of rank other than one, a dict with non-string keys, a fragment call cycle, or two different fragments under one name. A `Fragment` passed directly is refused for the same reason, since a fragment is emitted as a section of the program that calls it: ``` SerializationError: cannot serialize Fragment 'pi_pulse' directly; fragments are emitted as `fragment ...:` sections of the host QProgram that calls them — serialize that program ``` -On the way in, malformed input raises `qp.ParseError`, whose message carries -the 1-based line number of the offending line and whose `line_num` attribute -holds the same number. Two other errors travel through unwrapped: a -`ValidationError` when a declaration the grammar accepts is rejected by the -program being built, and a `TypeError` when a constructor call in the file -does not fit its class's signature. +On the way in, malformed input raises `qp.ParseError`, whose message carries the 1-based line number of the offending line and whose `line_num` attribute holds the same number. Two other errors travel through unwrapped: a `ValidationError` when a declaration the grammar accepts is rejected by the program being built, and a `TypeError` when a constructor call in the file does not fit its class's signature. ## A file end to end -This is `qp.dumps` output for a T1 experiment built on -`qp.BusSchema.transmon()`, with an averaging block around a delay sweep: +This is `qp.dumps` output for a T1 experiment built on `qp.BusSchema.transmon()`, with an averaging block around a delay sweep: ``` #!QProgram 0.2 @@ -75,18 +53,11 @@ body: measure q[0].readout "readout" "weights" name="q0/readout/m0" ``` -The writer always emits the sections in this order: the `#!QProgram` header, -one `require` line per vendor, `metadata:`, `schema:`, any fragment -definitions, then `body:`. Nesting is two-space indentation, one statement per -line, and a `#` starts a comment that runs to the end of the line. The full -grammar is in [the `.qp` file format reference](../reference/qp-format.md). +The writer always emits the sections in this order: the `#!QProgram` header, one `require` line per vendor, `metadata:`, `schema:`, any fragment definitions, then `body:`. Nesting is two-space indentation, one statement per line, and a `#` starts a comment that runs to the end of the line. The full grammar is in [the `.qp` file format reference](../reference/qp-format.md). ## What the round trip preserves -For a program whose waveform and sweep-source arguments are numbers, quoted -strings, or bare variable references, loading the text back produces a -structurally equal program, and serializing that copy reproduces the same -bytes: +For a program whose waveform and sweep-source arguments are numbers, quoted strings, or bare variable references, loading the text back produces a structurally equal program, and serializing that copy reproduces the same bytes: ```python text = qp.dumps(program) @@ -99,38 +70,15 @@ assert reloaded.label == program.label assert reloaded.description == program.description ``` -The structural equality covers the whole AST: blocks, operations, expressions, -waveforms, bus references, measurement handles (which compare by name), and -the point arrays inside sweep sources such as `Values`, which are written out -in full because a truncated array could not be reconstructed. -`tests/test_round_trip.py` asserts this one feature surface at a time, and -`tests/test_round_trip_property.py` asserts both halves over programs -generated with hypothesis. +The structural equality covers the whole AST: blocks, operations, expressions, waveforms, bus references, measurement handles (which compare by name), and the point arrays inside sweep sources such as `Values`, which are written out in full because a truncated array could not be reconstructed. `tests/test_round_trip.py` asserts this one feature surface at a time, and `tests/test_round_trip_property.py` asserts both halves over programs generated with hypothesis. -The one shape that writes but does not read back is an expression or a math -function inside a constructor argument, such as -`Gaussian(amplitude=qp.sin(phi), ...)`. The writer emits it as it stands, the -grammar has no production for it, and the load fails with `ParseError: Unknown -waveform or sweep source type: sin`. Nothing in `dumps` checks for it, so a -clean write is not by itself a promise that the text parses. The argument -forms the parser does accept are in -[inline waveform constructors](../reference/qp-format.md#inline-waveform-constructors). +The one shape that writes but does not read back is an expression or a math function inside a constructor argument, such as `Gaussian(amplitude=qp.sin(phi), ...)`. The writer emits it as it stands, the grammar has no production for it, and the load fails with `ParseError: Unknown waveform or sweep source type: sin`. Nothing in `dumps` checks for it, so a clean write is not by itself a promise that the text parses. The argument forms the parser does accept are in [inline waveform constructors](../reference/qp-format.md#inline-waveform-constructors). ## What the round trip does not preserve -The Python class of a schema does not survive. `qp.BusSchema.transmon()` -returns a `TransmonSchema`; after a round trip `program.schema` is a plain -`BusSchema` carrying the same elements, bus kinds, and naming pattern. Bus -paths in the body are unaffected, because a `BusRef` is a `str` subclass and -the reloaded dynamic schema resolves `schema.q[0].drive` to the same -`q0/drive`. What is gone is the static type, and with it editor completion and -type checking on the accessors. `BusSchema` has no structural equality either, -so the original and the reloaded schema never compare equal. +The Python class of a schema does not survive. `qp.BusSchema.transmon()` returns a `TransmonSchema`; after a round trip `program.schema` is a plain `BusSchema` carrying the same elements, bus kinds, and naming pattern. Bus paths in the body are unaffected, because a `BusRef` is a `str` subclass and the reloaded dynamic schema resolves `schema.q[0].drive` to the same `q0/drive`. What is gone is the static type, and with it editor completion and type checking on the accessors. `BusSchema` has no structural equality either, so the original and the reloaded schema never compare equal. -Whether a measurement name was auto-allocated or user-supplied is in-memory -state, not part of the file. `QProgram.measure` records it so that `rebind` -knows which names to re-derive when a bus changes, and every handle -reconstructed from `.qp` looks user-supplied: +Whether a measurement name was auto-allocated or user-supplied is in-memory state, not part of the file. `QProgram.measure` records it so that `rebind` knows which names to re-derive when a bus changes, and every handle reconstructed from `.qp` looks user-supplied: ```python program.measure(schema.q[0].readout, "readout", "weights") @@ -143,63 +91,29 @@ qp.dumps(qp.loads(qp.dumps(program)).rebind(elements=moved)) # ... measure q[1].readout "readout" "weights" name="q0/readout/m0" ``` -Rebind before saving when the names matter. Runtime values written onto a -`MeasurementHandle` by an execution are in-memory state for the same reason: -the file records the name, never the result. +Rebind before saving when the names matter. Runtime values written onto a `MeasurementHandle` by an execution are in-memory state for the same reason: the file records the name, never the result. -`QProgram.source_map` runs the other way. It is empty for a program built in -Python and filled by `loads` and `load` with the 1-based line of every node in -the `body:` section, which is what lets a diagnostic point at a line of the -file it came from. `expand` clears it, since the expansion restructures the -tree the paths were computed against. +`QProgram.source_map` runs the other way. It is empty for a program built in Python and filled by `loads` and `load` with the 1-based line of every node in the `body:` section, which is what lets a diagnostic point at a line of the file it came from. `expand` clears it, since the expansion restructures the tree the paths were computed against. -Going the other way, from text to program to text, is a normalization rather -than an identity. Comments and blank lines are the author's and are not -recorded in the AST, so they do not come back. Metadata keys the parser does -not know are accepted for forward compatibility and dropped on the next write, -so an `author: "..."` line loads without error but is not written back. -Fragment call arguments are re-emitted positionally in parameter order whatever -spelling the caller used, and a hand-written `measure` line with no `name=` -comes back as `m0`, `m1`, and so on even when its bus is a schema path, because -the handle is allocated before the path is promoted to a `BusRef`. +Going the other way, from text to program to text, is a normalization rather than an identity. Comments and blank lines are the author's and are not recorded in the AST, so they do not come back. Metadata keys the parser does not know are accepted for forward compatibility and dropped on the next write, so an `author: "..."` line loads without error but is not written back. Fragment call arguments are re-emitted positionally in parameter order whatever spelling the caller used, and a hand-written `measure` line with no `name=` comes back as `m0`, `m1`, and so on even when its bus is a schema path, because the handle is allocated before the path is promoted to a `BusRef`. ## Format version and the require line -Every file opens with the format version, which the writer takes from -`FORMAT_VERSION` in `qprogram/serialization/_format.py`, the single constant -both sides read. It is the installed library version truncated to -`major.minor`, so a `qprogram` 0.2.1 writes: +Every file opens with the format version, which the writer takes from `FORMAT_VERSION` in `qprogram/serialization/_format.py`, the single constant both sides read. It is the installed library version truncated to `major.minor`, so a `qprogram` 0.2.1 writes: ``` #!QProgram 0.2 ``` -The parser checks the header before anything else, and what it does next -depends on which side of the running version the file is on. +The parser checks the header before anything else, and what it does next depends on which side of the running version the file is on. -An older file is migrated. A release that changes the syntax registers one -migration under its own version, and loading applies every migration newer than -the file's version, oldest first, to the lines in memory — the file on disk is -never rewritten. So a program saved by any earlier release parses against -today's grammar, and a release that broke nothing registers nothing. The -rewrites work line for line, which is what keeps a `ParseError`'s line number -and `source_map` pointing at lines of the file you opened; -[Migrations](../developer/serialization-internals.md#migrations) is how one is -written. +An older file is migrated. A release that changes the syntax registers one migration under its own version, and loading applies every migration newer than the file's version, oldest first, to the lines in memory — the file on disk is never rewritten. So a program saved by any earlier release parses against today's grammar, and a release that broke nothing registers nothing. The rewrites work line for line, which is what keeps a `ParseError`'s line number and `source_map` pointing at lines of the file you opened; [Migrations](../developer/serialization-internals.md#migrations) is how one is written. -A newer file is refused: `#!QProgram 0.9` fails with `Line 1: Unsupported -format version 0.9`, because a release cannot know what a later one changed, and -there is no migration that runs backwards. The version is `major.minor` exactly -— a patch release changes code, never the format, so a file has no patch to -declare, and `#!QProgram 0.2.3` is refused with the same message. +A newer file is refused: `#!QProgram 0.9` fails with `Line 1: Unsupported format version 0.9`, because a release cannot know what a later one changed, and there is no migration that runs backwards. The version is `major.minor` exactly — a patch release changes code, never the format, so a file has no patch to declare, and `#!QProgram 0.2.3` is refused with the same message. -That is the compatibility contract: a minor adds sections, operations, and -constructs, a major is reserved for a change that breaks the older spelling -outright, and either way the file that a release wrote goes on loading under -every release after it. +That is the compatibility contract: a minor adds sections, operations, and constructs, a major is reserved for a change that breaks the older spelling outright, and either way the file that a release wrote goes on loading under every release after it. -A program that uses vendor operations or vendor blocks carries one `require` -line per vendor, directly after the header: +A program that uses vendor operations or vendor blocks carries one `require` line per vendor, directly after the header: ``` #!QProgram 0.2 @@ -211,29 +125,16 @@ body: myvendor.set_markers "drive_q0" "0001" ``` -The writer emits a line for every vendor it finds anywhere in the program, -including inside fragment definitions and conditional arms, and including a -vendor whose only contribution is a block. The version comes from whatever the -installed extension registered, truncated to `major.minor`, because that is -the granularity the compatibility check works at. +The writer emits a line for every vendor it finds anywhere in the program, including inside fragment definitions and conditional arms, and including a vendor whose only contribution is a block. The version comes from whatever the installed extension registered, truncated to `major.minor`, because that is the granularity the compatibility check works at. -The parser resolves each line against the extension registered in this -environment, and does it before reading the body. The rule is the header's, one -level down: the line asks for a `major.minor`, anything the installed extension -cannot provide is refused, and anything older loads with the extension's own -migrations applied to the body first. +The parser resolves each line against the extension registered in this environment, and does it before reading the body. The rule is the header's, one level down: the line asks for a `major.minor`, anything the installed extension cannot provide is refused, and anything older loads with the extension's own migrations applied to the body first. ``` Line 3: file requires myvendor 2.0, newer than the installed myvendor 0.1.0 — install myvendor 2.0 or newer Line 3: file version '0.7.1' must be exactly major.minor ``` -So an extension that renames an operation registers a rewrite for it — -`qp.register_vendor_migration("myvendor", "0.4")` — and the files its users -already have keep loading. A patch component in the line is refused rather than -ignored, since a patch release of an extension has no wire form of its own. A -`require` line that appears after the first section is rejected outright, so -the dependency list is always readable off the top of the file: +So an extension that renames an operation registers a rewrite for it — `qp.register_vendor_migration("myvendor", "0.4")` — and the files its users already have keep loading. A patch component in the line is refused rather than ignored, since a patch release of an extension has no wire form of its own. A `require` line that appears after the first section is rejected outright, so the dependency list is always readable off the top of the file: ``` Line 6: `require` declarations must appear directly after the header, before any section @@ -241,46 +142,26 @@ Line 6: `require` declarations must appear directly after the header, before any ## Vendor activation at parse time -When a `require` line names a vendor that is not registered yet, the parser -tries to make it registered instead of failing. A vendor package declares an -entry point whose name is the vendor namespace and whose value is a module -that self-registers on import: +When a `require` line names a vendor that is not registered yet, the parser tries to make it registered instead of failing. A vendor package declares an entry point whose name is the vendor namespace and whose value is a module that self-registers on import: ```toml [project.entry-points."qprogram.vendors"] myvendor = "qprogram_myvendor" ``` -The parser looks the vendor up in the `qprogram.vendors` group, imports the -module behind it, and checks that a protocol version is now registered. The -discovery scan is memoized for the life of the process, and if two installed -distributions claim the same namespace the first one found wins, which is a -packaging mistake rather than a supported arrangement. Because the import runs -the extension's registration side effects, an environment where every required -extension is installed and declares that entry point can load the file, with -nothing imported by hand first. An extension installed without the entry point -still has to be imported before the load. +The parser looks the vendor up in the `qprogram.vendors` group, imports the module behind it, and checks that a protocol version is now registered. The discovery scan is memoized for the life of the process, and if two installed distributions claim the same namespace the first one found wins, which is a packaging mistake rather than a supported arrangement. Because the import runs the extension's registration side effects, an environment where every required extension is installed and declares that entry point can load the file, with nothing imported by hand first. An extension installed without the entry point still has to be imported before the load. -Two failures are worth telling apart. If no installed package claims the -vendor, the file cannot be loaded here at all: +Two failures are worth telling apart. If no installed package claims the vendor, the file cannot be loaded here at all: ``` Line 3: file requires vendor 'myvendor' 0.1 but no matching extension is registered in this environment — install the package that declares the 'qprogram.vendors' entry point for 'myvendor', or import the extension before loading ``` -If a package does claim it but the import raises, or imports without calling -`register_vendor_version`, that is a defect in the extension, and the -`VendorActivationError` describing it is wrapped in the `ParseError` so the -message names the entry point that failed. +If a package does claim it but the import raises, or imports without calling `register_vendor_version`, that is a defect in the extension, and the `VendorActivationError` describing it is wrapped in the `ParseError` so the message names the entry point that failed. -Activation is driven by the `require` line and nothing else. A hand-written -file that calls `myvendor.acquire` with no `require myvendor` line loads fine -if the extension is already imported, and otherwise fails later, at the first -dotted operation, as an unknown vendor operation. Write the line. +Activation is driven by the `require` line and nothing else. A hand-written file that calls `myvendor.acquire` with no `require myvendor` line loads fine if the extension is already imported, and otherwise fails later, at the first dotted operation, as an unknown vendor operation. Write the line. -Pass `auto_activate=False` to `qp.loads` or `qp.load` to turn the on-demand -import off. An unregistered vendor is then a hard error whatever is installed, -and the message says so: +Pass `auto_activate=False` to `qp.loads` or `qp.load` to turn the on-demand import off. An unregistered vendor is then a hard error whatever is installed, and the message says so: ``` Line 3: file requires vendor 'myvendor' 0.1 but no matching extension is registered in this environment — auto-activation is disabled; import the extension before loading (e.g. `import qprogram_myvendor`) @@ -288,10 +169,7 @@ Line 3: file requires vendor 'myvendor' 0.1 but no matching extension is registe ## What a vendor package registers -The grammar has no built-in keyword lists. Both sides dispatch through -registries, so a vendor adds vocabulary by registering it at import time and -needs no parser or writer change. These are the calls it makes, all reachable -from the top-level package: +The grammar has no built-in keyword lists. Both sides dispatch through registries, so a vendor adds vocabulary by registering it at import time and needs no parser or writer change. These are the calls it makes, all reachable from the top-level package: | Call | What it adds | |---|---| @@ -300,30 +178,15 @@ from the top-level package: | `qp.register_vendor_block(vendor, name, cls)` | A control-flow block written as `.:` with an indented suite. | | `qp.register_waveform(cls)` and `qp.register_sweep_source(cls)` | A constructor name for a waveform or a sweep source. `register_sweep_source` also registers the class's `TOKEN` with the capability registry. | -Operations accept optional `serialize` and `parse` callbacks for a wire form -that signature-driven serialization cannot produce, and blocks accept -`serialize_header` and `parse_header` for the same reason; without them, the -operation is written from its `__init__` signature with required parameters -positional in declaration order and optional ones as `key=value` only where -the stored value differs from the default. `register_operation` and -`register_block` underneath take a `vendor=None` argument and register core -vocabulary; they are reachable as `qp.serialization.register_operation` and -`qp.serialization.register_block`. - -Re-registering the same class under the same name is allowed, since an -import-time side-effect module can run more than once. Claiming a name that -another class already holds raises `ValueError` naming the incumbent, because -replacing it would change how every existing file with that keyword parses. -Vendor names are checked against the reserved set (`qp.RESERVED_KEYWORDS` plus -the `core` sentinel) and rejected if they collide. - -[Building a vendor extension](../developer/vendor-extensions.md) walks a -package through all of this end to end. +Operations accept optional `serialize` and `parse` callbacks for a wire form that signature-driven serialization cannot produce, and blocks accept `serialize_header` and `parse_header` for the same reason; without them, the operation is written from its `__init__` signature with required parameters positional in declaration order and optional ones as `key=value` only where the stored value differs from the default. `register_operation` and `register_block` underneath take a `vendor=None` argument and register core vocabulary; they are reachable as `qp.serialization.register_operation` and `qp.serialization.register_block`. + +Re-registering the same class under the same name is allowed, since an import-time side-effect module can run more than once. Claiming a name that another class already holds raises `ValueError` naming the incumbent, because replacing it would change how every existing file with that keyword parses. Vendor names are checked against the reserved set (`qp.RESERVED_KEYWORDS` plus the `core` sentinel) and rejected if they collide. + +[Building a vendor extension](../developer/vendor-extensions.md) walks a package through all of this end to end. ## Schemas serialize inline -If your program was constructed with a `BusSchema`, the schema lands at the -top of the file as a single inline block: +If your program was constructed with a `BusSchema`, the schema lands at the top of the file as a single inline block: ``` schema: @@ -339,20 +202,9 @@ play q[0].drive "pi_pulse" measure q[0].readout "readout" "weights" name="q0/readout/m0" ``` -The writer emits this expanded form even for the presets, so -`BusSchema.transmon()` writes the same block a hand-built schema of the same -shape would. The presets are construction-time conveniences on the Python -side; recording their contents rather than their names means adding a bus to a -preset cannot change what an existing file says. A non-default naming pattern -is emitted as a `naming:` line inside the block. +The writer emits this expanded form even for the presets, so `BusSchema.transmon()` writes the same block a hand-built schema of the same shape would. The presets are construction-time conveniences on the Python side; recording their contents rather than their names means adding a bus to a preset cannot change what an existing file says. A non-default naming pattern is emitted as a `naming:` line inside the block. -An unquoted path and a quoted string mean different things. An unquoted -`q[0].drive` in a program that declares a schema resolves against it, and a -path naming an element or a bus kind the schema does not have is a parse -error. A quoted `"q[0].drive"` stays the string it looks like, which is what -keeps a raw-string bus that happens to be path-shaped from being promoted on -reload. In a program with no `schema:` block, an unquoted path-shaped token -has nothing to resolve against and stays a plain string. +An unquoted path and a quoted string mean different things. An unquoted `q[0].drive` in a program that declares a schema resolves against it, and a path naming an element or a bus kind the schema does not have is a parse error. A quoted `"q[0].drive"` stays the string it looks like, which is what keeps a raw-string bus that happens to be path-shaped from being promoted on reload. In a program with no `schema:` block, an unquoted path-shaped token has nothing to resolve against and stays a plain string. ## Variables in the file @@ -365,11 +217,7 @@ body: var t units="ns" description="Free-evolution time" ``` -The unquoted token after `var` is the identifier used everywhere else in the -body; the rest is optional metadata, and only the annotations the variable -actually carries are emitted, so a bare variable writes as `var gain` and -reloads identically. A fragment's parameters and locals form their own scope -and are declared inside the `fragment` section rather than here. +The unquoted token after `var` is the identifier used everywhere else in the body; the rest is optional metadata, and only the annotations the variable actually carries are emitted, so a bare variable writes as `var gain` and reloads identically. A fragment's parameters and locals form their own scope and are declared inside the `fragment` section rather than here. ## Waveform aliases and inline constructors @@ -380,38 +228,15 @@ play "drive_q0" "pi_pulse" play "drive_q0" IQDrag(amplitude=0.5, duration=40, sigma=8, beta=0.2) ``` -An alias stays a string on reload, ready to be resolved later by -`with_waveforms` against a -[waveform library](#waveform-libraries-and-the-wfl-file). An inline -constructor comes back as the same class it started as, with the same argument -values. - -A waveform registered with `qp.register_waveform` uses that same constructor -syntax with no further work, but the class has to satisfy one contract: the -writer emits every public attribute the instance holds, taken from `vars(wf)` -in assignment order, and the parser passes those keywords straight to the -constructor. Attribute names and parameter names therefore have to agree, and -a public attribute that is not a constructor parameter breaks the round trip. -A class that caches a derived array as `self.samples_cache` writes that array -into the file and then fails on reload with -`TypeError: Blip.__init__() got an unexpected keyword argument 'samples_cache'`. -Give the cache a leading underscore and the writer skips it. -[Adding waveforms](../developer/adding-waveforms.md) has the rest of the class -contract. - -Sweep sources work the same way and go through the same code path, which is -what lets one nest inside a combinator's argument list. `Values` is the -exception: it writes as the bracket literal `[0.1, 0.2, 0.3]` rather than a -constructor call, and the literal holds every point. +An alias stays a string on reload, ready to be resolved later by `with_waveforms` against a [waveform library](#waveform-libraries-and-the-wfl-file). An inline constructor comes back as the same class it started as, with the same argument values. + +A waveform registered with `qp.register_waveform` uses that same constructor syntax with no further work, but the class has to satisfy one contract: the writer emits every public attribute the instance holds, taken from `vars(wf)` in assignment order, and the parser passes those keywords straight to the constructor. Attribute names and parameter names therefore have to agree, and a public attribute that is not a constructor parameter breaks the round trip. A class that caches a derived array as `self.samples_cache` writes that array into the file and then fails on reload with `TypeError: Blip.__init__() got an unexpected keyword argument 'samples_cache'`. Give the cache a leading underscore and the writer skips it. [Adding waveforms](../developer/adding-waveforms.md) has the rest of the class contract. + +Sweep sources work the same way and go through the same code path, which is what lets one nest inside a combinator's argument list. `Values` is the exception: it writes as the bracket literal `[0.1, 0.2, 0.3]` rather than a constructor call, and the literal holds every point. ## Waveform libraries and the `.wfl` file -An alias has to be resolved before a program can run, and `qp.WaveformLibrary` -is what resolves it. The split is there so that a program can say which pulse -to play without saying what that pulse currently is, which is what lets it -survive a recalibration unchanged; the concrete pulses live in a library -instead, an object with no platform attached that is replaced after every -tune-up. +An alias has to be resolved before a program can run, and `qp.WaveformLibrary` is what resolves it. The split is there so that a program can say which pulse to play without saying what that pulse currently is, which is what lets it survive a recalibration unchanged; the concrete pulses live in a library instead, an object with no platform attached that is replaced after every tune-up. ```python import qprogram as qp @@ -431,46 +256,17 @@ library.set("weights", qp.waveforms.IQPair(qp.waveforms.Square(1.0, 2000), qp.wa resolved = program.with_waveforms(library) ``` -Which keyword arguments `set` receives decides the tier the entry is stored at, -and only three combinations are accepted. `element`, `idx`, and `kind` together -register an exact entry, reachable from one bus only. `element` and `kind` -register a family entry, reachable from that bus kind at any index. None of the -three registers a global entry, reachable from every bus. `idx` takes a tuple -for a multi-index element, which is how the coupler entry above covers -`c[0,1].flux`. Any other combination raises `ValidationError` rather than -guessing at the tier: +Which keyword arguments `set` receives decides the tier the entry is stored at, and only three combinations are accepted. `element`, `idx`, and `kind` together register an exact entry, reachable from one bus only. `element` and `kind` register a family entry, reachable from that bus kind at any index. None of the three registers a global entry, reachable from every bus. `idx` takes a tuple for a multi-index element, which is how the coupler entry above covers `c[0,1].flux`. Any other combination raises `ValidationError` rather than guessing at the tier: ``` WaveformLibrary.set: specify (element, idx, kind) for an exact entry, (element, kind) for a family default, or none of them for a global entry; got element='q', idx=0, kind=None ``` -`library.get(bus, name)` tries the three tiers most specific first and returns -the first entry that matches, or `None` when none does, so a more specific -entry shadows a less specific one for the buses it covers and the global tier -acts as the default. Reaching the exact and family tiers needs the -`(element, idx, kind)` metadata a `BusRef` carries: a raw-string bus has none -of it, so `library.get("q0/drive", "pi_pulse")` is `None` even with the exact -`q[0].drive` entry registered. Asked for the same name on the schema path, -the library returns the `IQDrag` with amplitude 0.5 on `q[0].drive` and the one -with amplitude 0.9 on `q[1].drive`. Setting the same name at the -same tier twice overwrites, so the last entry registered wins. - -`with_waveforms` deep-copies the program and rewrites every string waveform -attribute its operations declare, which covers a `measure`'s weights as well as -a `play`'s waveform. A name with no entry stays a string and an already -concrete waveform passes through, both without error, and each replacement -re-runs the channel check, so an IQ pulse resolved onto a single-channel bus -raises `ValidationError` here rather than in a vendor compiler. `apply` is the -same operation spelled from the library's side, for tooling and tests that -resolve without going through a platform: `library.apply(program)` calls -`program.with_waveforms(library)`. A plain `{name: waveform}` mapping is -accepted where a library is, and goes through -`qp.WaveformLibrary.from_mapping`, which puts every key at the global tier, so -a mapping resolves on every bus and cannot express a per-bus difference. - -The library carries its own four functions, matching the program's in name and -in behavior, with `save` and `load` fixed to UTF-8 the same way. `loads` and -`load` are class methods and return a new library: +`library.get(bus, name)` tries the three tiers most specific first and returns the first entry that matches, or `None` when none does, so a more specific entry shadows a less specific one for the buses it covers and the global tier acts as the default. Reaching the exact and family tiers needs the `(element, idx, kind)` metadata a `BusRef` carries: a raw-string bus has none of it, so `library.get("q0/drive", "pi_pulse")` is `None` even with the exact `q[0].drive` entry registered. Asked for the same name on the schema path, the library returns the `IQDrag` with amplitude 0.5 on `q[0].drive` and the one with amplitude 0.9 on `q[1].drive`. Setting the same name at the same tier twice overwrites, so the last entry registered wins. + +`with_waveforms` deep-copies the program and rewrites every string waveform attribute its operations declare, which covers a `measure`'s weights as well as a `play`'s waveform. A name with no entry stays a string and an already concrete waveform passes through, both without error, and each replacement re-runs the channel check, so an IQ pulse resolved onto a single-channel bus raises `ValidationError` here rather than in a vendor compiler. `apply` is the same operation spelled from the library's side, for tooling and tests that resolve without going through a platform: `library.apply(program)` calls `program.with_waveforms(library)`. A plain `{name: waveform}` mapping is accepted where a library is, and goes through `qp.WaveformLibrary.from_mapping`, which puts every key at the global tier, so a mapping resolves on every bus and cannot express a per-bus difference. + +The library carries its own four functions, matching the program's in name and in behavior, with `save` and `load` fixed to UTF-8 the same way. `loads` and `load` are class methods and return a new library: ```python library.save("cal.wfl") @@ -480,9 +276,7 @@ text = library.dumps() library = qp.WaveformLibrary.loads(text) ``` -`dumps` writes the header line and one line per entry, in insertion order, so -`qp.WaveformLibrary.loads(library.dumps()).dumps()` reproduces the text -exactly. This is the library built above: +`dumps` writes the header line and one line per entry, in insertion order, so `qp.WaveformLibrary.loads(library.dumps()).dumps()` reproduces the text exactly. This is the library built above: ``` #!WaveformLibrary 0.2 @@ -493,75 +287,31 @@ exactly. This is the library built above: "weights" = IQPair(I=Square(amplitude=1.0, duration=2000), Q=Square(amplitude=1.0, duration=2000)) ``` -The coordinate between the name and the `=` is the tier: `element[idx].kind` -for an exact entry, `element[*].kind` for a family entry, and nothing at all -for a global one. Waveforms use the same constructor syntax as `.qp` and are -looked up in the same registry, so a class registered with -`qp.register_waveform` needs no further work to appear in a `.wfl` file, and a -vendor waveform needs its package imported before the file loads. An empty -library writes as the header alone and loads back empty. +The coordinate between the name and the `=` is the tier: `element[idx].kind` for an exact entry, `element[*].kind` for a family entry, and nothing at all for a global one. Waveforms use the same constructor syntax as `.qp` and are looked up in the same registry, so a class registered with `qp.register_waveform` needs no further work to appear in a `.wfl` file, and a vendor waveform needs its package imported before the file loads. An empty library writes as the header alone and loads back empty. -The header carries a version, and it is the same number a `.qp` file written by -the same release carries. It is read the same way too: `major.minor` exactly, a -later version refused, and an earlier one migrated by the rewrites registered -for the `"wfl"` format. The two formats keep separate tables, since -`"pi" = Square(...)` in a library and `play "drive" Square(...)` in a program -are not the same line, so a release that changes both registers its rewrite -under both. +The header carries a version, and it is the same number a `.qp` file written by the same release carries. It is read the same way too: `major.minor` exactly, a later version refused, and an earlier one migrated by the rewrites registered for the `"wfl"` format. The two formats keep separate tables, since `"pi" = Square(...)` in a library and `play "drive" Square(...)` in a program are not the same line, so a release that changes both registers its rewrite under both. -What `dumps` refuses to write is a waveform that is not concrete, since a -calibration set that carries a `Variable` is not something an instrument can be -handed: +What `dumps` refuses to write is a waveform that is not concrete, since a calibration set that carries a `Variable` is not something an instrument can be handed: ``` SerializationError: cannot serialize the waveform stored under name 'x': a WaveformLibrary must hold concrete waveforms (no Variables / symbolic parameters). Underlying error: 'amp' ``` -A library is never part of a `.qp` file, and resolution is one-way: the aliases -are gone from `resolved`, and `qp.dumps(resolved)` writes the pulses inline as -`IQDrag(...)` and `IQPair(...)` calls. The pair worth keeping is therefore the -alias-bearing program, which changes when the experiment changes, and the -`.wfl` next to it, which changes when the calibration does. -[The `.wfl` format](../reference/qp-format.md#the-wfl-format) gives the -grammar, the header, and the version rules. +A library is never part of a `.qp` file, and resolution is one-way: the aliases are gone from `resolved`, and `qp.dumps(resolved)` writes the pulses inline as `IQDrag(...)` and `IQPair(...)` calls. The pair worth keeping is therefore the alias-bearing program, which changes when the experiment changes, and the `.wfl` next to it, which changes when the calibration does. [The `.wfl` format](../reference/qp-format.md#the-wfl-format) gives the grammar, the header, and the version rules. ## What the writer normalizes -Optional keyword arguments sitting at their default are not emitted at all. -`measure(..., fields=(qp.MeasurementField.IQ,))` writes as a bare `measure` -line with no `fields=` suffix, which also means adding a new optional -parameter with a default does not change what existing programs write. -Requested fields are canonically ordered on the way out, by -`MeasurementField` declaration order and then vendor names alphabetically, so -`fields=("iq", "state")` and `fields=("state", "iq")` both write as -`fields=["state", "iq"]` and both compare equal in memory. - -Variable identifiers are used verbatim. Ids are validated as Python-style -identifiers and `QProgram.variable` rejects duplicates, so the writer never -has to invent a disambiguation suffix. - -Nothing in the output depends on the state of the process that produced it. -There are no timestamps, no object addresses, and no set iteration left -unordered: `require` lines are sorted by vendor name, fragments are emitted in -dependency order computed at write time rather than in registration order, -variables and schema elements follow declaration order, and measurement fields -follow their canonical order. Floats are written with `repr`, which is the -shortest text that reads back as the same double. Two runs of the same program -therefore produce byte-identical files, and a regenerated `.qp` file in a git -repository shows a diff only where the program actually changed. +Optional keyword arguments sitting at their default are not emitted at all. `measure(..., fields=(qp.MeasurementField.IQ,))` writes as a bare `measure` line with no `fields=` suffix, which also means adding a new optional parameter with a default does not change what existing programs write. Requested fields are canonically ordered on the way out, by `MeasurementField` declaration order and then vendor names alphabetically, so `fields=("iq", "state")` and `fields=("state", "iq")` both write as `fields=["state", "iq"]` and both compare equal in memory. + +Variable identifiers are used verbatim. Ids are validated as Python-style identifiers and `QProgram.variable` rejects duplicates, so the writer never has to invent a disambiguation suffix. + +Nothing in the output depends on the state of the process that produced it. There are no timestamps, no object addresses, and no set iteration left unordered: `require` lines are sorted by vendor name, fragments are emitted in dependency order computed at write time rather than in registration order, variables and schema elements follow declaration order, and measurement fields follow their canonical order. Floats are written with `repr`, which is the shortest text that reads back as the same double. Two runs of the same program therefore produce byte-identical files, and a regenerated `.qp` file in a git repository shows a diff only where the program actually changed. ## Where the parser stays strict -Anything the parser cannot map onto a registered class fails on load rather -than loading as something else. That covers an unknown operation name, core or -dotted-vendor; an unknown block keyword; an unknown top-level section; an -unknown waveform or sweep-source constructor; a bus path that does not resolve -against the program's schema; a `schema:` block with no element declarations; a -`label` or `description` whose value is not a quoted string; and a `require` -line that does not match the installed extension. +Anything the parser cannot map onto a registered class fails on load rather than loading as something else. That covers an unknown operation name, core or dotted-vendor; an unknown block keyword; an unknown top-level section; an unknown waveform or sweep-source constructor; a bus path that does not resolve against the program's schema; a `schema:` block with no element declarations; a `label` or `description` whose value is not a quoted string; and a `require` line that does not match the installed extension. -The messages name the offending token and, where the answer is a small closed -set, list the alternatives: +The messages name the offending token and, where the answer is a small closed set, list the alternatives: ``` Line 4: unknown operation 'playy': no core operation is registered under that name @@ -569,38 +319,20 @@ Line 6: unknown sweep source 'Rango'; registered sources are ['Concat', 'File', Line 3: unexpected top-level line 'bodyy:'; expected `metadata:`, `schema:`, `fragment ...:`, or `body:` ``` -Excess positional tokens on an operation line are an error too, since dropping -them would load a different program without saying so: +Excess positional tokens on an operation line are an error too, since dropping them would load a different program without saying so: ``` Line 4: too many arguments for 'Play': 4 positional tokens but the operation takes at most 2; unexpected: ['"extra"', '"more"']. If you meant an arithmetic expression, parenthesize it: `(100 - t)`. ``` -Two error shapes come from outside the line-tracking path and so carry no -line number: an unknown constructor name, reported as -`Unknown waveform or sweep source type: Gaussion`, and a constructor call -whose arguments do not fit its signature, where the class's own `TypeError` -travels out unwrapped. A `var` line whose id is a reserved keyword is a third -exception, and surfaces the same `InvalidVariableIdError` the builder raises at -the `variable()` call. +Two error shapes come from outside the line-tracking path and so carry no line number: an unknown constructor name, reported as `Unknown waveform or sweep source type: Gaussion`, and a constructor call whose arguments do not fit its signature, where the class's own `TypeError` travels out unwrapped. A `var` line whose id is a reserved keyword is a third exception, and surfaces the same `InvalidVariableIdError` the builder raises at the `variable()` call. ## Why the format is text -`.qp` trades binary compactness for two properties that matter more for the -files people keep. It is readable, so a program reviews as a diff in a git -repository and can be edited by hand or generated by a script that does not -link against QProgram. It is self-contained, so a file pins the format version -and the protocol version of every extension it depends on, and an environment -that satisfies those can load and run it without further context. +`.qp` trades binary compactness for two properties that matter more for the files people keep. It is readable, so a program reviews as a diff in a git repository and can be edited by hand or generated by a script that does not link against QProgram. It is self-contained, so a file pins the format version and the protocol version of every extension it depends on, and an environment that satisfies those can load and run it without further context. -The cost is size. A sweep over ten thousand explicit points is ten thousand -numbers of text, since the writer refuses to truncate an array it could not -reconstruct. If you need a wire format optimized for size, build it on the AST -directly rather than post-processing the text: the classes in -`qp.operations`, `qp.blocks`, and `qp.waveforms`, and the expression types -`qp.Variable` and `qp.Expression`, are structural and stable. +The cost is size. A sweep over ten thousand explicit points is ten thousand numbers of text, since the writer refuses to truncate an array it could not reconstruct. If you need a wire format optimized for size, build it on the AST directly rather than post-processing the text: the classes in `qp.operations`, `qp.blocks`, and `qp.waveforms`, and the expression types `qp.Variable` and `qp.Expression`, are structural and stable. ## Related pages -[Serialization internals](../developer/serialization-internals.md) covers the -registries, the writer's dispatch, and the parser's structure. +[Serialization internals](../developer/serialization-internals.md) covers the registries, the writer's dispatch, and the parser's structure. diff --git a/docs/guide/variables.md b/docs/guide/variables.md index 83f9a9d..7930f30 100644 --- a/docs/guide/variables.md +++ b/docs/guide/variables.md @@ -1,18 +1,12 @@ # Variables and expressions -Anywhere QProgram accepts a number, it also accepts a `Variable` or an -`Expression`. This is how a sweep works: a `Sweep` binds one variable to a -`SweepSource`, the runtime writes a value into that variable on each iteration, -and every expression built on top of it re-evaluates against the new binding. +Anywhere QProgram accepts a number, it also accepts a `Variable` or an `Expression`. This is how a sweep works: a `Sweep` binds one variable to a `SweepSource`, the runtime writes a value into that variable on each iteration, and every expression built on top of it re-evaluates against the new binding. -Building an expression is pure data construction. `100 + t` allocates an AST -node and does nothing else; no arithmetic runs until something calls -`evaluate()` on it. +Building an expression is pure data construction. `100 + t` allocates an AST node and does nothing else; no arithmetic runs until something calls `evaluate()` on it. ## Declaring variables -`QProgram.variable(id, *, label=None, units=None, description=None)` returns -the `Variable` and records it on the program: +`QProgram.variable(id, *, label=None, units=None, description=None)` returns the `Variable` and records it on the program: ```python import qprogram as qp @@ -23,38 +17,17 @@ dur = program.variable("dur", units="ns") amp = program.variable("amp") ``` -`id` is positional and required. The other three are keyword-only and default -to `None`. Each call appends to `program.variables`, which reports them in -declaration order, and a repeated id raises `ValidationError` with -`Variable 'freq' is already declared on this QProgram`. - -Two other calls declare variables on your behalf. `QProgram.get_parameter` -returns a fresh variable for the value the runtime reads, with an id derived -from `f"{bus}_{parameter}"` (non-word characters replaced by underscores, a -numeric suffix on collision) and the original `bus.parameter` string kept as -the label. `Fragment.variable` declares a fragment-local variable, renamed onto -the host program as `{fragment}_{id}` when the call is expanded; see -[Fragments](fragments.md). - -`qp.Variable("freq")` builds the same object directly and works fine as an -expression leaf, but it belongs to no program. The `.qp` writer emits one `var` -line per entry in `program.variables` and resolves every referenced variable -through that table, so serializing a program that reaches an undeclared -variable fails with a `KeyError` on the id rather than a `SerializationError`. -Declare through `program.variable`. +`id` is positional and required. The other three are keyword-only and default to `None`. Each call appends to `program.variables`, which reports them in declaration order, and a repeated id raises `ValidationError` with `Variable 'freq' is already declared on this QProgram`. + +Two other calls declare variables on your behalf. `QProgram.get_parameter` returns a fresh variable for the value the runtime reads, with an id derived from `f"{bus}_{parameter}"` (non-word characters replaced by underscores, a numeric suffix on collision) and the original `bus.parameter` string kept as the label. `Fragment.variable` declares a fragment-local variable, renamed onto the host program as `{fragment}_{id}` when the call is expanded; see [Fragments](fragments.md). + +`qp.Variable("freq")` builds the same object directly and works fine as an expression leaf, but it belongs to no program. The `.qp` writer emits one `var` line per entry in `program.variables` and resolves every referenced variable through that table, so serializing a program that reaches an undeclared variable fails with a `KeyError` on the id rather than a `SerializationError`. Declare through `program.variable`. ### Identifier rules -The id is written verbatim as the identifier in `.qp` files (`for freq in -Range(start=...)`, `get_parameter "drive_q0" "lo_frequency" -> lo_freq`), so it -has to be safe to embed unquoted. Three rules apply: the id matches -`[A-Za-z_][A-Za-z0-9_]*`, it is unique within one `QProgram`, and it is not one -of the [reserved keywords](../reference/reserved.md). +The id is written verbatim as the identifier in `.qp` files (`for freq in Range(start=...)`, `get_parameter "drive_q0" "lo_frequency" -> lo_freq`), so it has to be safe to embed unquoted. Three rules apply: the id matches `[A-Za-z_][A-Za-z0-9_]*`, it is unique within one `QProgram`, and it is not one of the [reserved keywords](../reference/reserved.md). -The pattern and reserved rules both raise -[`InvalidVariableIdError`](../reference/errors.md#invalidvariableiderror), -which also subclasses `ValueError`. Its `reserved` attribute says which rule -tripped, and the message differs accordingly: +The pattern and reserved rules both raise [`InvalidVariableIdError`](../reference/errors.md#invalidvariableiderror), which also subclasses `ValueError`. Its `reserved` attribute says which rule tripped, and the message differs accordingly: ```python try: @@ -72,12 +45,7 @@ except qp.InvalidVariableIdError as e: # "Variable id 'where' is reserved for future QProgram syntax ..." ``` -The keyword list is reserved against syntax the `.qp` format may grow: a -`Variable("if")` becomes ambiguous the moment the format has an `if` block, so -the id is rejected now to keep files that parse today parsing later. -Reservations are case-sensitive, which makes `If`, `Where`, and `True` valid -ids. Duplicate ids are the third rule and raise `ValidationError`, not -`InvalidVariableIdError`. +The keyword list is reserved against syntax the `.qp` format may grow: a `Variable("if")` becomes ambiguous the moment the format has an `if` block, so the id is rejected now to keep files that parse today parsing later. Reservations are case-sensitive, which makes `If`, `Where`, and `True` valid ids. Duplicate ids are the third rule and raise `ValidationError`, not `InvalidVariableIdError`. Anything richer than an identifier belongs in `label` and `description`: @@ -92,19 +60,9 @@ phi = program.variable( ### label, units, and description -All three are free-form strings and none of them affects execution. They are -written into the `var` line of a `.qp` file, parsed back from it, and carried -across fragment expansion when a fragment-local variable is renamed onto the -host program. `label` and `units` travel one step further: the executor writes -them onto the swept coordinate of every result array, as the `long_name` and -`units` attributes that xarray's own plotting reads. Validation and the -capability token vocabulary ignore all three, and nothing reads `description`. +All three are free-form strings and none of them affects execution. They are written into the `var` line of a `.qp` file, parsed back from it, and carried across fragment expansion when a fragment-local variable is renamed onto the host program. `label` and `units` travel one step further: the executor writes them onto the swept coordinate of every result array, as the `long_name` and `units` attributes that xarray's own plotting reads. Validation and the capability token vocabulary ignore all three, and nothing reads `description`. -That means `units="ns"` records what the numbers mean and converts nothing. A -variable swept over `Range(0, 200, 4)` and passed to `wait` carries -nanoseconds because `wait` takes nanoseconds, not because the variable says so. -A program declaring one annotated variable and one bare one serializes like -this, and the file round-trips back to a program equal to the original: +That means `units="ns"` records what the numbers mean and converts nothing. A variable swept over `Range(0, 200, 4)` and passed to `wait` carries nanoseconds because `wait` takes nanoseconds, not because the variable says so. A program declaring one annotated variable and one bare one serializes like this, and the file round-trips back to a program equal to the original: ``` #!QProgram 0.2 @@ -124,16 +82,11 @@ body: measure "readout_q0" "readout" "weights" name="m0" ``` -Only the attributes a variable actually carries are emitted, so `amp` declares -as a bare `var amp`. See -[Variable declarations](../reference/qp-format.md#variable-declarations) for -the quoting and escaping rules. +Only the attributes a variable actually carries are emitted, so `amp` declares as a bare `var amp`. See [Variable declarations](../reference/qp-format.md#variable-declarations) for the quoting and escaping rules. ## The current value -Each variable holds one value, and `evaluate()` reads it from the instance. -Before anything binds it, that value is the `UNASSIGNED` sentinel, a falsy -singleton whose `repr` is `UNASSIGNED`: +Each variable holds one value, and `evaluate()` reads it from the instance. Before anything binds it, that value is the `UNASSIGNED` sentinel, a falsy singleton whose `repr` is `UNASSIGNED`: ```python freq.value # UNASSIGNED @@ -143,17 +96,9 @@ freq.reset() freq.value # UNASSIGNED ``` -`set_value` stores whatever number you give it with no checking. The executor -calls it twice over: once per loop iteration for the variable a `Sweep` binds, -and once per `get_parameter` for the value read back from the platform. The -sweep path coerces to `float`, so a variable swept over an integer `Range` -reads back as a float; the `get_parameter` path passes the platform's parameter -store value through unchanged, and reads `0.0` for a key the store does not -hold. +`set_value` stores whatever number you give it with no checking. The executor calls it twice over: once per loop iteration for the variable a `Sweep` binds, and once per `get_parameter` for the value read back from the platform. The sweep path coerces to `float`, so a variable swept over an integer `Range` reads back as a float; the `get_parameter` path passes the platform's parameter store value through unchanged, and reads `0.0` for a key the store does not hold. -Nothing in the package calls `reset()`. A variable therefore keeps the last -value bound to it after a run finishes, which matters whenever you read a -variable back after execution: +Nothing in the package calls `reset()`. A variable therefore keeps the last value bound to it after a run finishes, which matters whenever you read a variable back after execution: ```python delay = program.variable("delay", units="ns") @@ -166,14 +111,11 @@ result = qp.simulate(program) delay.value # 20.0, the last value the sweep bound ``` -Calling `set_value` from your own code is worth doing when you want to evaluate -an expression or render a waveform in plain Python for plotting or debugging. +Calling `set_value` from your own code is worth doing when you want to evaluate an expression or render a waveform in plain Python for plotting or debugging. ## Expression nodes -Ten concrete node types make up the AST. Every one of them is an `Expression`, -carries the same `evaluate()` and `variables()` methods, and has a `.qp` form -the writer and parser agree on. +Ten concrete node types make up the AST. Every one of them is an `Expression`, carries the same `evaluate()` and `variables()` methods, and has a `.qp` form the writer and parser agree on. | Node | Built by | `.qp` form | |---|---|---| @@ -188,18 +130,11 @@ the writer and parser agree on. | `MathFunc` | `qp.sin` and friends, and `abs()` | `sin(a)` | | `Where` | `qp.where` | `where(c, a, b)` | -`Variable` and `MeasurementRef` are the two bindings the runtime writes to. -`MeasurementRef` points at a field of a measurement result and exists so that a -conditional can branch on a classified state; `"state"` is the only field it -accepts, because that is the only one a branch can test. You normally get one -from the `handle.state` proxy rather than constructing it, and -[Control flow](control-flow.md) covers that side of it. +`Variable` and `MeasurementRef` are the two bindings the runtime writes to. `MeasurementRef` points at a field of a measurement result and exists so that a conditional can branch on a classified state; `"state"` is the only field it accepts, because that is the only one a branch can test. You normally get one from the `handle.state` proxy rather than constructing it, and [Control flow](control-flow.md) covers that side of it. ### Arithmetic -The four binary operators and both unary signs work on any expression, in -either operand order. A numeric literal on the other side is wrapped as a -`Constant`: +The four binary operators and both unary signs work on any expression, in either operand order. A numeric literal on the other side is wrapped as a `Constant`: ```python t = program.variable("t") @@ -211,27 +146,15 @@ amp * 2 # BinaryOp("*", amp, Constant(2)) -amp # UnaryOp("-", amp) ``` -`bool` is rejected everywhere a number is wrapped, because `True` would coerce -silently to `1` and hide the mistake: `qp.Constant(True)` raises `TypeError` -with `Constant value must be int or float, got bool`. Any other non-numeric -operand raises `Cannot use str in an Expression; expected Expression, -handle., int, or float`. +`bool` is rejected everywhere a number is wrapped, because `True` would coerce silently to `1` and hide the mistake: `qp.Constant(True)` raises `TypeError` with `Constant value must be int or float, got bool`. Any other non-numeric operand raises `Cannot use str in an Expression; expected Expression, handle., int, or float`. -`**`, `%`, and `//` are not overloaded and raise the ordinary Python -`unsupported operand type(s)` error, since the `.qp` format has no form for -them. Write `t * t` for a square, and reach for `qp.exp` and `qp.log` for -anything else. +`**`, `%`, and `//` are not overloaded and raise the ordinary Python `unsupported operand type(s)` error, since the `.qp` format has no form for them. Write `t * t` for a square, and reach for `qp.exp` and `qp.log` for anything else. ### Comparisons and logical combination -`<`, `<=`, `>`, and `>=` build `Comparison` nodes. `==` and `!=` do not: -`Variable.__eq__` compares ids and has to keep returning a plain `bool`, or -variables could not live in the sets `variables()` returns, or serve as dict -keys. Use `qp.eq` and `qp.ne` for the expression-building form. +`<`, `<=`, `>`, and `>=` build `Comparison` nodes. `==` and `!=` do not: `Variable.__eq__` compares ids and has to keep returning a plain `bool`, or variables could not live in the sets `variables()` returns, or serve as dict keys. Use `qp.eq` and `qp.ne` for the expression-building form. -`and`, `or`, and `not` are Python keywords and cannot be overloaded at all, so -the NumPy and SymPy convention applies: `&`, `|`, and `~`, or the named helpers -`qp.and_`, `qp.or_`, and `qp.not_`. +`and`, `or`, and `not` are Python keywords and cannot be overloaded at all, so the NumPy and SymPy convention applies: `&`, `|`, and `~`, or the named helpers `qp.and_`, `qp.or_`, and `qp.not_`. ```python amp < 0.5 # Comparison("<", amp, Constant(0.5)) @@ -242,13 +165,9 @@ qp.or_(amp < 0.0, amp > 1.0) # LogicalBinaryOp("or", ...) ~(amp < 0.5) # LogicalNot(...) ``` -The parentheses in `(amp < 0.5) & (t > 100)` are not optional. `&` and `|` bind -tighter than the comparison operators in Python, so `amp < 0.5 & t > 100` -parses as `amp < (0.5 & t) > 100` and fails on the wrong thing. +The parentheses in `(amp < 0.5) & (t > 100)` are not optional. `&` and `|` bind tighter than the comparison operators in Python, so `amp < 0.5 & t > 100` parses as `amp < (0.5 & t) > 100` and fails on the wrong thing. -Logical operands must already be expressions; numbers are not coerced, because -a logical operand is a condition rather than a value. Passing a bare `bool` -gets a message that names the usual cause: +Logical operands must already be expressions; numbers are not coerced, because a logical operand is a condition rather than a value. Passing a bare `bool` gets a message that names the usual cause: ```python qp.and_(amp < 0.5, True) @@ -258,14 +177,9 @@ qp.and_(amp < 0.5, True) # `==` returns a plain bool, not a Comparison. ``` -`LogicalBinaryOp` never short-circuits. Both operands are always evaluated, so -an unbound variable in either half propagates `UNASSIGNED` regardless of which -side it sits on, and an unbound-variable diagnostic does not depend on operand -order. +`LogicalBinaryOp` never short-circuits. Both operands are always evaluated, so an unbound variable in either half propagates `UNASSIGNED` regardless of which side it sits on, and an unbound-variable diagnostic does not depend on operand order. -Comparisons and logical nodes are data, not booleans, and `Expression` blocks -the accident that would otherwise follow. `__bool__` raises rather than -reporting that a `Comparison` instance is truthy: +Comparisons and logical nodes are data, not booleans, and `Expression` blocks the accident that would otherwise follow. `__bool__` raises rather than reporting that a `Comparison` instance is truthy: ```python if amp < 0.5: # TypeError @@ -275,17 +189,11 @@ if amp < 0.5: # TypeError # qprogram.where(cond, then, else_) to build a conditional expression. ``` -The same guard is why `min(a, b)` on two variables raises, and why `qp.minimum` -exists. +The same guard is why `min(a, b)` on two variables raises, and why `qp.minimum` exists. ### Math functions and `where` -Nine math functions build `MathFunc` nodes. `qp.sin`, `qp.cos`, `qp.tan`, -`qp.exp`, `qp.log`, and `qp.sqrt` take one operand; `qp.minimum` and -`qp.maximum` take two or more and raise `TypeError` with -`minimum() requires at least two arguments` below that; and the built-in -`abs()` produces `MathFunc("abs", ...)` through `__abs__`. The `.qp` name of -each node is its `MathFunc.name`, which is the helper's own name. +Nine math functions build `MathFunc` nodes. `qp.sin`, `qp.cos`, `qp.tan`, `qp.exp`, `qp.log`, and `qp.sqrt` take one operand; `qp.minimum` and `qp.maximum` take two or more and raise `TypeError` with `minimum() requires at least two arguments` below that; and the built-in `abs()` produces `MathFunc("abs", ...)` through `__abs__`. The `.qp` name of each node is its `MathFunc.name`, which is the helper's own name. ```python qp.sin(freq * 2 * 3.14159) @@ -296,17 +204,9 @@ qp.minimum(amp, 0.5) qp.maximum(amp, 0.0, 1.0) ``` -Evaluation runs through NumPy, imported lazily so that building an expression -does not pay for it. The transcendental functions return Python floats, while -`abs`, `minimum`, and `maximum` preserve an integer input. They also inherit -NumPy's out-of-domain behavior instead of raising: `qp.log(0).evaluate()` -returns `-inf` with a `RuntimeWarning`, and `qp.sqrt(-1).evaluate()` returns -`nan`. +Evaluation runs through NumPy, imported lazily so that building an expression does not pay for it. The transcendental functions return Python floats, while `abs`, `minimum`, and `maximum` preserve an integer input. They also inherit NumPy's out-of-domain behavior instead of raising: `qp.log(0).evaluate()` returns `-inf` with a `RuntimeWarning`, and `qp.sqrt(-1).evaluate()` returns `nan`. -`qp.where(condition, then, else_)` is the ternary. The condition has to be an -expression, while the two branches accept numbers and get wrapped. Only the -chosen branch is evaluated, so the branch not taken may reference a variable -that happens to be unbound: +`qp.where(condition, then, else_)` is the ternary. The condition has to be an expression, while the two branches accept numbers and get wrapped. Only the chosen branch is evaluated, so the branch not taken may reference a variable that happens to be unbound: ```python qp.where(t > 100, amp, 0.0) @@ -319,8 +219,7 @@ used.set_value(42) qp.where(qp.eq(cond, 1), used, unused).evaluate() # 42 ``` -An unbound condition still makes the whole node `UNASSIGNED`, and -`variables()` reports all three subtrees including the branch evaluation skips. +An unbound condition still makes the whole node `UNASSIGNED`, and `variables()` reports all three subtrees including the branch evaluation skips. ## Where expressions are accepted @@ -338,18 +237,11 @@ program.play( ) ``` -Waveform constructors accept an expression on every numeric parameter, and -`Waveform.envelope()` resolves it when it renders the samples; see -[Waveforms](waveforms.md). The `.qp` format is narrower than the AST here. A -constructor argument may be a number, a quoted string, or a bare variable -reference, and the parser rejects anything else, so `Gaussian(amplitude=amp)` -round-trips but `Gaussian(amplitude=amp / 2)` writes without complaint and then -fails to parse back. [Errors](../reference/errors.md) has the detail. +Waveform constructors accept an expression on every numeric parameter, and `Waveform.envelope()` resolves it when it renders the samples; see [Waveforms](waveforms.md). The `.qp` format is narrower than the AST here. A constructor argument may be a number, a quoted string, or a bare variable reference, and the parser rejects anything else, so `Gaussian(amplitude=amp)` round-trips but `Gaussian(amplitude=amp / 2)` writes without complaint and then fails to parse back. [Errors](../reference/errors.md) has the detail. ## Evaluating an expression -`evaluate()` walks the tree, reads each variable's current value, and returns a -number: +`evaluate()` walks the tree, reads each variable's current value, and returns a number: ```python expr = freq * 2 + 100 @@ -362,20 +254,13 @@ freq.reset() expr.evaluate() # UNASSIGNED ``` -`UNASSIGNED` propagates: any unbound variable anywhere in the tree makes the -whole expression `UNASSIGNED`, and it takes precedence over an arithmetic -failure that a bound value would have hit. `(t / 0).evaluate()` raises -`ZeroDivisionError` once `t` is bound and returns `UNASSIGNED` while it is not. +`UNASSIGNED` propagates: any unbound variable anywhere in the tree makes the whole expression `UNASSIGNED`, and it takes precedence over an arithmetic failure that a bound value would have hit. `(t / 0).evaluate()` raises `ZeroDivisionError` once `t` is bound and returns `UNASSIGNED` while it is not. -Comparisons and logical nodes evaluate to a `bool`, which is an `int` subclass, -so a comparison used as a numeric operand behaves as `0` or `1`. +Comparisons and logical nodes evaluate to a `bool`, which is an `int` subclass, so a comparison used as a numeric operand behaves as `0` or `1`. ### evaluate_or_raise -`evaluate_or_raise()` returns the number or raises -[`UnassignedVariableError`](../reference/errors.md#unassignedvariableerror) -instead of handing back the sentinel. Use it where the caller has no way to -carry on without a value: +`evaluate_or_raise()` returns the number or raises [`UnassignedVariableError`](../reference/errors.md#unassignedvariableerror) instead of handing back the sentinel. Use it where the caller has no way to carry on without a value: ```python expr.evaluate_or_raise() # raises while freq is unbound @@ -390,18 +275,13 @@ except qp.UnassignedVariableError as e: print(e.free_variables) # {Variable('amp'), Variable('freq')} ``` -`free_variables` is `expression.variables()`, collected when the error is -constructed. It is every variable the tree references, not only the unbound -ones, so a partly bound expression reports all of them. +`free_variables` is `expression.variables()`, collected when the error is constructed. It is every variable the tree references, not only the unbound ones, so a partly bound expression reports all of them. -Waveforms call `evaluate_or_raise` on each parameter internally, which is why -`qp.waveforms.Gaussian(amplitude=amp, duration=40, sigma=8).envelope()` raises -until `amp` is bound. +Waveforms call `evaluate_or_raise` on each parameter internally, which is why `qp.waveforms.Gaussian(amplitude=amp, duration=40, sigma=8).envelope()` raises until `amp` is bound. ## Free variables -`expression.variables()` returns the set of variables the tree references. -Every node unions its children's sets, so one call covers the whole subtree: +`expression.variables()` returns the set of variables the tree references. Every node unions its children's sets, so one call covers the whole subtree: ```python (freq + 100).variables() # {freq} @@ -409,23 +289,13 @@ Every node unions its children's sets, so one call covers the whole subtree: qp.Constant(5).variables() # set() ``` -`Operation.variables()` and `Block.variables()` are built on this: they walk -public attributes, descend into expressions, waveform parameters, and nested -lists, and union what they find. That is how the compiler works out which -variables an operation depends on. `Sweep.variables()` adds the variable it -binds to whatever its body reports, and `Parallel.variables()` unions in each -composed loop's own, because those loop headers sit outside the shared body the -inherited walk covers. +`Operation.variables()` and `Block.variables()` are built on this: they walk public attributes, descend into expressions, waveform parameters, and nested lists, and union what they find. That is how the compiler works out which variables an operation depends on. `Sweep.variables()` adds the variable it binds to whatever its body reports, and `Parallel.variables()` unions in each composed loop's own, because those loop headers sit outside the shared body the inherited walk covers. -`MeasurementRef.variables()` returns an empty set. A measurement reference is a -different kind of binding, written by the runtime when the measurement produces -a result rather than by a loop, so it takes no part in the variable walk. +`MeasurementRef.variables()` returns an empty set. A measurement reference is a different kind of binding, written by the runtime when the measurement produces a result rather than by a loop, so it takes no part in the variable walk. ## Capability tokens -An operation reports the capabilities it needs from `required_capabilities()`, -and an operation that carries an expression adds one `expr.*` token per node -kind in it. `qp.protocol.expression_tokens` does the recursion: +An operation reports the capabilities it needs from `required_capabilities()`, and an operation that carries an expression adds one `expr.*` token per node kind in it. `qp.protocol.expression_tokens` does the recursion: ```python op = qp.operations.Wait(bus="drive_q0", duration=100 + t * 2) @@ -433,29 +303,15 @@ sorted(op.required_capabilities()) # ['expr.binary_op', 'expr.constant', 'expr.variable', 'op.wait'] ``` -The node kinds map to `expr.constant`, `expr.variable`, -`expr.measurement_ref`, `expr.binary_op`, `expr.unary_op`, `expr.comparison`, -`expr.logical_and_or`, `expr.logical_not`, `expr.where`, and one -`expr.math.` per math function. A plain numeric literal contributes -nothing, because it is not a node until it is wrapped. - -The operations that contribute expression tokens are `wait`, `set_frequency`, -`set_phase`, `set_gain`, `set_offset`, and `set_parameter`, each on its numeric -arguments, plus `Conditional` on each arm's condition. `Play` does not. Its -tokens describe the waveform (`waveform.single`, `waveform.gaussian`, and so -on), so an expression inside a waveform parameter contributes no `expr.*` token -even though `Play.variables()` still finds the variable in it. - -`expr.*` tokens are checked against the platform's own capability set rather -than the slot the operation routes to, since they describe which node kinds the -platform's compiler can lower, not what any one instrument can do. A missing -token becomes a `missing-capability` diagnostic naming the token and the -profile that lacks it. [Capabilities](capabilities.md) covers the routing. +The node kinds map to `expr.constant`, `expr.variable`, `expr.measurement_ref`, `expr.binary_op`, `expr.unary_op`, `expr.comparison`, `expr.logical_and_or`, `expr.logical_not`, `expr.where`, and one `expr.math.` per math function. A plain numeric literal contributes nothing, because it is not a node until it is wrapped. + +The operations that contribute expression tokens are `wait`, `set_frequency`, `set_phase`, `set_gain`, `set_offset`, and `set_parameter`, each on its numeric arguments, plus `Conditional` on each arm's condition. `Play` does not. Its tokens describe the waveform (`waveform.single`, `waveform.gaussian`, and so on), so an expression inside a waveform parameter contributes no `expr.*` token even though `Play.variables()` still finds the variable in it. + +`expr.*` tokens are checked against the platform's own capability set rather than the slot the operation routes to, since they describe which node kinds the platform's compiler can lower, not what any one instrument can do. A missing token becomes a `missing-capability` diagnostic naming the token and the profile that lacks it. [Capabilities](capabilities.md) covers the routing. ## Equality and identity -Expression nodes compare structurally, with one twist: variables compare by -`id`. +Expression nodes compare structurally, with one twist: variables compare by `id`. ```python v1 = qp.Variable("freq") @@ -465,16 +321,9 @@ v1 is v2 # False hash(v1) == hash(v2) # True ``` -`hash` is over `("Variable", id)`, so two variables with the same id collapse -to one entry in the set `variables()` returns. That is what makes a whole -program survive `deepcopy` and a `qp.loads(qp.dumps(...))` round-trip and still -compare equal: after a round-trip the original Python objects are gone, and the -ids are all that is left to match on. `QProgram` itself defines no `__eq__`, so -the comparison to make is `reloaded.body == program.body`. +`hash` is over `("Variable", id)`, so two variables with the same id collapse to one entry in the set `variables()` returns. That is what makes a whole program survive `deepcopy` and a `qp.loads(qp.dumps(...))` round-trip and still compare equal: after a round-trip the original Python objects are gone, and the ids are all that is left to match on. `QProgram` itself defines no `__eq__`, so the comparison to make is `reloaded.body == program.body`. -Everything else is plain structural equality over the fields the node holds. -`MeasurementRef` compares by `(handle.name, field)` for the same -survive-the-round-trip reason. +Everything else is plain structural equality over the fields the node holds. `MeasurementRef` compares by `(handle.name, field)` for the same survive-the-round-trip reason. ```python qp.Constant(5) == qp.Constant(5) # True @@ -491,5 +340,4 @@ qp.sin(freq) == qp.cos(freq) # False | Evaluate, fail if anything is unbound | `expression.evaluate_or_raise()` | | Get a number from an `int \| float \| Expression` argument | `x.evaluate_or_raise() if isinstance(x, qp.Expression) else x` | -Most user code calls none of these. They are what the platform, the serializer, -and the test suite reach for; the code that builds a program only builds nodes. +Most user code calls none of these. They are what the platform, the serializer, and the test suite reach for; the code that builds a program only builds nodes. diff --git a/docs/guide/waveforms.md b/docs/guide/waveforms.md index a28ab17..c8bb5c3 100644 --- a/docs/guide/waveforms.md +++ b/docs/guide/waveforms.md @@ -1,12 +1,8 @@ # Waveforms -A waveform describes a pulse envelope. It is pure data; nothing about it -involves hardware. The same `Gaussian(0.5, 40, 8)` shape can be a flux pulse -on one platform and a charge-line pulse on another, depending on the bus it -ends up on. +A waveform describes a pulse envelope. It is pure data; nothing about it involves hardware. The same `Gaussian(0.5, 40, 8)` shape can be a flux pulse on one platform and a charge-line pulse on another, depending on the bus it ends up on. -Waveforms are the one vocabulary in the package with no top-level re-export, -so they are always reached through the submodule: +Waveforms are the one vocabulary in the package with no top-level re-export, so they are always reached through the submodule: ```python import qprogram as qp @@ -14,27 +10,17 @@ import qprogram as qp pulse = qp.waveforms.Gaussian(amplitude=0.5, duration=40, sigma=8) ``` -The one thing a bus cares about is the channel count: a single-channel bus -takes a `Waveform`, an IQ bus takes an `IQWaveform`. `play()` and `measure()` -check this, but only when the bus is a schema-bound `BusRef`, since a raw -string bus carries no channel metadata to check against. Playing a `Gaussian` -on the IQ `drive` bus of a transmon schema raises: +The one thing a bus cares about is the channel count: a single-channel bus takes a `Waveform`, an IQ bus takes an `IQWaveform`. `play()` and `measure()` check this, but only when the bus is a schema-bound `BusRef`, since a raw string bus carries no channel metadata to check against. Playing a `Gaussian` on the IQ `drive` bus of a transmon schema raises: ``` ValidationError: Bus 'q0/drive' is an IQ channel but received a single-channel Waveform (Gaussian). Use an IQWaveform (e.g. IQPair, IQDrag) instead. ``` -The mirror case, an `IQDrag` on the single-channel `flux` bus, names `Square` -and `FlatTop` as the shapes that belong there. Both checks run again inside -`with_waveforms()`, when a string alias is replaced by a concrete shape, so a -mismatch introduced by calibration data is caught at substitution rather than -in the platform compiler. `measure()` is narrower still: both its `waveform` -and its `weights` must be `IQWaveform`s or aliases. +The mirror case, an `IQDrag` on the single-channel `flux` bus, names `Square` and `FlatTop` as the shapes that belong there. Both checks run again inside `with_waveforms()`, when a string alias is replaced by a concrete shape, so a mismatch introduced by calibration data is caught at substitution rather than in the platform compiler. `measure()` is narrower still: both its `waveform` and its `weights` must be `IQWaveform`s or aliases. ## Two base classes -`Waveform` is the base for single-channel (real) shapes. Two methods are -abstract, so every subclass supplies them: +`Waveform` is the base for single-channel (real) shapes. Two methods are abstract, so every subclass supplies them: | Method | What it returns | |--------------------------|----------------------------------------------------------------| @@ -49,8 +35,7 @@ abstract, so every subclass supplies them: | `get_Q()` | the quadrature `Waveform` | | `get_duration()` | the duration in nanoseconds, as an `int` | -Both bases derive the same measures from those, so any shape, built-in or your -own, answers them without extra code: +Both bases derive the same measures from those, so any shape, built-in or your own, answers them without extra code: | Method | What it returns | |--------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| @@ -60,19 +45,15 @@ own, answers them without extra code: | `spectrum(resolution=1)` | a `(frequencies_hz, complex_spectrum)` pair. Real shapes use `numpy.fft.rfft`, so 64 samples at `resolution=1` give 33 one-sided bins up to 500 MHz; IQ shapes use a `fftshift`ed two-sided `numpy.fft.fft`. | | `plot(resolution=1, ...)` | whatever the renderer returns, which for matplotlib is an `Axes`, or an `(I, Q)` pair of them for an IQ shape. | -`envelope()` resolves every symbolic parameter before it samples anything, by -calling `Expression.evaluate_or_raise()` on it. A variable with no value -therefore fails at the point of use rather than somewhere inside numpy: +`envelope()` resolves every symbolic parameter before it samples anything, by calling `Expression.evaluate_or_raise()` on it. A variable with no value therefore fails at the point of use rather than somewhere inside numpy: ``` UnassignedVariableError: Cannot evaluate expression Variable('amp'): unassigned variable(s) {Variable('amp')} ``` -Every measure above is computed from `envelope()`, so they all raise the same -error under the same conditions. +Every measure above is computed from `envelope()`, so they all raise the same error under the same conditions. -`plot()` takes the same three arguments `result.plot` takes, and means the same -things by them: +`plot()` takes the same three arguments `result.plot` takes, and means the same things by them: @@ -81,18 +62,9 @@ Waveform.plot(resolution=1, *, style=None, renderer=None, target=None) IQWaveform.plot(resolution=1, *, style=None, renderer=None, target=None) ``` -The envelope is described as a `qp.plotting.Figure` and handed to a renderer, -which for the default matplotlib one returns the `Axes` it drew on. An IQ shape -draws two panels stacked on a shared x axis, labeled `I` and `Q` and in the -theme's first two colours, and returns the two handles as an `(I, Q)` pair. -matplotlib is imported when it is first drawn with, from the `qprogram[viz]` -extra, which keeps the rest of the package importable without it; without -matplotlib installed the call raises `ModuleNotFoundError`. -[Plotting results](plotting.md) is the walkthrough for all three arguments; -what follows is what a waveform does differently. +The envelope is described as a `qp.plotting.Figure` and handed to a renderer, which for the default matplotlib one returns the `Axes` it drew on. An IQ shape draws two panels stacked on a shared x axis, labeled `I` and `Q` and in the theme's first two colours, and returns the two handles as an `(I, Q)` pair. matplotlib is imported when it is first drawn with, from the `qprogram[viz]` extra, which keeps the rest of the package importable without it; without matplotlib installed the call raises `ModuleNotFoundError`. [Plotting results](plotting.md) is the walkthrough for all three arguments; what follows is what a waveform does differently. -The style defaults to a plain `Style()`, the same as a result's, so a pulse and -the sweep it produced sit on one palette instead of looking like two libraries: +The style defaults to a plain `Style()`, the same as a result's, so a pulse and the sweep it produced sit on one palette instead of looking like two libraries: ```python import qprogram as qp @@ -101,15 +73,9 @@ pi_pulse = qp.waveforms.Gaussian(amplitude=0.5, duration=40, sigma=8) pi_pulse.plot(style=qp.plotting.Style(theme=qp.plotting.DARK)) ``` -The one thing a `Style` does not carry by default is a figure size, which is -what lets the same one suit a measurement and a pulse. A style that names none -is drawn at `qp.plotting.ENVELOPE_SIZE`, six inches by two, or -`qp.plotting.IQ_ENVELOPE_SIZE` for the stacked pair, which is an inch taller; -`Style(size=(4, 1.5))` overrides that, and a `target` you pass in keeps whatever -size its figure already has. +The one thing a `Style` does not carry by default is a figure size, which is what lets the same one suit a measurement and a pulse. A style that names none is drawn at `qp.plotting.ENVELOPE_SIZE`, six inches by two, or `qp.plotting.IQ_ENVELOPE_SIZE` for the stacked pair, which is an inch taller; `Style(size=(4, 1.5))` overrides that, and a `target` you pass in keeps whatever size its figure already has. -`target` is one surface for a single-channel shape and an `(I, Q)` pair for an -IQ one, which is how a pulse composes into a layout of your own: +`target` is one surface for a single-channel shape and an `(I, Q)` pair for an IQ one, which is how a pulse composes into a layout of your own: ```python import matplotlib.pyplot as plt @@ -120,30 +86,15 @@ fig, (top, bottom) = plt.subplots(2, 1, sharex=True) drag.plot(target=(top, bottom)) ``` -That pair is also the one place a waveform asks for more than a result does. Two -panels sharing a scale is a matplotlib layout rather than anything the figure -describes, and it is the only pair the package knows how to build, so a -registered `renderer` other than the built-in one raises `ValidationError` when -it is asked for with no `target`: the panels it would otherwise be handed are -matplotlib's. - -Both bases also define `_repr_html_`, so a bare waveform renders in a Jupyter -cell without an explicit `plot()`. It draws the envelope once per surface and -returns a `` holding both, with the dark one behind a -`prefers-color-scheme` source, so a cell in a dark notebook is not a white -rectangle. It draws with the default renderer, since what a cell wants is an -image. That reads the browser's setting, which is the editor's own theme in VS -Code and the operating system's under JupyterLab; where the two disagree, -`plot(style=...)` is how to say which surface you are on. - -Only the waveform itself takes that path. `wf.plot()` makes the axes the cell's -value instead, which a notebook shows as `` beside the figure, so -bind it or end the call with a semicolon. +That pair is also the one place a waveform asks for more than a result does. Two panels sharing a scale is a matplotlib layout rather than anything the figure describes, and it is the only pair the package knows how to build, so a registered `renderer` other than the built-in one raises `ValidationError` when it is asked for with no `target`: the panels it would otherwise be handed are matplotlib's. + +Both bases also define `_repr_html_`, so a bare waveform renders in a Jupyter cell without an explicit `plot()`. It draws the envelope once per surface and returns a `` holding both, with the dark one behind a `prefers-color-scheme` source, so a cell in a dark notebook is not a white rectangle. It draws with the default renderer, since what a cell wants is an image. That reads the browser's setting, which is the editor's own theme in VS Code and the operating system's under JupyterLab; where the two disagree, `plot(style=...)` is how to say which surface you are on. + +Only the waveform itself takes that path. `wf.plot()` makes the axes the cell's value instead, which a notebook shows as `` beside the figure, so bind it or end the call with a semicolon. ## Single-channel built-ins -Every duration and width below is in nanoseconds, every frequency is in hertz, -and every phase offset is in radians. +Every duration and width below is in nanoseconds, every frequency is in hertz, and every phase offset is in radians. | Constructor | Samples it renders | |-------------------------------------------------------------------|---------------------------------------------------------------------------| @@ -171,76 +122,21 @@ cz_pulse = qp.waveforms.SuddenNetZero(amplitude=0.5, duration=100, b=0.4, t_phi= measured = qp.waveforms.Arbitrary(samples=np.linspace(0.0, 1.0, 64)) ``` -`Square` renders with `numpy.full`, so its dtype follows its amplitude and an -integer amplitude yields an integer array. `Tukey` does the same in its two -untapered cases, and `Arbitrary` keeps whatever dtype its input had. Every -other shape computes in floating point. - -`Gaussian` is not truncation-corrected. The peak sits at the center of the -sample window, so an even sample count straddles it and the largest sample -falls slightly below `amplitude`: `Gaussian(0.5, 40, 8).envelope().max()` is -`0.4990`, not `0.5`. The tails are clipped wherever the window ends rather than -forced to zero, so the same shape starts and ends at `0.0513 * amplitude`. -Widening the -window to `duration=60` at the same `sigma` adds tail without changing the -pulse. `GaussianDragCorrection` inherits all three parameters and differs only -in the envelope: it is antisymmetric about the center, where it crosses zero. -Its derivative is taken with respect to sample index rather than time, so its -amplitude scales with `resolution` while the Gaussian's does not. - -`Tukey` splits `alpha` between the two edges: the flat top is -`(1 - alpha) * duration` wide and each cosine ramp is -`(alpha / 2) * duration`. `alpha=0` is a rectangle and `alpha=1` is a Hann -window that reaches zero at both endpoints; the default is `0.5`. It matches -the `alpha` of `scipy.signal.windows.tukey`, and needs no `erf` evaluation, -which is the reason to prefer it to `FlatTop` when either edge shape will do. - -`FlatTop` builds each edge from an error function of width -`smooth_duration / 3`, and multiplies the rising and falling edges together -rather than splicing them, which keeps the envelope smooth when the two -overlap. The rise crosses half amplitude `smooth_duration` ns into the pulse -and is flat to within a part in 10⁵ by twice that, so a `duration` that is not -comfortably longer than `2 * smooth_duration` never reaches full amplitude. -`buffer` adds zero padding on each side, on top of `duration` rather than -inside it: `FlatTop(0.5, 200, 20, buffer=10).get_duration()` is `220`. - -`Ramp` samples with `numpy.linspace`, so both endpoints are hit exactly once -the window holds at least two samples, and the step between samples is -`(to_amplitude - from_amplitude) / (n - 1)` rather than anything derived from -`resolution` alone. A one-sample window yields `from_amplitude` alone, a -window holding less than one sample yields an empty array, and a negative -duration raises -`ValueError: Number of samples, -5, must be non-negative.` from `linspace`. - -`SuddenNetZero` plays a positive square segment, a zero hold of width `t_phi`, -then a negative segment scaled by `b`. The two segments are meant to cancel -the net integrated flux, and the cancellation is exact only when `b` is 1 and -the samples left over after the hold divide evenly between the segments. The -positive segment takes `(duration - t_phi) // 2` samples and the negative one -takes the rest, so an odd remainder gives the negative segment the extra -sample: at `duration=101, t_phi=20, b=1` the envelope sums to `-amplitude` -instead of zero. In practice `b` is detuned from 1 to null whatever residual -the flux line adds, so the `b=0.4` above integrates to a non-zero area. - -`Sine` and `Cosine` express their sample times in seconds, which is what pairs -with a `frequency` in Hz: 200 ns at 50 MHz is ten cycles. Neither tapers to -zero at the endpoints, so pair one with a window shape when the discontinuity -matters. `Sech` is the analogue of `Gaussian` for adiabatic passage: paired -with a quadratic phase ramp it gives analytically solvable population -transfer. Its `tau` plays the role `sigma` plays for a Gaussian. - -`Arbitrary` and `Chained` are the escape hatches. `Arbitrary` takes a -sequence or an ndarray through `numpy.asarray`, so the stored dtype follows -the input, and an ndarray is adopted rather than copied: do not mutate an array -you have handed to it, because waveforms are compared and hashed by value. -`envelope()` returns a copy for the same reason, and ignores `resolution` -entirely, since the samples are already the envelope at one per nanosecond. -`Chained` passes whatever resolution it is asked for down to each child and -sums their durations. `a + b` builds a `Chained` too, flattening as it goes, so -`a + b + c` is one three-element chain rather than nested pairs; a non-waveform -operand yields `NotImplemented`, which surfaces as `TypeError`. An empty chain -reports `get_duration() == 0` but its `envelope()` raises -`ValueError: need at least one array to concatenate`. +`Square` renders with `numpy.full`, so its dtype follows its amplitude and an integer amplitude yields an integer array. `Tukey` does the same in its two untapered cases, and `Arbitrary` keeps whatever dtype its input had. Every other shape computes in floating point. + +`Gaussian` is not truncation-corrected. The peak sits at the center of the sample window, so an even sample count straddles it and the largest sample falls slightly below `amplitude`: `Gaussian(0.5, 40, 8).envelope().max()` is `0.4990`, not `0.5`. The tails are clipped wherever the window ends rather than forced to zero, so the same shape starts and ends at `0.0513 * amplitude`. Widening the window to `duration=60` at the same `sigma` adds tail without changing the pulse. `GaussianDragCorrection` inherits all three parameters and differs only in the envelope: it is antisymmetric about the center, where it crosses zero. Its derivative is taken with respect to sample index rather than time, so its amplitude scales with `resolution` while the Gaussian's does not. + +`Tukey` splits `alpha` between the two edges: the flat top is `(1 - alpha) * duration` wide and each cosine ramp is `(alpha / 2) * duration`. `alpha=0` is a rectangle and `alpha=1` is a Hann window that reaches zero at both endpoints; the default is `0.5`. It matches the `alpha` of `scipy.signal.windows.tukey`, and needs no `erf` evaluation, which is the reason to prefer it to `FlatTop` when either edge shape will do. + +`FlatTop` builds each edge from an error function of width `smooth_duration / 3`, and multiplies the rising and falling edges together rather than splicing them, which keeps the envelope smooth when the two overlap. The rise crosses half amplitude `smooth_duration` ns into the pulse and is flat to within a part in 10⁵ by twice that, so a `duration` that is not comfortably longer than `2 * smooth_duration` never reaches full amplitude. `buffer` adds zero padding on each side, on top of `duration` rather than inside it: `FlatTop(0.5, 200, 20, buffer=10).get_duration()` is `220`. + +`Ramp` samples with `numpy.linspace`, so both endpoints are hit exactly once the window holds at least two samples, and the step between samples is `(to_amplitude - from_amplitude) / (n - 1)` rather than anything derived from `resolution` alone. A one-sample window yields `from_amplitude` alone, a window holding less than one sample yields an empty array, and a negative duration raises `ValueError: Number of samples, -5, must be non-negative.` from `linspace`. + +`SuddenNetZero` plays a positive square segment, a zero hold of width `t_phi`, then a negative segment scaled by `b`. The two segments are meant to cancel the net integrated flux, and the cancellation is exact only when `b` is 1 and the samples left over after the hold divide evenly between the segments. The positive segment takes `(duration - t_phi) // 2` samples and the negative one takes the rest, so an odd remainder gives the negative segment the extra sample: at `duration=101, t_phi=20, b=1` the envelope sums to `-amplitude` instead of zero. In practice `b` is detuned from 1 to null whatever residual the flux line adds, so the `b=0.4` above integrates to a non-zero area. + +`Sine` and `Cosine` express their sample times in seconds, which is what pairs with a `frequency` in Hz: 200 ns at 50 MHz is ten cycles. Neither tapers to zero at the endpoints, so pair one with a window shape when the discontinuity matters. `Sech` is the analogue of `Gaussian` for adiabatic passage: paired with a quadratic phase ramp it gives analytically solvable population transfer. Its `tau` plays the role `sigma` plays for a Gaussian. + +`Arbitrary` and `Chained` are the escape hatches. `Arbitrary` takes a sequence or an ndarray through `numpy.asarray`, so the stored dtype follows the input, and an ndarray is adopted rather than copied: do not mutate an array you have handed to it, because waveforms are compared and hashed by value. `envelope()` returns a copy for the same reason, and ignores `resolution` entirely, since the samples are already the envelope at one per nanosecond. `Chained` passes whatever resolution it is asked for down to each child and sums their durations. `a + b` builds a `Chained` too, flattening as it goes, so `a + b + c` is one three-element chain rather than nested pairs; a non-waveform operand yields `NotImplemented`, which surfaces as `TypeError`. An empty chain reports `get_duration() == 0` but its `envelope()` raises `ValueError: need at least one array to concatenate`. ### What the shape parameters mean @@ -256,10 +152,7 @@ Every width is the real width of the shape, not a ratio against `duration`. | `SuddenNetZero.b` | The ratio of the negative segment's amplitude to the positive one's, normally near 1. | | `SuddenNetZero.t_phi` | The width of the zero hold between the two segments, in nanoseconds. | -`duration` is independent of the width: it is the window the shape is rendered -into, so it controls truncation and nothing else. That is what makes `sigma` -the knob a calibration sweep reaches for, since a Gaussian's rotation angle -goes with its area, roughly `amplitude * sigma * sqrt(2π)`. +`duration` is independent of the width: it is the window the shape is rendered into, so it controls truncation and nothing else. That is what makes `sigma` the knob a calibration sweep reaches for, since a Gaussian's rotation angle goes with its area, roughly `amplitude * sigma * sqrt(2π)`. ## IQ built-ins @@ -284,56 +177,23 @@ sideband = qp.waveforms.Modulated(envelope=qp.waveforms.Gaussian(0.5, 40, 8), fr virtual_z = qp.waveforms.IQRotation(base=drive, phase=1.5708) ``` -`IQPair` is the generic pair, and the only IQ shape that hands back exactly -the objects it was given: `get_I()` and `get_Q()` return them by identity. It -rejects a non-`Waveform` argument with -`TypeError: I and Q must be Waveform instances`, and unequal durations with +`IQPair` is the generic pair, and the only IQ shape that hands back exactly the objects it was given: `get_I()` and `get_Q()` return them by identity. It rejects a non-`Waveform` argument with `TypeError: I and Q must be Waveform instances`, and unequal durations with ``` ValidationError: IQPair channels must have equal durations; got I=100 ns, Q=999 ns ``` -The duration check is best-effort. When both durations are symbolic and still -unassigned, `get_duration()` raises `UnassignedVariableError`, the constructor -swallows it and accepts the pair, and the platform compiler verifies the match -once values are bound. - -`IQDrag` is the standard DRAG shape, and stores its four parameters rather than -two child waveforms: `get_I()` and `get_Q()` build a `Gaussian` and a -`GaussianDragCorrection` on demand, so the channels cannot drift apart from -each other or from `get_duration()`. - -The remaining three adapt an existing shape and validate its type in -`__init__`, naming what they got: `IQZero("pi_pulse")` raises -`TypeError: IQZero envelope must be a Waveform, got str`. `IQZero` puts a -single-channel envelope on I and silence on Q, which is how a calibrated -single-channel pulse reaches an IQ-typed bus without rewriting the program -around it. `Modulated` lifts one onto an IQ bus at an intermediate frequency, -producing `envelope * cos(2π * frequency * t + phase)` on I and the sine on Q. -`IQRotation` applies `I' = I * cos(phase) - Q * sin(phase)` and -`Q' = I * sin(phase) + Q * cos(phase)`, which is the shape a virtual-Z gate or -a software phase offset needs. - -`Modulated` and `IQRotation` materialize both channels as `Arbitrary` waveforms -sampled at one per nanosecond, and `IQZero` does the same for its silent Q -channel while handing back its I channel unchanged. Materializing collapses the -parametric structure of what these shapes wrap to concrete samples, and since -`Arbitrary.envelope()` ignores `resolution`, they only behave correctly at -`resolution=1`. `Modulated` and `IQRotation` quietly return 1-ns samples for -any other value, and `IQZero` mixes a resolution-aware I channel with a -fixed-length Q channel, so -`IQZero(Square(0.5, 100)).peak_amplitude(resolution=2)` raises -`ValueError: operands could not be broadcast together with shapes (50,) (100,)`. -Where a coarser rendering is needed, prefer carrying the phase or frequency -through the underlying envelope's own parameters. +The duration check is best-effort. When both durations are symbolic and still unassigned, `get_duration()` raises `UnassignedVariableError`, the constructor swallows it and accepts the pair, and the platform compiler verifies the match once values are bound. + +`IQDrag` is the standard DRAG shape, and stores its four parameters rather than two child waveforms: `get_I()` and `get_Q()` build a `Gaussian` and a `GaussianDragCorrection` on demand, so the channels cannot drift apart from each other or from `get_duration()`. + +The remaining three adapt an existing shape and validate its type in `__init__`, naming what they got: `IQZero("pi_pulse")` raises `TypeError: IQZero envelope must be a Waveform, got str`. `IQZero` puts a single-channel envelope on I and silence on Q, which is how a calibrated single-channel pulse reaches an IQ-typed bus without rewriting the program around it. `Modulated` lifts one onto an IQ bus at an intermediate frequency, producing `envelope * cos(2π * frequency * t + phase)` on I and the sine on Q. `IQRotation` applies `I' = I * cos(phase) - Q * sin(phase)` and `Q' = I * sin(phase) + Q * cos(phase)`, which is the shape a virtual-Z gate or a software phase offset needs. + +`Modulated` and `IQRotation` materialize both channels as `Arbitrary` waveforms sampled at one per nanosecond, and `IQZero` does the same for its silent Q channel while handing back its I channel unchanged. Materializing collapses the parametric structure of what these shapes wrap to concrete samples, and since `Arbitrary.envelope()` ignores `resolution`, they only behave correctly at `resolution=1`. `Modulated` and `IQRotation` quietly return 1-ns samples for any other value, and `IQZero` mixes a resolution-aware I channel with a fixed-length Q channel, so `IQZero(Square(0.5, 100)).peak_amplitude(resolution=2)` raises `ValueError: operands could not be broadcast together with shapes (50,) (100,)`. Where a coarser rendering is needed, prefer carrying the phase or frequency through the underlying envelope's own parameters. ## Variables and expressions in parameters -A parameter annotated `float | Expression` or `int | Expression` accepts a -`Variable` or an `Expression` in place of a number, and that covers every -numeric parameter of every built-in with one exception: `FlatTop.buffer` is -annotated plain `int` and takes a number only. This is how a pulse parameter -gets swept inside a loop: +A parameter annotated `float | Expression` or `int | Expression` accepts a `Variable` or an `Expression` in place of a number, and that covers every numeric parameter of every built-in with one exception: `FlatTop.buffer` is annotated plain `int` and takes a number only. This is how a pulse parameter gets swept inside a loop: ```python import qprogram as qp @@ -345,19 +205,11 @@ with program.sweep(amp, qp.Range(0.0, 1.0, 0.01)): program.play("drive_q0", qp.waveforms.Gaussian(amplitude=amp, duration=40, sigma=8)) ``` -The waveform stores the symbolic parameter and nothing else happens at -construction. The platform's compiler then decides whether to update an -amplitude register on the fly or re-upload the waveform on each iteration; you -write the same thing either way. +The waveform stores the symbolic parameter and nothing else happens at construction. The platform's compiler then decides whether to update an amplitude register on the fly or re-upload the waveform on each iteration; you write the same thing either way. -The structural parameters are the ones that must be concrete when the object -is built: `Arbitrary.samples`, the list in `Chained.waveforms`, `IQPair.I` and -`IQPair.Q`, `IQZero.envelope`, `Modulated.envelope`, and `IQRotation.base`. -Each of the last four is type-checked in `__init__`, so a mistake there is a -`TypeError` at the call site rather than a failure during rendering. +The structural parameters are the ones that must be concrete when the object is built: `Arbitrary.samples`, the list in `Chained.waveforms`, `IQPair.I` and `IQPair.Q`, `IQZero.envelope`, `Modulated.envelope`, and `IQRotation.base`. Each of the last four is type-checked in `__init__`, so a mistake there is a `TypeError` at the call site rather than a failure during rendering. -A waveform can be evaluated locally once its variables are bound, which is -what plotting a swept shape needs: +A waveform can be evaluated locally once its variables are bound, which is what plotting a swept shape needs: ```python import qprogram as qp @@ -370,10 +222,7 @@ amp.reset() ## Structural equality -Waveforms compare the way every other AST node does, through the shared rule -[Core ideas](concepts.md#structural-equality) sets out: an exact type match, -then attribute-by-attribute comparison of `vars()`. Two consequences are -particular to waveforms. +Waveforms compare the way every other AST node does, through the shared rule [Core ideas](concepts.md#structural-equality) sets out: an exact type match, then attribute-by-attribute comparison of `vars()`. Two consequences are particular to waveforms. ```python import numpy as np @@ -384,21 +233,11 @@ qp.waveforms.Gaussian(qp.Variable("amp"), 40, 8) == qp.waveforms.Gaussian(qp.Var qp.waveforms.Arbitrary(np.array([1.0, 2.0])) == qp.waveforms.Arbitrary([1.0, 2.0]) ``` -All three are `True`. The second holds because a `Variable` compares by its -string id and the two objects share the id `"amp"`, so a waveform whose -amplitude is swept still compares equal across a rebuild. The third holds -because `Arbitrary` converts its argument with `numpy.asarray` before storing -it, which puts a list and the equivalent array in the same place. +All three are `True`. The second holds because a `Variable` compares by its string id and the two objects share the id `"amp"`, so a waveform whose amplitude is swept still compares equal across a rebuild. The third holds because `Arbitrary` converts its argument with `numpy.asarray` before storing it, which puts a list and the equivalent array in the same place. -Waveforms are usable as dictionary keys, on the condition that they are treated -as values and never mutated after being hashed. `Arbitrary` is the one shape -where equality and hashing disagree: hashing includes the array's dtype and -equality does not, so `Arbitrary(np.array([1, 2, 3]))` and -`Arbitrary(np.array([1.0, 2.0, 3.0]))` compare equal yet land in different -buckets of a `dict` or `set`. +Waveforms are usable as dictionary keys, on the condition that they are treated as values and never mutated after being hashed. `Arbitrary` is the one shape where equality and hashing disagree: hashing includes the array's dtype and equality does not, so `Arbitrary(np.array([1, 2, 3]))` and `Arbitrary(np.array([1.0, 2.0, 3.0]))` compare equal yet land in different buckets of a `dict` or `set`. -Structural equality is what survives a `.qp` round trip. `QProgram` itself -does not define `__eq__`, so compare bodies: +Structural equality is what survives a `.qp` round trip. `QProgram` itself does not define `__eq__`, so compare bodies: ```python import qprogram as qp @@ -412,11 +251,7 @@ assert reloaded.body == program.body ## On the wire -The writer emits a waveform as a constructor call: the class name verbatim, -then every attribute in `vars(wf)` that does not start with an underscore, as -a keyword argument. Defaults are written out rather than omitted, and sample -arrays are written in full, because the parser has no way to recover dropped -samples. +The writer emits a waveform as a constructor call: the class name verbatim, then every attribute in `vars(wf)` that does not start with an underscore, as a keyword argument. Defaults are written out rather than omitted, and sample arrays are written in full, because the parser has no way to recover dropped samples. ``` #!QProgram 0.2 @@ -428,18 +263,11 @@ body: play "flux_q2" Chained(waveforms=[Square(amplitude=1.0, duration=50), Gaussian(amplitude=0.5, duration=40, sigma=8)]) ``` -Each shape also carries a capability token, which is what lets a `Profile` -advertise the subset of shapes its compiler can lower. The token is -`waveform.` followed by the snake_case class name, with `SuddenNetZero` the -one abbreviation: it is `waveform.snz`, not `waveform.sudden_net_zero`. On top -of the per-class token, a `play` contributes the channel kind -(`waveform.single` or `waveform.iq`), and a string alias contributes -`waveform.alias` and no per-class token at all. +Each shape also carries a capability token, which is what lets a `Profile` advertise the subset of shapes its compiler can lower. The token is `waveform.` followed by the snake_case class name, with `SuddenNetZero` the one abbreviation: it is `waveform.snz`, not `waveform.sudden_net_zero`. On top of the per-class token, a `play` contributes the channel kind (`waveform.single` or `waveform.iq`), and a string alias contributes `waveform.alias` and no per-class token at all. ## Custom waveforms -Subclass `Waveform` or `IQWaveform` and implement the abstract methods. To -make the new shape serializable, register it with the parser: +Subclass `Waveform` or `IQWaveform` and implement the abstract methods. To make the new shape serializable, register it with the parser: ```python import numpy as np @@ -461,24 +289,11 @@ class HalfSine(qp.waveforms.Waveform): return self.duration ``` -Once registered, `HalfSine(amplitude=0.5, duration=100)` appears in `.qp` -files using the constructor syntax, and the parser reconstructs the object on -load. Because the writer works from `vars(wf)`, anything stored on `self` under -the same name as a constructor parameter round-trips without further work, and -anything else stored on `self` is emitted too and will fail to reload; prefix -computed attributes with an underscore. Registering a different class under a -name already taken raises `ValueError` rather than silently changing how -existing files parse. Pair the registration with -`qp.register_waveform_token(HalfSine, "waveform.half_sine")` to give profiles -something to advertise; without a token the shape contributes only its channel -kind, and any profile accepting that kind accepts it. See -[Adding waveforms](../developer/adding-waveforms.md) for the developer-side -details. +Once registered, `HalfSine(amplitude=0.5, duration=100)` appears in `.qp` files using the constructor syntax, and the parser reconstructs the object on load. Because the writer works from `vars(wf)`, anything stored on `self` under the same name as a constructor parameter round-trips without further work, and anything else stored on `self` is emitted too and will fail to reload; prefix computed attributes with an underscore. Registering a different class under a name already taken raises `ValueError` rather than silently changing how existing files parse. Pair the registration with `qp.register_waveform_token(HalfSine, "waveform.half_sine")` to give profiles something to advertise; without a token the shape contributes only its channel kind, and any profile accepting that kind accepts it. See [Adding waveforms](../developer/adding-waveforms.md) for the developer-side details. ## Picking the right shape -Two shapes often both fit a job. The third column is the property that decides -between them. +Two shapes often both fit a job. The third column is the property that decides between them. | When you need | Reach for | Because | |-----------------------------------------------------|------------------------------------|--------------------------------------------------------------------------------------------| @@ -499,13 +314,8 @@ between them. | Two shapes played back to back | `Chained`, or `a + b` | durations add and each child keeps its own parameters | | Anything else | Subclass `Waveform` / `IQWaveform` | `envelope()` and `get_duration()` supply every derived measure | -For programs that go through calibration, you usually pass string aliases -(`"pi_pulse"`, `"readout"`) and let the platform substitute the concrete -waveform from its calibration store. +For programs that go through calibration, you usually pass string aliases (`"pi_pulse"`, `"readout"`) and let the platform substitute the concrete waveform from its calibration store. ## Related pages -[Operations](operations.md) has the `play` and `measure` signatures. -[Buses](buses.md) covers the schemas whose `channel` field drives the -single-versus-IQ check. [Saving and loading](serialization.md) covers how -aliases survive the `.qp` round trip. +[Operations](operations.md) has the `play` and `measure` signatures. [Buses](buses.md) covers the schemas whose `channel` field drives the single-versus-IQ check. [Saving and loading](serialization.md) covers how aliases survive the `.qp` round trip. diff --git a/docs/index.md b/docs/index.md index 4fa502b..34a34d3 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,12 +1,6 @@ # QProgram -QProgram is a Python DSL for describing pulse-level quantum experiments. A -program says what the chip should do; a platform decides how to run it. The -package is the language plus everything that can be settled without an -instrument attached: the AST, the `.qp` text format, the capability protocol a -platform validates programs against, a reference executor written in Python, -and the hooks vendor packages register themselves through. Its only runtime -dependencies are `numpy` and `xarray`. +QProgram is a Python DSL for describing pulse-level quantum experiments. A program says what the chip should do; a platform decides how to run it. The package is the language plus everything that can be settled without an instrument attached: the AST, the `.qp` text format, the capability protocol a platform validates programs against, a reference executor written in Python, and the hooks vendor packages register themselves through. Its only runtime dependencies are `numpy` and `xarray`. ## A first program @@ -41,159 +35,41 @@ result = qp.simulate(resolved) data = result.get(m0) # xarray.DataArray with named dimensions ``` -`data` comes back with dimensions `("gain", "IQ")` and shape `(101, 2)`. -Dimensions are named after the enclosing loops, outermost first, so the sweep -over `gain` becomes an axis of 101 points; the trailing `IQ` axis carries -coordinates `["I", "Q"]`. The 1000 shots of the `average` block are reduced -rather than kept. +`data` comes back with dimensions `("gain", "IQ")` and shape `(101, 2)`. Dimensions are named after the enclosing loops, outermost first, so the sweep over `gain` becomes an axis of 101 points; the trailing `IQ` axis carries coordinates `["I", "Q"]`. The 1000 shots of the `average` block are reduced rather than kept. ## What the package does and does not do -QProgram compiles nothing and talks to no instrument. It builds a description -of an experiment, checks that description against what a platform says it can -do, and hands it over. Lowering to instrument code, scheduling, and -calibration all sit on the platform side of `qp.PlatformProtocol`. - -The same `QProgram` therefore runs on any platform that implements that -protocol. What portability costs is that the core vocabulary can only be the -part every platform can be asked to support. Instrument-specific work -(markers, active reset, triggers, slow-control parameters) lives in optional -vendor packages that register their operations at import time, so adding an -instrument does not change `qprogram`, and a program that uses one runs only -where that package is installed. Its `.qp` file records the dependency as a -`require` line and refuses to load without it, which is the trade-off taken on -purpose: a loud `ParseError` rather than a file that loads with an operation -silently missing. - -Programs are ordinary Python objects. Blocks are containers, operations are -leaves, both are nodes, and `program.body.walk()` walks them, so a program can -be assembled by a function, a loop, or a comprehension and inspected -afterwards without a parser in the way. The cost is that Python control flow -runs while the program is being built and leaves no trace in the AST: a Python -`for` unrolls into repeated nodes, a loop that has to survive into execution is -`program.sweep(...)` or `program.average(...)`, and a branch on a measurement -result is `program.if_(...)`. The transformers (`rebind`, `with_waveforms`, -`expand`) deep-copy the program rather than mutate it, so a node held from -before a transform is not a node of the result. - -`qp.save(program, "exp.qp")` writes a line-oriented text file that reviews and -diffs like source, and `qp.load` reads it back. The round trip is pinned by the -test suite in two directions: `qp.loads(qp.dumps(program))` carries the same -label, description, variables, and body under structural equality, and -re-emitting that program reproduces the text byte for byte. Numbers are written -through `repr` and arrays are never truncated, so the values that come back are -the values that went in. What the file preserves is the program, not the -document: the parser strips comments and the writer emits none, so a -hand-edited `.qp` file loses its annotations on the next save, and measurement -handles come back as new objects to be looked up by name through -`QProgram.measurement_handles`. Anything the format has no representation for -is refused rather than approximated. An unregistered operation class, a 2-D -array, and a `Fragment` handed to `qp.dumps` directly each raise -`SerializationError`. - -A bus is addressed by a `BusRef` that a `BusSchema` produces. -`qp.BusSchema.transmon()` and the other presets return typed subclasses, so -`schema.q[0].drive` completes in an editor and a kind the element does not -expose fails while the program is being built rather than at execution: a -dynamically built schema reports `'q' has no bus 'flux'. Available: drive, -readout`. The schema also owns the mapping from element, index, and kind to the -bus string, through `qp.BusNaming` (the default pattern -`"{element}{index}/{kind}"` gives `q0/drive`), so no naming convention is built -into the language. Presets are typed but fixed; schemas built with -`add_element` or composed with `schema_a + schema_b` work at run time and carry -no static type. Plain strings remain valid buses, and a platform validates them -against its default bus profile instead of a per-bus one. - -Capabilities are declared per slot, a slot being a `(bus, domain)` pair, with -the domains real-time (`rt`) and host-side (`host`). -`qp.validate(program, caps)` returns the `Diagnostic`s together with an -`ExecutionPlan` recording which domains each node can run in, and -`qp.explain(program, caps)` renders the same result as a tree with a -`[rt|host]`, `[rt]`, `[host]`, or `[--]` column per node, so an unsupported -operation is reported against the node that carries it instead of as one -rejection of the whole program. The check is static and no better than the -descriptor behind it: a `Profile` bundles capability tokens, numeric limits, -and predicates, the validator ignores limit keys it does not recognize, and -nothing in it looks at calibration, so a program can validate clean and still -fail on the device. +QProgram compiles nothing and talks to no instrument. It builds a description of an experiment, checks that description against what a platform says it can do, and hands it over. Lowering to instrument code, scheduling, and calibration all sit on the platform side of `qp.PlatformProtocol`. + +The same `QProgram` therefore runs on any platform that implements that protocol. What portability costs is that the core vocabulary can only be the part every platform can be asked to support. Instrument-specific work (markers, active reset, triggers, slow-control parameters) lives in optional vendor packages that register their operations at import time, so adding an instrument does not change `qprogram`, and a program that uses one runs only where that package is installed. Its `.qp` file records the dependency as a `require` line and refuses to load without it, which is the trade-off taken on purpose: a loud `ParseError` rather than a file that loads with an operation silently missing. + +Programs are ordinary Python objects. Blocks are containers, operations are leaves, both are nodes, and `program.body.walk()` walks them, so a program can be assembled by a function, a loop, or a comprehension and inspected afterwards without a parser in the way. The cost is that Python control flow runs while the program is being built and leaves no trace in the AST: a Python `for` unrolls into repeated nodes, a loop that has to survive into execution is `program.sweep(...)` or `program.average(...)`, and a branch on a measurement result is `program.if_(...)`. The transformers (`rebind`, `with_waveforms`, `expand`) deep-copy the program rather than mutate it, so a node held from before a transform is not a node of the result. + +`qp.save(program, "exp.qp")` writes a line-oriented text file that reviews and diffs like source, and `qp.load` reads it back. The round trip is pinned by the test suite in two directions: `qp.loads(qp.dumps(program))` carries the same label, description, variables, and body under structural equality, and re-emitting that program reproduces the text byte for byte. Numbers are written through `repr` and arrays are never truncated, so the values that come back are the values that went in. What the file preserves is the program, not the document: the parser strips comments and the writer emits none, so a hand-edited `.qp` file loses its annotations on the next save, and measurement handles come back as new objects to be looked up by name through `QProgram.measurement_handles`. Anything the format has no representation for is refused rather than approximated. An unregistered operation class, a 2-D array, and a `Fragment` handed to `qp.dumps` directly each raise `SerializationError`. + +A bus is addressed by a `BusRef` that a `BusSchema` produces. `qp.BusSchema.transmon()` and the other presets return typed subclasses, so `schema.q[0].drive` completes in an editor and a kind the element does not expose fails while the program is being built rather than at execution: a dynamically built schema reports `'q' has no bus 'flux'. Available: drive, readout`. The schema also owns the mapping from element, index, and kind to the bus string, through `qp.BusNaming` (the default pattern `"{element}{index}/{kind}"` gives `q0/drive`), so no naming convention is built into the language. Presets are typed but fixed; schemas built with `add_element` or composed with `schema_a + schema_b` work at run time and carry no static type. Plain strings remain valid buses, and a platform validates them against its default bus profile instead of a per-bus one. + +Capabilities are declared per slot, a slot being a `(bus, domain)` pair, with the domains real-time (`rt`) and host-side (`host`). `qp.validate(program, caps)` returns the `Diagnostic`s together with an `ExecutionPlan` recording which domains each node can run in, and `qp.explain(program, caps)` renders the same result as a tree with a `[rt|host]`, `[rt]`, `[host]`, or `[--]` column per node, so an unsupported operation is reported against the node that carries it instead of as one rejection of the whole program. The check is static and no better than the descriptor behind it: a `Profile` bundles capability tokens, numeric limits, and predicates, the validator ignores limit keys it does not recognize, and nothing in it looks at calibration, so a program can validate clean and still fail on the device. ## The layers a program passes through -The stages are building, serialization, validation, optimization, execution, -and result collection, of which only building and execution are compulsory. -Serialization is a detour off the AST rather than a stage every program passes -through: `qp.dumps` and `qp.loads` can be skipped entirely, or used as the only -interchange between the process that writes a program and the one that runs it. -Validation against a platform's capabilities comes next, and -`qp.optimize(program, caps)` is an opt-in rewrite that applies the one -reordering the validator otherwise reports as the `"reorderable-averaging"` -info hint, lifting a host-side sweep out of an `average` so that the averaging -itself can run in real time. It is opt-in because the rewrite groups all shots -of a sweep point together instead of interleaving passes, which changes nothing -for a stationary system and does change results under drift. - -Execution is the stage QProgram does not own. `qp.PlatformProtocol` requires a -platform to supply resource discovery (`get_bus_schema`, `get_buses`, -`get_parameters`, `get_global_parameters`), a `PlatformCapabilities` -descriptor, and `execute`. Its `validate`, `plan`, and `explain` methods have -working defaults that delegate to the core validator, so a platform with no -opinion of its own reports the same diagnostics a user gets from -`qp.validate`, and `stream` raises `NotImplementedError` until a platform -overrides it. The convention is that `execute` validates first and raises -`UnsupportedOperationError` on any diagnostic of severity `"error"`; a platform -is not forced to, and one that skips the check surfaces its own compiler errors -in place of structured diagnostics. `qp.simulate` runs a `qp.ReferencePlatform` -over the program in Python and is the executable definition of the language's -semantics; whatever runs the program, results arrive as one record per -measurement in a `QProgramResult`. +The stages are building, serialization, validation, optimization, execution, and result collection, of which only building and execution are compulsory. Serialization is a detour off the AST rather than a stage every program passes through: `qp.dumps` and `qp.loads` can be skipped entirely, or used as the only interchange between the process that writes a program and the one that runs it. Validation against a platform's capabilities comes next, and `qp.optimize(program, caps)` is an opt-in rewrite that applies the one reordering the validator otherwise reports as the `"reorderable-averaging"` info hint, lifting a host-side sweep out of an `average` so that the averaging itself can run in real time. It is opt-in because the rewrite groups all shots of a sweep point together instead of interleaving passes, which changes nothing for a stationary system and does change results under drift. + +Execution is the stage QProgram does not own. `qp.PlatformProtocol` requires a platform to supply resource discovery (`get_bus_schema`, `get_buses`, `get_parameters`, `get_global_parameters`), a `PlatformCapabilities` descriptor, and `execute`. Its `validate`, `plan`, and `explain` methods have working defaults that delegate to the core validator, so a platform with no opinion of its own reports the same diagnostics a user gets from `qp.validate`, and `stream` raises `NotImplementedError` until a platform overrides it. The convention is that `execute` validates first and raises `UnsupportedOperationError` on any diagnostic of severity `"error"`; a platform is not forced to, and one that skips the check surfaces its own compiler errors in place of structured diagnostics. `qp.simulate` runs a `qp.ReferencePlatform` over the program in Python and is the executable definition of the language's semantics; whatever runs the program, results arrive as one record per measurement in a `QProgramResult`. ## Vendor extensions -A vendor extension is a separate package that depends on `qprogram` and -registers itself on import through three independent hooks: a runtime namespace -(a `qp.VendorNamespace` subclass passed to `QProgram.register_vendor`, which is -what makes a call such as `program.fake_inst.beep(...)` resolve), a typed mixin -so that the same call completes in an editor, and serialization registry -entries (`qp.register_vendor_operation`, `qp.register_vendor_block`, -`qp.register_vendor_version`) that give its nodes a `.qp` form and a version -for the `require` line. Capability tokens and profiles are registered the same -way, through `qp.register_capability_tokens` and `qp.register_profile`. A -package that also declares a `qprogram.vendors` entry point can be activated by -the parser on demand, which is what lets a `.qp` file that names it load in a -fresh interpreter. The [Architecture](developer/architecture.md) and [Building -a vendor extension](developer/vendor-extensions.md) pages work through the -pattern. +A vendor extension is a separate package that depends on `qprogram` and registers itself on import through three independent hooks: a runtime namespace (a `qp.VendorNamespace` subclass passed to `QProgram.register_vendor`, which is what makes a call such as `program.fake_inst.beep(...)` resolve), a typed mixin so that the same call completes in an editor, and serialization registry entries (`qp.register_vendor_operation`, `qp.register_vendor_block`, `qp.register_vendor_version`) that give its nodes a `.qp` form and a version for the `require` line. Capability tokens and profiles are registered the same way, through `qp.register_capability_tokens` and `qp.register_profile`. A package that also declares a `qprogram.vendors` entry point can be activated by the parser on demand, which is what lets a `.qp` file that names it load in a fresh interpreter. The [Architecture](developer/architecture.md) and [Building a vendor extension](developer/vendor-extensions.md) pages work through the pattern. ## Versions and compatibility -The package is pre-1.0, so the Python API can change between releases without a -deprecation cycle. The `.qp` format version follows the library version -truncated to `major.minor`, so this release writes `#!QProgram 0.2`. A file from -an *earlier* version always loads: a release that changes the syntax registers a -migration under its own version, and loading applies every migration between the -file's version and this one to the lines in memory, leaving the file on disk as -it is. A file from a *later* version is refused with `ParseError` and -`Unsupported format version 0.9`, since this release has no way to know what a -later one changed. The header carries `major.minor` and nothing else — a patch -release cannot change the format, so a file has no patch to declare — and a file -with no header at all fails immediately with `Missing #!QProgram header`. - -Vendor compatibility is checked one `require` line at a time, before any of the -body is built, so a rejected file leaves no partially loaded program: the line -asks for a `major.minor`, anything the installed extension cannot provide is -refused, anything older loads with that extension's own migrations applied to -the body first, and a vendor that is installed but not yet imported is activated -through its `qprogram.vendors` entry point. -[Format version and the require line](guide/serialization.md#format-version-and-the-require-line) -has the message each failure produces and the argument that turns activation -off. +The package is pre-1.0, so the Python API can change between releases without a deprecation cycle. The `.qp` format version follows the library version truncated to `major.minor`, so this release writes `#!QProgram 0.2`. A file from an *earlier* version always loads: a release that changes the syntax registers a migration under its own version, and loading applies every migration between the file's version and this one to the lines in memory, leaving the file on disk as it is. A file from a *later* version is refused with `ParseError` and `Unsupported format version 0.9`, since this release has no way to know what a later one changed. The header carries `major.minor` and nothing else — a patch release cannot change the format, so a file has no patch to declare — and a file with no header at all fails immediately with `Missing #!QProgram header`. + +Vendor compatibility is checked one `require` line at a time, before any of the body is built, so a rejected file leaves no partially loaded program: the line asks for a `major.minor`, anything the installed extension cannot provide is refused, anything older loads with that extension's own migrations applied to the body first, and a vendor that is installed but not yet imported is activated through its `qprogram.vendors` entry point. [Format version and the require line](guide/serialization.md#format-version-and-the-require-line) has the message each failure produces and the argument that turns activation off. ## Pages by task -The [API reference](reference/api-qprogram.md) is generated from the source, -[.qp file format](reference/qp-format.md) describes the on-disk grammar, and -`src/qprogram/grammar/qp.lark` is the normative machine-readable form of that -grammar, kept in step with the production parser by the test suite. +The [API reference](reference/api-qprogram.md) is generated from the source, [.qp file format](reference/qp-format.md) describes the on-disk grammar, and `src/qprogram/grammar/qp.lark` is the normative machine-readable form of that grammar, kept in step with the production parser by the test suite. | If you want to ... | Read | |-------------------------------------------------|---------------------------------------------------------------------| diff --git a/docs/reference/api-qprogram.md b/docs/reference/api-qprogram.md index 428c389..40cd6a3 100644 --- a/docs/reference/api-qprogram.md +++ b/docs/reference/api-qprogram.md @@ -1,30 +1,8 @@ # API reference -Every entry below is generated from a docstring in `src/` by mkdocstrings. -Signatures, defaults, and type annotations are read from the code rather than -written out here, and each heading carries a fold with the source it came from. -Members are listed in the order they appear in their file rather than -alphabetically, and private names are hidden apart from `__init__`, whose -parameters are folded into the class heading. Every heading is anchored by -dotted path, so another page can link to a single member: -`api-qprogram.md#qprogram.QProgram.play`. - -The supported surface is `qprogram.__all__`, the names that resolve directly on -the package after `import qprogram as qp`. Three other kinds of name appear -here under a longer dotted path. The waveform, operation, block, and plotting -classes live in submodules the top level does not re-export, so they are -written `qp.waveforms.Gaussian`, `qp.operations.Play`, `qp.blocks.Sweep`, and -`qp.plotting.Style`. A few -names the top level does re-export are grouped with the submodule that defines -them instead, because they read better next to related material: `Call` and -`MeasurementField` sit with the rest of `qprogram.operations`, `UNASSIGNED` and -the expression helpers (`qp.eq`, `qp.sin`, and so on) sit with -`qprogram.variable`, and `dumps`/`save` sit with `qprogram.serialization.writer` -next to `loads`/`load`. The rest are extension points an integrator needs and -a program author does not, reached through their submodule: -`qp.serialization.register_operation`, `qp.protocol.validate_tokens`, -`qp.sweeps.validate_source`. For the reasoning behind any of these names, read -the [user guide](../guide/index.md); this page is the lookup. +Every entry below is generated from a docstring in `src/` by mkdocstrings. Signatures, defaults, and type annotations are read from the code rather than written out here, and each heading carries a fold with the source it came from. Members are listed in the order they appear in their file rather than alphabetically, and private names are hidden apart from `__init__`, whose parameters are folded into the class heading. Every heading is anchored by dotted path, so another page can link to a single member: `api-qprogram.md#qprogram.QProgram.play`. + +The supported surface is `qprogram.__all__`, the names that resolve directly on the package after `import qprogram as qp`. Three other kinds of name appear here under a longer dotted path. The waveform, operation, block, and plotting classes live in submodules the top level does not re-export, so they are written `qp.waveforms.Gaussian`, `qp.operations.Play`, `qp.blocks.Sweep`, and `qp.plotting.Style`. A few names the top level does re-export are grouped with the submodule that defines them instead, because they read better next to related material: `Call` and `MeasurementField` sit with the rest of `qprogram.operations`, `UNASSIGNED` and the expression helpers (`qp.eq`, `qp.sin`, and so on) sit with `qprogram.variable`, and `dumps`/`save` sit with `qprogram.serialization.writer` next to `loads`/`load`. The rest are extension points an integrator needs and a program author does not, reached through their submodule: `qp.serialization.register_operation`, `qp.protocol.validate_tokens`, `qp.sweeps.validate_source`. For the reasoning behind any of these names, read the [user guide](../guide/index.md); this page is the lookup. ## Top-level @@ -65,14 +43,7 @@ the [user guide](../guide/index.md); this page is the lookup. ### Sweep builders -`program.sweep(variable)`, with the source left out, returns a source builder; -`program.sweep(variable, source)` returns the loop context straight away. Both -are private classes that user code never constructs, but their methods are part -of the public surface, so they are documented here. Entering a builder before a -`from_*` call has picked any values raises `ValidationError` rather than -sweeping nothing. The context managers behind `average`, `block`, `if_`, -`elif_`, and `else_` add nothing to the context-manager protocol, so they have -no entries of their own. +`program.sweep(variable)`, with the source left out, returns a source builder; `program.sweep(variable, source)` returns the loop context straight away. Both are private classes that user code never constructs, but their methods are part of the public surface, so they are documented here. Entering a builder before a `from_*` call has picked any values raises `ValidationError` rather than sweeping nothing. The context managers behind `average`, `block`, `if_`, `elif_`, and `else_` add nothing to the context-manager protocol, so they have no entries of their own. ::: qprogram.qprogram._SweepBuilder options: @@ -95,15 +66,7 @@ no entries of their own. ## Sweep sources -What a `Sweep` iterates over: a description of the values, never a producer of -them. Every source answers `length()` and `values()` without the program -running, declares a `KIND` of `"linear"` or `"arbitrary"` along with its own -`sweep.` capability token, and compares and hashes structurally over its -public attributes, which are treated as immutable once the source is in a -program. `register_sweep_source` puts a subclass in the registry under its own -class name, which is what makes it parseable from a `.qp` file and spellable as -`sweep(variable).from_(...)`; `validate_source` checks the `length()` and -`values()` invariants for a new one. +What a `Sweep` iterates over: a description of the values, never a producer of them. Every source answers `length()` and `values()` without the program running, declares a `KIND` of `"linear"` or `"arbitrary"` along with its own `sweep.` capability token, and compares and hashes structurally over its public attributes, which are treated as immutable once the source is in a program. `register_sweep_source` puts a subclass in the registry under its own class name, which is what makes it parseable from a `.qp` file and spellable as `sweep(variable).from_(...)`; `validate_source` checks the `length()` and `values()` invariants for a new one. ::: qprogram.SweepSource options: @@ -167,11 +130,7 @@ class name, which is what makes it parseable from a `.qp` file and spellable as ### Typed schemas -Each preset factory on `BusSchema` returns one of these subclasses, whose -element properties are declared rather than resolved through `__getattr__`, so -an editor can complete the bus kinds. The classes are reachable under their own -names for a type annotation or for `combine`, which takes a class as readily as -an instance. +Each preset factory on `BusSchema` returns one of these subclasses, whose element properties are declared rather than resolved through `__getattr__`, so an editor can complete the bus kinds. The classes are reachable under their own names for a type annotation or for `combine`, which takes a class as readily as an instance. ::: qprogram.buses.TransmonSchema options: @@ -199,12 +158,7 @@ an instance. ### Typed element accessors -`TransmonSchema.q`, `FluxTunableTransmonSchema.q`, and `FluxoniumSchema.q` -return one of these factories; indexing one returns the matching accessor, -whose properties are the element's typed bus refs. -`FluxTunableTransmonQubitBuses` and `FluxoniumQubitBuses` subclass -`TransmonQubitBuses` to add their extra flux buses rather than repeating -`drive` and `readout`. +`TransmonSchema.q`, `FluxTunableTransmonSchema.q`, and `FluxoniumSchema.q` return one of these factories; indexing one returns the matching accessor, whose properties are the element's typed bus refs. `FluxTunableTransmonQubitBuses` and `FluxoniumQubitBuses` subclass `TransmonQubitBuses` to add their extra flux buses rather than repeating `drive` and `readout`. ::: qprogram.buses.TransmonQubitBuses options: @@ -232,14 +186,7 @@ whose properties are the element's typed bus refs. ### Typed schema base classes -A chip type no preset covers gets a subclass built from the same three pieces -the presets use: an accessor carrying one property per bus kind, a factory that -turns an index into an accessor, and the schema carrying one property per -element. The two base classes hold the machinery for the first two, and -`CouplerBuses`/`CouplerFactory` are reusable as they stand, because a coupler's -single `flux` bus is the same in every preset that has one. [Defining your own -typed schema](../guide/buses.md#defining-your-own-typed-schema) walks through a -complete class. +A chip type no preset covers gets a subclass built from the same three pieces the presets use: an accessor carrying one property per bus kind, a factory that turns an index into an accessor, and the schema carrying one property per element. The two base classes hold the machinery for the first two, and `CouplerBuses`/`CouplerFactory` are reusable as they stand, because a coupler's single `flux` bus is the same in every preset that has one. [Defining your own typed schema](../guide/buses.md#defining-your-own-typed-schema) walks through a complete class. ::: qprogram.buses._TypedElementAccessor options: @@ -263,12 +210,7 @@ complete class. ### Re-resolving a coordinate -`resolve_ref` is the one place an `(element, index, kind)` coordinate becomes a -`BusRef`: the `.qp` parser calls it for every `element[i].kind` path it reads, -and `QProgram.rebind` calls it for every ref it rewrites, which is what keeps a -re-indexed or ported program checked against the schema it lands on. -`naming_substituted_schema` covers the naming-only port, returning a dynamic -copy of the schema with the same elements declared under a new `BusNaming`. +`resolve_ref` is the one place an `(element, index, kind)` coordinate becomes a `BusRef`: the `.qp` parser calls it for every `element[i].kind` path it reads, and `QProgram.rebind` calls it for every ref it rewrites, which is what keeps a re-indexed or ported program checked against the schema it lands on. `naming_substituted_schema` covers the naming-only port, returning a dynamic copy of the schema with the same elements declared under a new `BusNaming`. ::: qprogram.buses.resolve_ref @@ -324,13 +266,7 @@ copy of the schema with the same elements declared under a new `BusNaming`. ### Helper functions -Free functions that build expression nodes, all reached as `qp.eq`, `qp.sin`, -and so on. `eq` and `ne` are the only way to compare two expressions for -equality: `Variable.__eq__` compares ids and has to keep returning a `bool` so -that variables stay usable in sets and as dictionary keys. `and_`, `or_`, and -`not_` are function forms of `&`, `|`, and `~`, which `Expression` does -overload. The math functions, `minimum`, `maximum`, and `where` have no -operator form at all. +Free functions that build expression nodes, all reached as `qp.eq`, `qp.sin`, and so on. `eq` and `ne` are the only way to compare two expressions for equality: `Variable.__eq__` compares ids and has to keep returning a `bool` so that variables stay usable in sets and as dictionary keys. `and_`, `or_`, and `not_` are function forms of `&`, `|`, and `~`, which `Expression` does overload. The math functions, `minimum`, `maximum`, and `where` have no operator form at all. ::: qprogram.variable.eq ::: qprogram.variable.ne @@ -379,12 +315,7 @@ operator form at all. ## Operations -The AST leaves, each appended by the matching builder method on `QProgram` -rather than constructed at a call site. `MeasurementField` and -`normalize_fields` are documented here because they live in the same module: the -first is a `StrEnum` of the field names a measurement can request, and the -second sorts a `fields` argument into the canonical order those names are -compared, hashed, and serialized in. +The AST leaves, each appended by the matching builder method on `QProgram` rather than constructed at a call site. `MeasurementField` and `normalize_fields` are documented here because they live in the same module: the first is a `StrEnum` of the field names a measurement can request, and the second sorts a `fields` argument into the canonical order those names are compared, hashed, and serialized in. ::: qprogram.operations options: @@ -451,14 +382,7 @@ compared, hashed, and serialized in. ## Plotting -`QProgramResult.plot` above is the front door. It runs `build_figure` to -describe the figure and a renderer to draw it, and the two halves are separate -so that a backend other than matplotlib is possible: everything down to -`Renderer` reads numpy and xarray only. `Waveform.plot` and `IQWaveform.plot` -take the same `style`, `renderer` and `target` and describe an envelope as the -same `Figure`, which is what keeps a pulse and a result on one palette. See -[Plotting results](../guide/plotting.md) for the walkthrough. These names live -in `qprogram.plotting`, which the top level does not re-export. +`QProgramResult.plot` above is the front door. It runs `build_figure` to describe the figure and a renderer to draw it, and the two halves are separate so that a backend other than matplotlib is possible: everything down to `Renderer` reads numpy and xarray only. `Waveform.plot` and `IQWaveform.plot` take the same `style`, `renderer` and `target` and describe an envelope as the same `Figure`, which is what keeps a pulse and a result on one palette. See [Plotting results](../guide/plotting.md) for the walkthrough. These names live in `qprogram.plotting`, which the top level does not re-export. ::: qprogram.plotting.build_figure @@ -518,22 +442,9 @@ in `qprogram.plotting`, which the top level does not re-export. ## Vendor protocol -A vendor extension groups its operations as methods on a `VendorNamespace` -subclass, registers that subclass under a namespace name with -`QProgram.register_vendor`, and reaches the program through the two protected -helpers below; `program..(...)` then works without core -QProgram knowing the vendor exists. - -The registration calls below all run at the extension's import time. -`register_operation` and `register_block` add core names unless given a -`vendor=`, while `register_vendor_operation` and `register_vendor_block` forward -to them with the vendor prefix already filled in; `register_waveform` keys a -waveform class by its own `__name__` with no prefix at all. -`register_vendor_version` records the extension's protocol version, and the -writer raises `SerializationError` on vendor content whose extension never -called it. `try_activate_vendor` is the discovery step: -it imports the package behind the `qprogram.vendors` entry point for a name and -returns `False` when no installed package claims that name. +A vendor extension groups its operations as methods on a `VendorNamespace` subclass, registers that subclass under a namespace name with `QProgram.register_vendor`, and reaches the program through the two protected helpers below; `program..(...)` then works without core QProgram knowing the vendor exists. + +The registration calls below all run at the extension's import time. `register_operation` and `register_block` add core names unless given a `vendor=`, while `register_vendor_operation` and `register_vendor_block` forward to them with the vendor prefix already filled in; `register_waveform` keys a waveform class by its own `__name__` with no prefix at all. `register_vendor_version` records the extension's protocol version, and the writer raises `SerializationError` on vendor content whose extension never called it. `try_activate_vendor` is the discovery step: it imports the package behind the `qprogram.vendors` entry point for a name and returns `False` when no installed package claims that name. ::: qprogram.VendorNamespace options: @@ -560,11 +471,7 @@ returns `False` when no installed package claims that name. ## Serialization -`dumps` and `save` write `.qp`; `loads` and `load` read it. Those two readers -and `ParseError` are resolved by the package's module-level `__getattr__` on -first access rather than at import time, because the parser imports `QProgram` -and importing them eagerly would close a cycle. Nothing about that shows at the -call site: `qp.loads` is read off the package like any other attribute. +`dumps` and `save` write `.qp`; `loads` and `load` read it. Those two readers and `ParseError` are resolved by the package's module-level `__getattr__` on first access rather than at import time, because the parser imports `QProgram` and importing them eagerly would close a cycle. Nothing about that shows at the call site: `qp.loads` is read off the package like any other attribute. ::: qprogram.serialization.writer.dumps ::: qprogram.serialization.writer.save @@ -573,27 +480,11 @@ call site: `qp.loads` is read off the package like any other attribute. ### Migrations -A file whose header declares an earlier format version is rewritten in memory -on the way in, by the migrations registered for the versions in between. One -migration covers one breaking change to the syntax, so a release that breaks -nothing registers none, and a file two releases behind collects both steps. The -rewrite works on lines and must return as many as it was given, which is what -keeps a `ParseError`'s line number and the source map pointing at lines of the -file on disk. - -Both text formats read this way. `.qp` and `.wfl` carry the same version, since -each is the library version cut to `major.minor`, so one running version bounds -both chains; the tables are separate, because the same line of text means one -thing in a program body and another in a library entry. `file_format` picks the -table, and a rewrite that both formats need is registered twice. See -[Migrations](../developer/serialization-internals.md#migrations) for how to -write one. - -A vendor extension has the same problem for the wire form of its own -operations, and `register_vendor_migration` is the same mechanism against the -version in the file's `require` line: an older line loads, with that extension's -rewrites applied to the body first. Those chains are per vendor and bounded by -the installed extension rather than by the library. +A file whose header declares an earlier format version is rewritten in memory on the way in, by the migrations registered for the versions in between. One migration covers one breaking change to the syntax, so a release that breaks nothing registers none, and a file two releases behind collects both steps. The rewrite works on lines and must return as many as it was given, which is what keeps a `ParseError`'s line number and the source map pointing at lines of the file on disk. + +Both text formats read this way. `.qp` and `.wfl` carry the same version, since each is the library version cut to `major.minor`, so one running version bounds both chains; the tables are separate, because the same line of text means one thing in a program body and another in a library entry. `file_format` picks the table, and a rewrite that both formats need is registered twice. See [Migrations](../developer/serialization-internals.md#migrations) for how to write one. + +A vendor extension has the same problem for the wire form of its own operations, and `register_vendor_migration` is the same mechanism against the version in the file's `require` line: an older line loads, with that extension's rewrites applied to the body first. Those chains are per vendor and bounded by the installed extension rather than by the library. ::: qprogram.serialization.migrations.register_migration ::: qprogram.serialization.migrations.register_vendor_migration @@ -614,16 +505,7 @@ the installed extension rather than by the library. ### Reference platform -The software platform in this repository. It validates a program, interprets -the AST, and returns `xarray.DataArray` results with one dimension per -enclosing sweep (a `Parallel` composition contributing one shared dimension) -and none for an averaging block, and it is the semantics a vendor compiler is -tested against: an error diagnostic becomes `UnsupportedOperationError`, a -warning is raised through `warnings.warn` with category `ExecutionWarning`, and -info diagnostics pass silently. `simulate(program)` is the one-call form, -running the program on a throwaway platform whose measurement model defaults to -a deterministic, all-zero `MockMeasurementModel`. See -[Running programs](../guide/execution.md) for the walkthrough. +The software platform in this repository. It validates a program, interprets the AST, and returns `xarray.DataArray` results with one dimension per enclosing sweep (a `Parallel` composition contributing one shared dimension) and none for an averaging block, and it is the semantics a vendor compiler is tested against: an error diagnostic becomes `UnsupportedOperationError`, a warning is raised through `warnings.warn` with category `ExecutionWarning`, and info diagnostics pass silently. `simulate(program)` is the one-call form, running the program on a throwaway platform whose measurement model defaults to a deterministic, all-zero `MockMeasurementModel`. See [Running programs](../guide/execution.md) for the walkthrough. ::: qprogram.simulate @@ -651,9 +533,7 @@ a deterministic, all-zero `MockMeasurementModel`. See ## Capability protocol -The data types and helpers that platforms use to declare which DSL features -they support. See [Capabilities, diagnostics, and profiles](../guide/capabilities.md) -for the narrative tour. +The data types and helpers that platforms use to declare which DSL features they support. See [Capabilities, diagnostics, and profiles](../guide/capabilities.md) for the narrative tour. ### Descriptors and bundles @@ -703,19 +583,9 @@ for the narrative tour. ### Validator -`validate(program, caps)` returns a list of `Diagnostic`s and an -`ExecutionPlan` covering every visited node except the root body, keyed by node -identity rather than by structural equality. `explain(program, caps)` renders -that same classification as a string: a header with the severity counts, then -one row per node carrying its `.qp` text, its domain, and any diagnostic on it. -`optimize(program, caps)` applies the one rewrite the validator only reports as -an info hint, lifting a host-side sweep out of an averaging block so that the -averaging itself can run in real time. +`validate(program, caps)` returns a list of `Diagnostic`s and an `ExecutionPlan` covering every visited node except the root body, keyed by node identity rather than by structural equality. `explain(program, caps)` renders that same classification as a string: a header with the severity counts, then one row per node carrying its `.qp` text, its domain, and any diagnostic on it. `optimize(program, caps)` applies the one rewrite the validator only reports as an info hint, lifting a host-side sweep out of an averaging block so that the averaging itself can run in real time. -`validate` and `explain` expand fragment `Call` nodes before walking, so their -diagnostics reference nodes of that expansion rather than nodes the caller -holds. `optimize` expands only when the program's own body holds an `average`; -otherwise it returns a deep copy with the `Call` nodes intact. +`validate` and `explain` expand fragment `Call` nodes before walking, so their diagnostics reference nodes of that expansion rather than nodes the caller holds. `optimize` expands only when the program's own body holds an `average`; otherwise it returns a deep copy with the `Call` nodes intact. ::: qprogram.validate @@ -725,11 +595,7 @@ otherwise it returns a deep copy with the `Call` nodes intact. ### Diagnostic paths -Every node-bearing `Diagnostic` carries a structural `path`. `AstPath` is the -type, `node_path` builds one for a node, `resolve_path` walks one back to the -node it names, `format_path` renders one for display, and `iter_child_edges` is -the single ordered traversal that `node_path`, `resolve_path`, and the -validator all read the AST through. +Every node-bearing `Diagnostic` carries a structural `path`. `AstPath` is the type, `node_path` builds one for a node, `resolve_path` walks one back to the node it names, `format_path` renders one for display, and `iter_child_edges` is the single ordered traversal that `node_path`, `resolve_path`, and the validator all read the AST through. ::: qprogram.AstPath @@ -743,11 +609,7 @@ validator all read the AST through. ### Registries and helpers -Registration and lookup for the capability tokens and the named profiles. -`Profile` runs every token it is given through `validate_tokens` in its -`__post_init__`, so a token no core definition or registration call has put in -`CAPABILITY_REGISTRY` is rejected where the profile is defined rather than -surfacing later as a feature the platform silently lacks. +Registration and lookup for the capability tokens and the named profiles. `Profile` runs every token it is given through `validate_tokens` in its `__post_init__`, so a token no core definition or registration call has put in `CAPABILITY_REGISTRY` is rejected where the profile is defined rather than surfacing later as a feature the platform silently lacks. ::: qprogram.protocol.CAPABILITY_REGISTRY @@ -771,23 +633,13 @@ surfacing later as a feature the platform silently lacks. ## Reserved keywords -A `frozenset` of the identifiers the `.qp` grammar reserves. Variable ids, -fragment names, and vendor namespaces are all checked against it, and -[Reserved keywords](reserved.md) lists the words with what each one is kept -for. +A `frozenset` of the identifiers the `.qp` grammar reserves. Variable ids, fragment names, and vendor namespaces are all checked against it, and [Reserved keywords](reserved.md) lists the words with what each one is kept for. ::: qprogram.RESERVED_KEYWORDS ## Errors -Every error QProgram raises while a program is built, parsed, or run derives -from `QProgramError`, so one `except` covers all of it. -`UnsupportedOperationError`, `BusNotAvailableError`, `WaveformResolutionError`, -`CompilationError`, and `HardwareError` are the platform-side half of the -hierarchy, defined here so that user code can catch one class per failure mode -whichever backend is in use; of the five, only `UnsupportedOperationError` is -raised in this repository, by `ReferencePlatform.execute`. [Errors](errors.md) -covers when each one fires and what it carries. +Every error QProgram raises while a program is built, parsed, or run derives from `QProgramError`, so one `except` covers all of it. `UnsupportedOperationError`, `BusNotAvailableError`, `WaveformResolutionError`, `CompilationError`, and `HardwareError` are the platform-side half of the hierarchy, defined here so that user code can catch one class per failure mode whichever backend is in use; of the five, only `UnsupportedOperationError` is raised in this repository, by `ReferencePlatform.execute`. [Errors](errors.md) covers when each one fires and what it carries. ::: qprogram.QProgramError options: diff --git a/docs/reference/errors.md b/docs/reference/errors.md index f08b561..b4d78c4 100644 --- a/docs/reference/errors.md +++ b/docs/reference/errors.md @@ -1,20 +1,8 @@ # Errors -QProgram has a single-rooted exception hierarchy. Everything the library raises -about a program, and everything a platform raises through the contract, is a -subclass of `QProgramError`, so one `except` covers the lot. Catch the level of -granularity you need. - -Argument types are the exception, and they raise a plain `TypeError` on purpose, -because a value with no expression or waveform form is a Python type error -rather than a fact about a program. Two families are documented. Expression -construction rejects an operand it cannot represent, so a `bool` where an -`Expression` belongs, or a `Variable` in a context that calls `bool()` on it, -comes back as a `TypeError` whose message names the alternative to write; see -[Comparisons and logical combination](../guide/variables.md#comparisons-and-logical-combination). -A malformed inline waveform constructor in a `.qp` file escapes as the waveform -class's own `TypeError`, described under -[Parse-time errors](#parse-time-errors). +QProgram has a single-rooted exception hierarchy. Everything the library raises about a program, and everything a platform raises through the contract, is a subclass of `QProgramError`, so one `except` covers the lot. Catch the level of granularity you need. + +Argument types are the exception, and they raise a plain `TypeError` on purpose, because a value with no expression or waveform form is a Python type error rather than a fact about a program. Two families are documented. Expression construction rejects an operand it cannot represent, so a `bool` where an `Expression` belongs, or a `Variable` in a context that calls `bool()` on it, comes back as a `TypeError` whose message names the alternative to write; see [Comparisons and logical combination](../guide/variables.md#comparisons-and-logical-combination). A malformed inline waveform constructor in a `.qp` file escapes as the waveform class's own `TypeError`, described under [Parse-time errors](#parse-time-errors). ## The hierarchy @@ -33,15 +21,7 @@ QProgramError HardwareError ``` -Every class here is defined in `src/qprogram/errors.py` except `ParseError`, -which lives in `qprogram.serialization.parser` because it is part of the -parser's own surface. All twelve are re-exported at the top level, so -`qp.ValidationError` and `qp.ParseError` both resolve. `qp.loads`, `qp.load`, -and `qp.ParseError` come through a module-level `__getattr__` on first -attribute access rather than at import time, because the parser imports -`QProgram` and importing it eagerly from `qprogram/__init__.py` would close a -cycle. `qp.errors` reaches the module holding the other eleven; -`qp.errors.ParseError` does not exist. +Every class here is defined in `src/qprogram/errors.py` except `ParseError`, which lives in `qprogram.serialization.parser` because it is part of the parser's own surface. All twelve are re-exported at the top level, so `qp.ValidationError` and `qp.ParseError` both resolve. `qp.loads`, `qp.load`, and `qp.ParseError` come through a module-level `__getattr__` on first attribute access rather than at import time, because the parser imports `QProgram` and importing it eagerly from `qprogram/__init__.py` would close a cycle. `qp.errors` reaches the module holding the other eleven; `qp.errors.ParseError` does not exist. ## Choosing what to catch @@ -60,10 +40,7 @@ cycle. `qp.errors` reaches the module holding the other eleven; ### `ValidationError` -Raised while a program is being assembled, whenever an operation, block, or -sweep source rejects its arguments. The checks run in the constructor or the -builder method, not in a later pass, so the traceback points at the line that -built the offending node. +Raised while a program is being assembled, whenever an operation, block, or sweep source rejects its arguments. The checks run in the constructor or the builder method, not in a later pass, so the traceback points at the line that built the offending node. | Module | What it rejects | |---|---| @@ -77,8 +54,7 @@ built the offending node. | `qprogram.result` | An empty `MeasurementHandle` name, and `QProgramResult.get(field=None)` | | `qprogram.waveform_library` | An empty waveform name, and a `WaveformLibrary.set()` whose `element`/`idx`/`kind` combination matches none of the three tiers | -A message names the offending value and the fix rather than the rule that was -broken: +A message names the offending value and the fix rather than the rule that was broken: ```python import qprogram as qp @@ -93,8 +69,7 @@ program.play(q[0].drive, qp.waveforms.Square(0.5, 100)) # instead. ``` -Three more, from a `measure()` on a drive bus, a `|` composition whose loops -run different numbers of iterations, and an unknown measurement field: +Three more, from a `measure()` on a drive bus, a `|` composition whose loops run different numbers of iterations, and an unknown measurement field: ``` ValidationError: Bus 'q0/drive' does not support acquisition @@ -109,18 +84,11 @@ ValidationError: unknown measurement field(s) ['bogus']. Known fields: `measure.fields.` via qprogram.protocol.register_capability_tokens. ``` -The base `ValidationError` does not extend `ValueError`. Construction -validation is common enough in this library that inheriting `ValueError` -would turn a generic `except ValueError` into an accidental catch-all for it. -Catch `qp.ValidationError` or `qp.QProgramError` instead. +The base `ValidationError` does not extend `ValueError`. Construction validation is common enough in this library that inheriting `ValueError` would turn a generic `except ValueError` into an accidental catch-all for it. Catch `qp.ValidationError` or `qp.QProgramError` instead. ### `InvalidVariableIdError` -A `Variable.id` fails the identifier rules. Two flavors share the class: a -pattern failure, where the id does not match `[A-Za-z_][A-Za-z0-9_]*`, and a -reserved keyword, where the id matches the pattern but is one of -[the reserved keywords](reserved.md). The `reserved` attribute distinguishes -them, and `id` carries the offending string: +A `Variable.id` fails the identifier rules. Two flavors share the class: a pattern failure, where the id does not match `[A-Za-z_][A-Za-z0-9_]*`, and a reserved keyword, where the id matches the pattern but is one of [the reserved keywords](reserved.md). The `reserved` attribute distinguishes them, and `id` carries the offending string: ```python import qprogram as qp @@ -132,9 +100,7 @@ except qp.InvalidVariableIdError as e: print((e.id, e.reserved)) # ('if', True) ``` -Both messages suggest the fix. The reserved one proposes appending `_var` and -points at the optional `label` argument for the human-readable name; the -pattern one spells the regular expression out: +Both messages suggest the fix. The reserved one proposes appending `_var` and points at the optional `label` argument for the human-readable name; the pattern one spells the regular expression out: ``` Variable id 'if' is reserved for future QProgram syntax (see @@ -146,14 +112,11 @@ digits, underscores only; cannot start with a digit, no spaces or special characters). Use the optional `label` for human-readable names. ``` -The class also subclasses `ValueError`, so `except ValueError` around -variable construction catches an invalid identifier as well. +The class also subclasses `ValueError`, so `except ValueError` around variable construction catches an invalid identifier as well. ### `UnassignedVariableError` -`Expression.evaluate_or_raise()` ran while at least one variable in the -expression was still unbound. The error carries the expression and the set of -free variables: +`Expression.evaluate_or_raise()` ran while at least one variable in the expression was still unbound. The error carries the expression and the set of free variables: ```python import qprogram as qp @@ -169,36 +132,17 @@ except qp.UnassignedVariableError as e: e.free_variables # {freq} ``` -The message reads -`Cannot evaluate expression : unassigned variable(s) `. The same -error comes out of `qp.simulate` when an operation holds an expression that no -enclosing loop binds, since the reference executor evaluates every operand -before it runs the operation. Like `InvalidVariableIdError`, this class -subclasses `ValueError` too. +The message reads `Cannot evaluate expression : unassigned variable(s) `. The same error comes out of `qp.simulate` when an operation holds an expression that no enclosing loop binds, since the reference executor evaluates every operand before it runs the operation. Like `InvalidVariableIdError`, this class subclasses `ValueError` too. ## Write-time errors -QProgram writes two formats, and both go through the same exception. -`qp.dumps` and `qp.save` write a program as `.qp`; -[`WaveformLibrary`](api-qprogram.md#qprogram.WaveformLibrary) has its own -`dumps` and `save`, which write a calibration library as `.wfl`. +QProgram writes two formats, and both go through the same exception. `qp.dumps` and `qp.save` write a program as `.qp`; [`WaveformLibrary`](api-qprogram.md#qprogram.WaveformLibrary) has its own `dumps` and `save`, which write a calibration library as `.wfl`. ### `SerializationError` -Raised instead of emitting output that is lossy or would not parse back. On -the `.qp` side that covers an operation or block class that was never -registered with the serialization registry, a vendor operation whose -extension never called `register_vendor_version`, an attribute value with no -`.qp` representation (a dict with non-string keys, an array with more than -one dimension), a fragment passed to `dumps` directly instead of the program -that calls it, a fragment call with an unbound parameter, two different -fragments with the same name reachable from one program, and a measurement -name that cannot survive the unquoted `name.field` wire form of a conditional -reference. On the `.wfl` side it covers an entry whose waveform is not -concrete. - -Arrays are never truncated: `Arbitrary` samples and `Values` sweeps are -written in full, and the text reparses to an equal program. +Raised instead of emitting output that is lossy or would not parse back. On the `.qp` side that covers an operation or block class that was never registered with the serialization registry, a vendor operation whose extension never called `register_vendor_version`, an attribute value with no `.qp` representation (a dict with non-string keys, an array with more than one dimension), a fragment passed to `dumps` directly instead of the program that calls it, a fragment call with an unbound parameter, two different fragments with the same name reachable from one program, and a measurement name that cannot survive the unquoted `name.field` wire form of a conditional reference. On the `.wfl` side it covers an entry whose waveform is not concrete. + +Arrays are never truncated: `Arbitrary` samples and `Values` sweeps are written in full, and the text reparses to an equal program. ``` SerializationError: Cannot serialize operation class 'MyOp': it is not @@ -216,10 +160,7 @@ WaveformLibrary must hold concrete waveforms (no Variables / symbolic parameters). Underlying error: 'v' ``` -A clean `dumps` is not by itself a promise that the text parses back. The -writer emits an expression wherever the AST holds one, including inside a -waveform or sweep-source constructor argument, and the parser does not accept -an expression in that position: +A clean `dumps` is not by itself a promise that the text parses back. The writer emits an expression wherever the AST holds one, including inside a waveform or sweep-source constructor argument, and the parser does not accept an expression in that position: ```python import qprogram as qp @@ -236,30 +177,11 @@ qp.loads(text) # ParseError: Unknown waveform or sweep source type: sin ``` -`Gaussian(amplitude=(phi * 2), ...)` fails in the same place, with an empty -class name in the message: any argument containing a `(` is routed to the -constructor parser, and the class name it reads is the text before the -opening bracket, which here is nothing at all. Keep constructor -arguments to numbers, quoted strings, and bare variable references, the -shapes [the format documents](qp-format.md#inline-waveform-constructors), and -a file that writes without a `SerializationError` parses back into an equal -program. Nothing in the writer checks this for you. +`Gaussian(amplitude=(phi * 2), ...)` fails in the same place, with an empty class name in the message: any argument containing a `(` is routed to the constructor parser, and the class name it reads is the text before the opening bracket, which here is nothing at all. Keep constructor arguments to numbers, quoted strings, and bare variable references, the shapes [the format documents](qp-format.md#inline-waveform-constructors), and a file that writes without a `SerializationError` parses back into an equal program. Nothing in the writer checks this for you. ## Parse-time errors -`ParseError` is what `qp.load` and `qp.loads` raise on a `.qp` document that -does not follow the grammar or fails a compatibility check, and what -`WaveformLibrary.load` and `WaveformLibrary.loads` raise on a `.wfl` -document. Compatibility accounts for the first group of `.qp` cases: a -missing `#!QProgram` header, a header whose version is newer than the parser's -or is not `major.minor`, a `require` declaration that cannot be satisfied, and `require` -lines that do not sit directly after the header. The rest are grammar: -a second `schema:` declaration, a schema with no elements or a malformed -`info=` value, a bus path that does not resolve against the schema, a -duplicate `var` id, a fragment defined after `body:` or called before it is -defined, an `elif` or `else` without a matching `if`, an unknown operation or -block keyword, an unknown sweep source, and an argument list that does not fit -the signature. +`ParseError` is what `qp.load` and `qp.loads` raise on a `.qp` document that does not follow the grammar or fails a compatibility check, and what `WaveformLibrary.load` and `WaveformLibrary.loads` raise on a `.wfl` document. Compatibility accounts for the first group of `.qp` cases: a missing `#!QProgram` header, a header whose version is newer than the parser's or is not `major.minor`, a `require` declaration that cannot be satisfied, and `require` lines that do not sit directly after the header. The rest are grammar: a second `schema:` declaration, a schema with no elements or a malformed `info=` value, a bus path that does not resolve against the schema, a duplicate `var` id, a fragment defined after `body:` or called before it is defined, an `elif` or `else` without a matching `if`, an unknown operation or block keyword, an unknown sweep source, and an argument list that does not fit the signature. ``` ParseError: Line 1: Missing #!QProgram header @@ -274,16 +196,9 @@ myvendor 1.2.0 — install myvendor 99.0 or newer ParseError: Line 2: file version '1.9.1' must be exactly major.minor ``` -A `require` line asks for a `major.minor`, and anything the installed extension -cannot provide is refused. An older version loads, with that extension's -migrations applied to the body first. +A `require` line asks for a `major.minor`, and anything the installed extension cannot provide is refused. An older version loads, with that extension's migrations applied to the body first. -An id declared in a `.qp` file is checked twice, and the two failures come -back differently. A malformed id is rejected by the parser's own pattern -check, so `var 1x` raises a `ParseError` carrying the line number. A -reserved id passes that check and is rejected by the `Variable` constructor -instead, so `var if` raises `InvalidVariableIdError` with no line -information: +An id declared in a `.qp` file is checked twice, and the two failures come back differently. A malformed id is rejected by the parser's own pattern check, so `var 1x` raises a `ParseError` carrying the line number. A reserved id passes that check and is rejected by the `Variable` constructor instead, so `var if` raises `InvalidVariableIdError` with no line information: ```python import qprogram as qp @@ -297,10 +212,7 @@ qp.loads("#!QProgram 0.2\n\nbody:\n var if\n") # syntax ... ``` -Inline constructors are where an argument-list mistake leaves the hierarchy -entirely. The parser hands the arguments it read straight to the waveform -class, so a missing or misspelled constructor argument surfaces as that -class's own `TypeError`: +Inline constructors are where an argument-list mistake leaves the hierarchy entirely. The parser hands the arguments it read straight to the waveform class, so a missing or misspelled constructor argument surfaces as that class's own `TypeError`: ```python import qprogram as qp @@ -310,24 +222,11 @@ qp.loads('#!QProgram 0.2\n\nbody:\n play "b" Gaussian(amplitude=0.5)\n') # 'duration' and 'sigma' ``` -A sweep source nested inside a combinator's argument list escapes the same -way, because it reaches the class through the same argument parser. Only the -outermost sweep-source constructor has its `TypeError` wrapped, so -`for x in Range(start=0):` is a `ParseError` carrying the line number while -`for x in Concat(sources=[Range(start=0)]):` is a bare `TypeError` naming the -missing `stop` argument. - -Catch `(qp.ParseError, TypeError)` around `load` and `loads` if you are -parsing files you did not write. - -Most messages name the 1-based line number and carry it separately as -`ParseError.line_num`, and the string form gains a `Line N: ` prefix when -`line_num` is non-zero. Two raise sites in the parser omit it, both in -helpers that run below the line loop and have no view of the cursor: the -unknown-class check in `_parse_waveform_expr`, and the operand promotion in -`_to_expression`. Everything else does carry a line, including the sweep-source -lookup on a `for` header, which is the near twin of the waveform lookup. So two -almost identical mistakes read differently: +A sweep source nested inside a combinator's argument list escapes the same way, because it reaches the class through the same argument parser. Only the outermost sweep-source constructor has its `TypeError` wrapped, so `for x in Range(start=0):` is a `ParseError` carrying the line number while `for x in Concat(sources=[Range(start=0)]):` is a bare `TypeError` naming the missing `stop` argument. + +Catch `(qp.ParseError, TypeError)` around `load` and `loads` if you are parsing files you did not write. + +Most messages name the 1-based line number and carry it separately as `ParseError.line_num`, and the string form gains a `Line N: ` prefix when `line_num` is non-zero. Two raise sites in the parser omit it, both in helpers that run below the line loop and have no view of the cursor: the unknown-class check in `_parse_waveform_expr`, and the operand promotion in `_to_expression`. Everything else does carry a line, including the sweep-source lookup on a `for` header, which is the near twin of the waveform lookup. So two almost identical mistakes read differently: ```python import qprogram as qp @@ -351,12 +250,7 @@ So treat `line_num == 0` as "no line attributed", not as "whole-file error". ## Vendor extension activation -`VendorActivationError` is raised by `qp.try_activate_vendor(name)` when a -`qprogram.vendors` entry point claims `name` but its import target raises, or -imports without calling `register_vendor_version`. The extension is installed -and broken, which is a different failure from not being installed at all: -`try_activate_vendor` returns `False` in that case and leaves the decision to -the caller. +`VendorActivationError` is raised by `qp.try_activate_vendor(name)` when a `qprogram.vendors` entry point claims `name` but its import target raises, or imports without calling `register_vendor_version`. The extension is installed and broken, which is a different failure from not being installed at all: `try_activate_vendor` returns `False` in that case and leaves the decision to the caller. ``` VendorActivationError: vendor extension for 'myvendor' is installed (entry @@ -368,28 +262,15 @@ the package must call register_vendor_version('myvendor', '') on import ``` -Reading a `.qp` file whose `require` line names a vendor triggers activation -by default, and the parser wraps any `VendorActivationError` in a -`ParseError` carrying the `require` line's number. Passing -`auto_activate=False` to `qp.loads` or `qp.load` turns the discovery off, in -which case an unregistered vendor is a `ParseError` whose hint asks you to -import the extension yourself. +Reading a `.qp` file whose `require` line names a vendor triggers activation by default, and the parser wraps any `VendorActivationError` in a `ParseError` carrying the `require` line's number. Passing `auto_activate=False` to `qp.loads` or `qp.load` turns the discovery off, in which case an unregistered vendor is a `ParseError` whose hint asks you to import the extension yourself. ## Platform-side errors -These five classes give platforms one hierarchy to report failures through, -so the catch surface is uniform across backends. Four of them -(`BusNotAvailableError`, `WaveformResolutionError`, `CompilationError`, and -`HardwareError`) are defined in `qprogram` and raised only by platforms; no -core code path raises them. `UnsupportedOperationError` is the exception: -core raises it too. +These five classes give platforms one hierarchy to report failures through, so the catch surface is uniform across backends. Four of them (`BusNotAvailableError`, `WaveformResolutionError`, `CompilationError`, and `HardwareError`) are defined in `qprogram` and raised only by platforms; no core code path raises them. `UnsupportedOperationError` is the exception: core raises it too. ### `UnsupportedOperationError` -The platform cannot run an operation as written. Core raises it from -`ReferencePlatform.execute()`, the engine behind `qp.simulate`, on any -`severity="error"` diagnostic the validator reports, listing every one in the -message: +The platform cannot run an operation as written. Core raises it from `ReferencePlatform.execute()`, the engine behind `qp.simulate`, on any `severity="error"` diagnostic the validator reports, listing every one in the message: ```python import qprogram as qp @@ -410,43 +291,25 @@ qp.simulate(program) # MeasurementField.STATE to fields=) (at body[1]) ``` -Each line is a `Diagnostic` rendered as `[severity] code: message (at path)`; -the ten codes the validator emits are tabulated with their severities and the -condition that produces each under -[Diagnostics](../guide/capabilities.md#diagnostics). +Each line is a `Diagnostic` rendered as `[severity] code: message (at path)`; the ten codes the validator emits are tabulated with their severities and the condition that produces each under [Diagnostics](../guide/capabilities.md#diagnostics). -A hardware backend raises it for the same reason, and for anything it cannot -lower: a vendor operation it does not implement, a control-flow construct its -compiler does not support. +A hardware backend raises it for the same reason, and for anything it cannot lower: a vendor operation it does not implement, a control-flow construct its compiler does not support. ### `BusNotAvailableError` -The program references a bus name the backend does not expose. The program is -structurally well-formed; it just does not fit this particular platform. A -bus problem caught while the program is being built is a `ValidationError` -instead. +The program references a bus name the backend does not expose. The program is structurally well-formed; it just does not fit this particular platform. A bus problem caught while the program is being built is a `ValidationError` instead. ### `WaveformResolutionError` -A string waveform alias reached execution without a concrete waveform behind -it, usually a name missing from `QProgram.with_waveforms` or from the -`WaveformLibrary` that fed it. The reference platform does not raise this, -because it models measurements only and never reads waveform content: -`qp.simulate` on a program full of unresolved aliases returns a -`QProgramResult` as usual. +A string waveform alias reached execution without a concrete waveform behind it, usually a name missing from `QProgram.with_waveforms` or from the `WaveformLibrary` that fed it. The reference platform does not raise this, because it models measurements only and never reads waveform content: `qp.simulate` on a program full of unresolved aliases returns a `QProgramResult` as usual. ### `CompilationError` -A backend-internal failure produced an invalid lowered representation: -timing constraints not satisfied, resource over-allocation, code-generation -bugs, anything that surfaces during compilation but does not fit the other -classes. +A backend-internal failure produced an invalid lowered representation: timing constraints not satisfied, resource over-allocation, code-generation bugs, anything that surfaces during compilation but does not fit the other classes. ### `HardwareError` -Runtime failure at the instrument level: driver errors, SCPI failures, lost -trigger pulses. Anything raised during execution rather than at compile or -validate time. +Runtime failure at the instrument level: driver errors, SCPI failures, lost trigger pulses. Anything raised during execution rather than at compile or validate time. ## Which error to expect @@ -475,34 +338,12 @@ validate time. ## Why two parents on some classes -`InvalidVariableIdError` and `UnassignedVariableError` inherit from both -`ValidationError` and `ValueError`. Each reports a value that is wrong on its -own terms: an identifier that is not a legal identifier, an expression with -no number to compute. That is exactly what `ValueError` means in Python, so -both spellings catch them. Every other class in the hierarchy descends from -`QProgramError` alone. +`InvalidVariableIdError` and `UnassignedVariableError` inherit from both `ValidationError` and `ValueError`. Each reports a value that is wrong on its own terms: an identifier that is not a legal identifier, an expression with no number to compute. That is exactly what `ValueError` means in Python, so both spellings catch them. Every other class in the hierarchy descends from `QProgramError` alone. ## `Diagnostic` is not an exception -The validator surface lives next door, but it is not part of this hierarchy. -`qp.validate(program, caps)` returns a tuple `(list[Diagnostic], -ExecutionPlan)` rather than raising. The list comes back, the caller decides -what to do. A `Diagnostic` is a frozen dataclass with `severity` -(`"error"`, `"warning"`, or `"info"`), `code`, `message`, `node`, `path`, -`capability`, `limit`, and `domain` fields. - -Platforms typically translate any `severity="error"` diagnostic into one of -the platform-side exceptions above, `UnsupportedOperationError` being the -usual choice, so end users see one consistent error class regardless of which -axis tripped. A `severity="warning"` diagnostic means the program runs but in -a degraded way; `ReferencePlatform.execute` passes those to -`warnings.warn` as a `qp.ExecutionWarning` rather than raising, which is how -the `"forced-host"` notice on a block that lost real-time dispatch reaches -the caller. `severity="info"` diagnostics, such as the -`"reorderable-averaging"` hint, are neither raised nor warned; they come back -in the list as advisory output. - -See [Capabilities, diagnostics, and profiles](../guide/capabilities.md) -for the validator walkthrough, and -[Diagnostics](../guide/capabilities.md#diagnostics) for the ten codes with -their severities and producing conditions. +The validator surface lives next door, but it is not part of this hierarchy. `qp.validate(program, caps)` returns a tuple `(list[Diagnostic], ExecutionPlan)` rather than raising. The list comes back, the caller decides what to do. A `Diagnostic` is a frozen dataclass with `severity` (`"error"`, `"warning"`, or `"info"`), `code`, `message`, `node`, `path`, `capability`, `limit`, and `domain` fields. + +Platforms typically translate any `severity="error"` diagnostic into one of the platform-side exceptions above, `UnsupportedOperationError` being the usual choice, so end users see one consistent error class regardless of which axis tripped. A `severity="warning"` diagnostic means the program runs but in a degraded way; `ReferencePlatform.execute` passes those to `warnings.warn` as a `qp.ExecutionWarning` rather than raising, which is how the `"forced-host"` notice on a block that lost real-time dispatch reaches the caller. `severity="info"` diagnostics, such as the `"reorderable-averaging"` hint, are neither raised nor warned; they come back in the list as advisory output. + +See [Capabilities, diagnostics, and profiles](../guide/capabilities.md) for the validator walkthrough, and [Diagnostics](../guide/capabilities.md#diagnostics) for the ten codes with their severities and producing conditions. diff --git a/docs/reference/index.md b/docs/reference/index.md index a013260..4f9d3ba 100644 --- a/docs/reference/index.md +++ b/docs/reference/index.md @@ -1,10 +1,6 @@ # Reference -Four pages of normative material: the two text formats the package reads and -writes, the identifiers the DSL holds back, the exception hierarchy, and the -generated API listing. Where one of these pages disagrees with a guide page, -this section is the one to trust. Where one of them disagrees with `src/`, the -source wins, and the page is a bug. +Four pages of normative material: the two text formats the package reads and writes, the identifiers the DSL holds back, the exception hierarchy, and the generated API listing. Where one of these pages disagrees with a guide page, this section is the one to trust. Where one of them disagrees with `src/`, the source wins, and the page is a bug. | Page | What it fixes | |---|---| @@ -13,17 +9,6 @@ source wins, and the page is a bug. | [Errors](errors.md) | The `QProgramError` hierarchy, which call raises which, and the two families of argument error that stay outside it as a plain `TypeError`. | | [API reference](api-qprogram.md) | Signatures and docstrings for the names in `qprogram.__all__`, plus the submodule classes and extension points the guides name, rendered from `src/` by mkdocstrings. | -Three of the four are checkable against the package at runtime. The format -version comes from `qprogram.serialization._format.FORMAT_VERSION`, currently -`"0.2"` since it follows the library version truncated to `major.minor`, and is -what the writer emits in the `#!QProgram` header and what the parser compares a -file's major version against. The canonical grammar ships as -`src/qprogram/grammar/qp.lark` and is readable with -`qprogram.grammar.grammar_text()`. The reserved set is `qp.RESERVED_KEYWORDS`. -The API page is generated from one mkdocstrings directive per symbol, so a new -public name needs a directive rather than a hand-written signature. +Three of the four are checkable against the package at runtime. The format version comes from `qprogram.serialization._format.FORMAT_VERSION`, currently `"0.2"` since it follows the library version truncated to `major.minor`, and is what the writer emits in the `#!QProgram` header and what the parser compares a file's major version against. The canonical grammar ships as `src/qprogram/grammar/qp.lark` and is readable with `qprogram.grammar.grammar_text()`. The reserved set is `qp.RESERVED_KEYWORDS`. The API page is generated from one mkdocstrings directive per symbol, so a new public name needs a directive rather than a hand-written signature. -These pages describe the observable surface and not the reasoning behind it. -For how the AST, the capability protocol, and the serializer are put together, -read the [developer guide](../developer/index.md), starting with -[Architecture](../developer/architecture.md). +These pages describe the observable surface and not the reasoning behind it. For how the AST, the capability protocol, and the serializer are put together, read the [developer guide](../developer/index.md), starting with [Architecture](../developer/architecture.md). diff --git a/docs/reference/qp-format.md b/docs/reference/qp-format.md index 46334ff..ba75eb8 100644 --- a/docs/reference/qp-format.md +++ b/docs/reference/qp-format.md @@ -1,23 +1,12 @@ # .qp file format -The `.qp` format is the text serialization of a QProgram. It tracks the Python -API closely: the operations, blocks, waveforms, and sweep sources -`program.play(...)`, `program.sweep(...)` and the rest of the builder produce -all have a written form, you can write that form by hand, and `qp.load` -rebuilds the program from it. The format is indentation-based, line-oriented, -and parses with no external dependency. - -The correspondence has one hole, in the builder's favor. The builder accepts -an expression anywhere a number goes, including inside a waveform or -sweep-source constructor argument; the writer emits it; the parser rejects it -there (see [Inline waveform constructors](#inline-waveform-constructors)). -Keep constructor arguments to numbers, strings, and bare variable references -and the round trip is exact. +The `.qp` format is the text serialization of a QProgram. It tracks the Python API closely: the operations, blocks, waveforms, and sweep sources `program.play(...)`, `program.sweep(...)` and the rest of the builder produce all have a written form, you can write that form by hand, and `qp.load` rebuilds the program from it. The format is indentation-based, line-oriented, and parses with no external dependency. + +The correspondence has one hole, in the builder's favor. The builder accepts an expression anywhere a number goes, including inside a waveform or sweep-source constructor argument; the writer emits it; the parser rejects it there (see [Inline waveform constructors](#inline-waveform-constructors)). Keep constructor arguments to numbers, strings, and bare variable references and the round trip is exact. ## Top-level layout -A file opens with the header, then carries up to four kinds of declaration -ahead of the body: +A file opens with the header, then carries up to four kinds of declaration ahead of the body: ``` #!QProgram 0.2 @@ -33,10 +22,7 @@ body: # the program itself ... ``` -That is the order the writer emits. The parser enforces three of the ordering -rules: `require` lines sit directly after the header, a `fragment` section -precedes `body:`, and there is at most one `schema:`. Breaking one of the three -stops the parse, each on the offending line: +That is the order the writer emits. The parser enforces three of the ordering rules: `require` lines sit directly after the header, a `fragment` section precedes `body:`, and there is at most one `schema:`. Breaking one of the three stops the parse, each on the offending line: ``` `require` declarations must appear directly after the header, before any @@ -45,13 +31,9 @@ fragment definitions must appear before the `body:` section duplicate schema declaration — a program may have at most one schema ``` -Everything else is tolerated. `metadata:` and `schema:` may follow `body:`, a -second `body:` section appends its statements to the same body rather than -starting a new one, and a file whose body is empty, or which has no `body:` -section at all, parses to an empty program. +Everything else is tolerated. `metadata:` and `schema:` may follow `body:`, a second `body:` section appends its statements to the same body rather than starting a new one, and a file whose body is empty, or which has no `body:` section at all, parses to an empty program. -A top-level line that is none of these is a hard error rather than a skipped -line, so a typo such as `bodyy:` cannot load as an empty-but-valid program: +A top-level line that is none of these is a hard error rather than a skipped line, so a typo such as `bodyy:` cannot load as an empty-but-valid program: ``` unexpected top-level line 'bodyy:'; expected `metadata:`, `schema:`, @@ -60,14 +42,9 @@ unexpected top-level line 'bodyy:'; expected `metadata:`, `schema:`, ## Header -The header is exactly `#!QProgram .`, matched by the terminal -`/#!QProgram[ \t]+[0-9]+\.[0-9]+/`. Blank lines before it are skipped. +The header is exactly `#!QProgram .`, matched by the terminal `/#!QProgram[ \t]+[0-9]+\.[0-9]+/`. Blank lines before it are skipped. -The running format version is `qprogram.serialization._format.FORMAT_VERSION`, -currently `"0.2"`. A file at that version parses directly, and an older one is -migrated on the way in (see [Versioning](#versioning)). A newer version, or a -header whose version is not exactly two integer components — a bare major, a -patch, anything else — stops the parse on line 1: +The running format version is `qprogram.serialization._format.FORMAT_VERSION`, currently `"0.2"`. A file at that version parses directly, and an older one is migrated on the way in (see [Versioning](#versioning)). A newer version, or a header whose version is not exactly two integer components — a bare major, a patch, anything else — stops the parse on line 1: ``` Line 1: Unsupported format version 0.9 @@ -86,31 +63,11 @@ require myvendor 0.1 require othervendor 1.2 ``` -The writer emits a line for every vendor the program touches: across the body -and every fragment definition, counting a vendor whose only contribution is a -block, and reaching vendor operations buried in conditional arms. A file -`qp.dumps` produces is always complete. The parser does not check the -converse: a hand-written file that calls `myvendor.acquire` with no -`require myvendor` line loads without complaint, provided the extension is -already imported. Write the line anyway; it is what makes the file -self-contained. - -What the parser does with the lines it finds is resolve each one against the -installed extension, by the rule the header follows one level up. The version in -the line is exactly `major.minor`: a patch release of an extension changes code, -not the wire form, so `require myvendor 0.1.9` is refused rather than rounded -down. A line asking for more than the installed extension provides is refused. An -older line loads, and the migrations that extension registered between the two -versions rewrite the body first, so a program saved against any earlier release -of the extension goes on loading — an earlier major included. The writer emits -`major.minor` for the same reason the check reads it. A vendor that is not -imported yet is activated on the spot through its `qprogram.vendors` entry point. -That discovery is the reason to write the line: a file missing it never triggers -the import, and the first dotted operation then fails as an unknown vendor -operation instead. - -Each failure stops the parse before the body is read, and names both versions. -Against an installed `myvendor 0.1.3`, the three shapes are: +The writer emits a line for every vendor the program touches: across the body and every fragment definition, counting a vendor whose only contribution is a block, and reaching vendor operations buried in conditional arms. A file `qp.dumps` produces is always complete. The parser does not check the converse: a hand-written file that calls `myvendor.acquire` with no `require myvendor` line loads without complaint, provided the extension is already imported. Write the line anyway; it is what makes the file self-contained. + +What the parser does with the lines it finds is resolve each one against the installed extension, by the rule the header follows one level up. The version in the line is exactly `major.minor`: a patch release of an extension changes code, not the wire form, so `require myvendor 0.1.9` is refused rather than rounded down. A line asking for more than the installed extension provides is refused. An older line loads, and the migrations that extension registered between the two versions rewrite the body first, so a program saved against any earlier release of the extension goes on loading — an earlier major included. The writer emits `major.minor` for the same reason the check reads it. A vendor that is not imported yet is activated on the spot through its `qprogram.vendors` entry point. That discovery is the reason to write the line: a file missing it never triggers the import, and the first dotted operation then fails as an unknown vendor operation instead. + +Each failure stops the parse before the body is read, and names both versions. Against an installed `myvendor 0.1.3`, the three shapes are: ``` Line 3: file requires myvendor 1.0, newer than the installed myvendor 0.1.3 @@ -124,8 +81,7 @@ registered in this environment — install the package that declares the before loading ``` -`qp.loads(text, auto_activate=False)` turns entry-point discovery off, and the -third message then ends differently: +`qp.loads(text, auto_activate=False)` turns entry-point discovery off, and the third message then ends differently: ``` Line 3: file requires vendor 'othervendor' 0.1 but no matching extension is @@ -133,37 +89,24 @@ registered in this environment — auto-activation is disabled; import the extension before loading (e.g. `import qprogram_othervendor`) ``` -An extension that is installed but fails to import, or that registers no -version, surfaces as a `ParseError` wrapping a `qp.VendorActivationError`. +An extension that is installed but fails to import, or that registers no version, surfaces as a `ParseError` wrapping a `qp.VendorActivationError`. ## Comments -Line comments start with `#`. They can occupy a whole line or trail an -operation: +Line comments start with `#`. They can occupy a whole line or trail an operation: ``` # This is a comment play "drive_q0" "pi_pulse" # inline comment ``` -The scan for the comment marker is quote-aware, and honors `\"` inside a -string, so `play "drive#0" "pi"` keeps its bus name and -`measure "ro" "w" "wt" name="a\"#b"` keeps the measurement name `a"#b`. The -header line is the one place `#` never starts a comment: it is taken whole, so -`#!QProgram 0.2 # note` fails with `Line 1: Unsupported format version note` -rather than parsing as a header with a trailing comment. +The scan for the comment marker is quote-aware, and honors `\"` inside a string, so `play "drive#0" "pi"` keeps its bus name and `measure "ro" "w" "wt" name="a\"#b"` keeps the measurement name `a"#b`. The header line is the one place `#` never starts a comment: it is taken whole, so `#!QProgram 0.2 # note` fails with `Line 1: Unsupported format version note` rather than parsing as a header with a trailing comment. ## Indentation -The writer emits two spaces per nesting level and never a tab. The parser is -looser: a block's body is every following line indented at least two columns -past the header, and the first line indented less ends the block. Over-indent -and the file still loads, normalizing to two spaces when it is written back -out. +The writer emits two spaces per nesting level and never a tab. The parser is looser: a block's body is every following line indented at least two columns past the header, and the first line indented less ends the block. Over-indent and the file still loads, normalizing to two spaces when it is written back out. -Under-indent and it also loads, but it means something else. A body indented -less than two columns past its header binds to the enclosing block instead, and -nothing warns: +Under-indent and it also loads, but it means something else. A body indented less than two columns past its header binds to the enclosing block instead, and nothing warns: ``` #!QProgram 0.2 @@ -183,16 +126,9 @@ body: sync ``` -with an empty `average` block and the `sync` outside it. Indentation is the -only thing carrying block membership, so it is the one part of a hand-written -file worth checking with `python -m qprogram.lsp explain`. +with an empty `average` block and the `sync` outside it. Indentation is the only thing carrying block membership, so it is the one part of a hand-written file worth checking with `python -m qprogram.lsp explain`. -Tabs are counted as one column each by the production parser, which means a -tab-indented statement reads as a dedent rather than as an indent. At the top -level that surfaces as `unexpected top-level line 'play "b" "p"'`, which does -not mention indentation at all. The reference Lark parser is configured with -`tab_len = 8` and accepts tabs, so this is one dimension in which the two -parsers are not interchangeable. Use spaces. +Tabs are counted as one column each by the production parser, which means a tab-indented statement reads as a dedent rather than as an indent. At the top level that surfaces as `unexpected top-level line 'play "b" "p"'`, which does not mention indentation at all. The reference Lark parser is configured with `tab_len = 8` and accepts tabs, so this is one dimension in which the two parsers are not interchangeable. Use spaces. ## Metadata @@ -202,33 +138,20 @@ metadata: description: "Rabi oscillation" ``` -Both fields are optional and both values are quoted strings. Backslash, double -quote, newline, carriage return, and tab are escaped as `\\`, `\"`, `\n`, `\r`, -and `\t`, and unescaped on the way back in, so a label holding a quote or a -newline survives the round trip. +Both fields are optional and both values are quoted strings. Backslash, double quote, newline, carriage return, and tab are escaped as `\\`, `\"`, `\n`, `\r`, and `\t`, and unescaped on the way back in, so a label holding a quote or a newline survives the round trip. -Every other character is carried literally, including the ones some languages -treat as line breaks (`\v`, `\f`, `\x1c`, `\x1d`, `\x1e`, `\x85`, `\u2028`, and -`\u2029`). A line ends at a newline, optionally preceded by a carriage return, -and nowhere else, so a value holding one of those stays on its line. Anything -that reads the format has to split lines on that terminator alone. Python's -`str.splitlines` does not: it breaks on all eight of those characters too. +Every other character is carried literally, including the ones some languages treat as line breaks (`\v`, `\f`, `\x1c`, `\x1d`, `\x1e`, `\x85`, `\u2028`, and `\u2029`). A line ends at a newline, optionally preceded by a carriage return, and nowhere else, so a value holding one of those stays on its line. Anything that reads the format has to split lines on that terminator alone. Python's `str.splitlines` does not: it breaks on all eight of those characters too. -The two defaults differ, which is why the writer's output differs. `label` -defaults to `""` and is omitted when empty; `description` defaults to `None`, -so an explicit empty description is a distinct value and is emitted as -`description: ""`. +The two defaults differ, which is why the writer's output differs. `label` defaults to `""` and is omitted when empty; `description` defaults to `None`, so an explicit empty description is a distinct value and is emitted as `description: ""`. -A line with no colon, and an unquoted `label` or `description`, each report -their own failure: +A line with no colon, and an unquoted `label` or `description`, each report their own failure: ``` Line 4: invalid metadata line 'label rabi'; expected `key: value` Line 4: metadata 'label' must be a quoted string, got: 'rabi' ``` -Any other key is ignored for forward compatibility, and its value is not -checked at all, so `author: someone here` loads and is dropped. +Any other key is ignored for forward compatibility, and its value is not checked at all, so `author: someone here` loads and is dropped. ## Schema declaration @@ -245,17 +168,9 @@ schema: flux info=single ``` -The header is exactly `schema:` with no inline content, and anything else -reports what it expected instead. The optional `naming:` line carries the -pattern, whose placeholders are `{element}`, `{index}`, and `{kind}`; the -writer emits it only when it differs from `BusNaming.DEFAULT_PATTERN`, which is -`"{element}{index}/{kind}"`. One or more `element :` blocks follow, in -declaration order, each listing bus kinds. +The header is exactly `schema:` with no inline content, and anything else reports what it expected instead. The optional `naming:` line carries the pattern, whose placeholders are `{element}`, `{index}`, and `{kind}`; the writer emits it only when it differs from `BusNaming.DEFAULT_PATTERN`, which is `"{element}{index}/{kind}"`. One or more `element :` blocks follow, in declaration order, each listing bus kinds. -Each bus line is ` info=[+acquires]`, where `` is -`single` or `IQ` and `acquires` flags a bus with an ADC. The `info` value is a -`+`-joined token list carrying exactly one channel and at most one flag, and -each way of getting the section wrong has its own message: +Each bus line is ` info=[+acquires]`, where `` is `single` or `IQ` and `acquires` flags a bus with an ADC. The `info` value is a `+`-joined token list carrying exactly one channel and at most one flag, and each way of getting the section wrong has its own message: ``` Line 3: invalid schema declaration: expected `schema:` followed by indented @@ -272,14 +187,9 @@ Line 8: schema `naming` must be a quoted string: 'naming: {element}' Line 8: inline schema has no element declarations ``` -The writer always emits this expanded form, including for the typed factories -such as `qp.BusSchema.transmon()`. Those are construction-time conveniences on -the Python side; recording the structural contents instead means that adding a -bus to a preset, or spelling one of its kinds differently, cannot change the -meaning of a `.qp` file that already exists. +The writer always emits this expanded form, including for the typed factories such as `qp.BusSchema.transmon()`. Those are construction-time conveniences on the Python side; recording the structural contents instead means that adding a bus to a preset, or spelling one of its kinds differently, cannot change the meaning of a `.qp` file that already exists. -Programs without a schema omit the section, and bus references in the body stay -as quoted strings. +Programs without a schema omit the section, and bus references in the body stay as quoted strings. ## Bus references in operations @@ -290,22 +200,11 @@ play q[0].drive "pulse" # schema-backed path play "drive_q0_raw" "pulse" # plain string ``` -The path form is `[].`, tokenized whole. The index is an -integer (`q[0]`) or a comma-separated tuple (`c[0,1]`), with no spaces inside -the brackets. +The path form is `[].`, tokenized whole. The index is an integer (`q[0]`) or a comma-separated tuple (`c[0,1]`), with no spaces inside the brackets. -Promotion from the written token back to a typed `qp.BusRef` runs only on the -attributes an operation declares in its `BUS_ATTRS`, which is `("bus",)` for -every core operation except `sync`, whose list lives under `("targets",)`, and -`Call`, which declares none. Restricting it that way is what keeps a quoted -string that merely looks like a path, such as a vendor parameter alias -`"cluster[0].module"`, from turning into a bus reference on reload. Quoting -carries the same distinction for buses: a quoted `"q[0].drive"` stays the -string it was written as, and only the bare form is promoted. +Promotion from the written token back to a typed `qp.BusRef` runs only on the attributes an operation declares in its `BUS_ATTRS`, which is `("bus",)` for every core operation except `sync`, whose list lives under `("targets",)`, and `Call`, which declares none. Restricting it that way is what keeps a quoted string that merely looks like a path, such as a vendor parameter alias `"cluster[0].module"`, from turning into a bus reference on reload. Quoting carries the same distinction for buses: a quoted `"q[0].drive"` stays the string it was written as, and only the bare form is promoted. -Promotion also requires the program to declare a schema. Without one, every -bus stays exactly as written. With one, the element and the bus kind must both -be declared, and the failure names what is available instead: +Promotion also requires the program to declare a schema. Without one, every bus stays exactly as written. With one, the element and the bus kind must both be declared, and the failure names what is available instead: ``` Line 8: bus path 'r[0].drive' does not resolve against the program schema: No @@ -314,20 +213,13 @@ Line 8: bus path 'q[0].flux' does not resolve against the program schema: 'q' has no bus 'flux'. Available: drive ``` -The index is not checked, because a schema declares element kinds rather than -a count: `q[9].drive` resolves against a schema that declares one `q` element, -and a tuple index joins with an underscore in the resolved name, so -`c[0,1].flux` under the default naming pattern is the bus `c0_1/flux`. +The index is not checked, because a schema declares element kinds rather than a count: `q[9].drive` resolves against a schema that declares one `q` element, and a tuple index joins with an underscore in the resolved name, so `c[0,1].flux` under the default naming pattern is the bus `c0_1/flux`. Plain string buses bypass schema validation entirely. Mix the two freely. ## Variable declarations -To reference a variable, declare it. The `var` declaration is what turns a -bare identifier in an argument position into a `qp.Variable`; an identifier -with no declaration decodes as a plain string instead, with no error. With no -`var pi_pulse` in the file, `play "b" pi_pulse` still parses; the waveform is -the string `"pi_pulse"`, and it writes back out as `play "b" "pi_pulse"`. +To reference a variable, declare it. The `var` declaration is what turns a bare identifier in an argument position into a `qp.Variable`; an identifier with no declaration decodes as a plain string instead, with no error. With no `var pi_pulse` in the file, `play "b" pi_pulse` still parses; the waveform is the string `"pi_pulse"`, and it writes back out as `play "b" "pi_pulse"`. ``` body: @@ -340,20 +232,14 @@ body: The rules on the identifier and its attributes: - `id` matches `[A-Za-z_][A-Za-z0-9_]*` (Python identifier rules). -- `id` is unique within the file, and within a fragment for a fragment-local - declaration. +- `id` is unique within the file, and within a fragment for a fragment-local declaration. - `id` is not one of the [reserved keywords](reserved.md). -- The optional attributes are exactly `label`, `units`, and `description`, - written as quoted `key="value"` pairs in any order on the same line, each at - most once. +- The optional attributes are exactly `label`, `units`, and `description`, written as quoted `key="value"` pairs in any order on the same line, each at most once. - Attribute values use the same escapes as metadata values. -A loop header may name a variable that no `var` line declares. The parser -declares it on demand, so the loop works and `qp.dumps` writes the missing -declaration out. +A loop header may name a variable that no `var` line declares. The parser declares it on demand, so the loop works and `qp.dumps` writes the missing declaration out. -The failures divide by who raises them. A malformed declaration is a -`ParseError` carrying the line number: +The failures divide by who raises them. A malformed declaration is a `ParseError` carrying the line number: ``` Line 4: `var` declaration must have the form `var [label="..."] @@ -367,9 +253,7 @@ Line 4: duplicate variable attribute 'label' Line 5: duplicate variable id 'amp' ``` -A reserved id is different. The declaration is well formed, and it is the -program under construction that rejects it, so `var if` raises -`qp.InvalidVariableIdError` with no line number at all: +A reserved id is different. The declaration is well formed, and it is the program under construction that rejects it, so `var if` raises `qp.InvalidVariableIdError` with no line number at all: ``` Variable id 'if' is reserved for future QProgram syntax (see @@ -381,22 +265,9 @@ Both `qp.loads` and `qp.load` document that escape from `ParseError`. ## Operations -One operation per line. Quoting is the type distinction: a quoted token is a -plain string (a raw bus name, a waveform alias that `with_waveforms` resolves -later from a [waveform library](#the-wfl-format), a parameter name) and a bare -token is a variable reference, a schema-backed bus path, or a measurement field -reference. Numeric arguments are -decimal integers, floats, or scientific notation, and the parser preserves the -distinction between `40` and `40.0` so a rewrite does not silently promote an -integer to a float. - -There are eleven core operation keywords. The syntax of most follows its -constructor signature, so the table is the signature written on the wire: -parameters with no default appear positionally in declaration order, and -parameters with a default appear as `key=value`, and only when the value -differs from that default. Three are special-cased instead: `measure` writes -its handle as `name=`, `sync` writes a variadic bus list, and `get_parameter` -writes its target variable after the `->`. +One operation per line. Quoting is the type distinction: a quoted token is a plain string (a raw bus name, a waveform alias that `with_waveforms` resolves later from a [waveform library](#the-wfl-format), a parameter name) and a bare token is a variable reference, a schema-backed bus path, or a measurement field reference. Numeric arguments are decimal integers, floats, or scientific notation, and the parser preserves the distinction between `40` and `40.0` so a rewrite does not silently promote an integer to a float. + +There are eleven core operation keywords. The syntax of most follows its constructor signature, so the table is the signature written on the wire: parameters with no default appear positionally in declaration order, and parameters with a default appear as `key=value`, and only when the value differs from that default. Three are special-cased instead: `measure` writes its handle as `name=`, `sync` writes a variadic bus list, and `get_parameter` writes its target variable after the `->`. | Keyword | Wire syntax | Notes | |---|---|---| @@ -436,24 +307,13 @@ body: set_gain "d" 0.5 ``` -`5e9` is written `5000000000.0` because the writer renders a Python float with -`str`. Both spellings parse to the same value, so a hand-written `5e9` is -correct and comes back in decimal form. +`5e9` is written `5000000000.0` because the writer renders a Python float with `str`. Both spellings parse to the same value, so a hand-written `5e9` is correct and comes back in decimal form. -The keyword form of an optional argument is what the writer emits, not the -only form the parser takes: `set_offset "f" 0.1 0.2` binds the second value -positionally and is rewritten as `set_offset "f" 0.1 offset_path1=0.2`. +The keyword form of an optional argument is what the writer emits, not the only form the parser takes: `set_offset "f" 0.1 0.2` binds the second value positionally and is rewritten as `set_offset "f" 0.1 offset_path1=0.2`. -Sequence values are bracket literals (`fields=["state", "iq"]`, -`outputs=[1, 2]`) and string-keyed dict values are brace literals -(`matrix={"a": 1.0}`). Both are generic forms, available wherever an operation -takes a list or a dict, and both are tokenized whole, so the spaces after the -commas are safe. `null` is the literal for Python `None`, and `true` / `false` -for the booleans. A dict entry with an unquoted key reports `dict keys must be -quoted strings, got 'a'`. +Sequence values are bracket literals (`fields=["state", "iq"]`, `outputs=[1, 2]`) and string-keyed dict values are brace literals (`matrix={"a": 1.0}`). Both are generic forms, available wherever an operation takes a list or a dict, and both are tokenized whole, so the spaces after the commas are safe. `null` is the literal for Python `None`, and `true` / `false` for the booleans. A dict entry with an unquoted key reports `dict keys must be quoted strings, got 'a'`. -A positional argument that the constructor has no parameter for is an error -rather than a dropped token, since dropping it would load a different program: +A positional argument that the constructor has no parameter for is an error rather than a dropped token, since dropping it would load a different program: ``` Line 5: too many arguments for 'Wait': 4 positional tokens but the operation @@ -461,9 +321,7 @@ takes at most 2; unexpected: ['-', 't']. If you meant an arithmetic expression, parenthesize it: `(100 - t)`. ``` -Unknown operations and unknown block keywords are hard errors for the same -reason. A keyword that names a registered block but carries no trailing colon -gets its own message, since the fix is one character: +Unknown operations and unknown block keywords are hard errors for the same reason. A keyword that names a registered block but carries no trailing colon gets its own message, since the fix is one character: ``` Line 4: unknown operation 'fly': no core operation is registered under that @@ -472,32 +330,15 @@ Line 4: 'average' is a block keyword — block headers need a trailing colon: `average 10:` ``` -Symmetrically, the writer raises `SerializationError` for anything it cannot -represent faithfully, and never truncates an array or emits a placeholder for -a node it does not recognize. +Symmetrically, the writer raises `SerializationError` for anything it cannot represent faithfully, and never truncates an array or emits a placeholder for a node it does not recognize. ### Measurements -A measurement's handle is written as a `name=` keyword rather than as a -positional string, so the line reads as intent: -`measure q[0].readout "readout" "weights" name="q0/readout/m0"`. Every -measurement operation and every conditional referring to the same name resolve -to one `qp.MeasurementHandle` instance after a load. +A measurement's handle is written as a `name=` keyword rather than as a positional string, so the line reads as intent: `measure q[0].readout "readout" "weights" name="q0/readout/m0"`. Every measurement operation and every conditional referring to the same name resolve to one `qp.MeasurementHandle` instance after a load. -Three spellings are accepted. The canonical `name="..."` keyword is what the -writer emits. A quoted string in the `handle` positional slot names the handle -too. With neither, the parser allocates one from the global `m0`, `m1`, ... -counter, even on a `q[0].readout` line: the bus is still the written token at -that point rather than a `qp.BusRef`, so the per-bus `q0/readout/m0` prefix the -builder uses for a schema-backed bus is never reached. Files the writer -produced are unaffected, since it always emits an explicit `name=`. A `name=` -that is not a quoted string reports `measurement name= must be a quoted -string, got 42`. +Three spellings are accepted. The canonical `name="..."` keyword is what the writer emits. A quoted string in the `handle` positional slot names the handle too. With neither, the parser allocates one from the global `m0`, `m1`, ... counter, even on a `q[0].readout` line: the bus is still the written token at that point rather than a `qp.BusRef`, so the per-bus `q0/readout/m0` prefix the builder uses for a schema-backed bus is never reached. Files the writer produced are unaffected, since it always emits an explicit `name=`. A `name=` that is not a quoted string reports `measurement name= must be a quoted string, got 42`. -`fields=` selects what the measurement produces. The values are the -`qp.MeasurementField` members, `"state"`, `"iq"`, and `"raw"`, and the default -is `["iq"]`, which the writer therefore omits. An unknown name is rejected at -the line: +`fields=` selects what the measurement produces. The values are the `qp.MeasurementField` members, `"state"`, `"iq"`, and `"raw"`, and the default is `["iq"]`, which the writer therefore omits. An unknown name is rejected at the line: ``` Line 4: unknown measurement field(s) ['bogus']. Known fields: ['iq', 'raw', @@ -505,9 +346,7 @@ Line 4: unknown measurement field(s) ['bogus']. Known fields: ['iq', 'raw', `measure.fields.` via qprogram.protocol.register_capability_tokens. ``` -The older `returns="state,iq"` spelling has its own diagnostic, because -neither the keyword nor the value shape is guessable from a generic unexpected -keyword error: +The older `returns="state,iq"` spelling has its own diagnostic, because neither the keyword nor the value shape is guessable from a generic unexpected keyword error: ``` Line 4: `returns=` was replaced by `fields=`, and the value is now a bracket @@ -515,9 +354,7 @@ list of field names rather than a comma-joined string: write `fields=["state", "iq"]` instead of `returns="state,iq"`. ``` -A vendor measurement operation registers with the same two callbacks core -`measure` uses, so `myvendor.acquire` takes `name=` and `fields=` in exactly -this form. +A vendor measurement operation registers with the same two callbacks core `measure` uses, so `myvendor.acquire` takes `name=` and `fields=` in exactly this form. ## Inline waveform constructors @@ -528,10 +365,7 @@ play "flux_q0" FlatTop(amplitude=amp, duration=dur, smooth_duration=5) measure "readout_q0" IQPair(Square(1.0, 2000), Square(0.0, 2000)) IQPair(Square(1.0, 2000), Square(1.0, 2000)) ``` -The name resolves through the waveform registry and the arguments bind to the -class's `__init__`, so the table of built-ins is the table of signatures. -Custom waveforms registered with `qp.register_waveform` are spelled the same -way and need no format change. +The name resolves through the waveform registry and the arguments bind to the class's `__init__`, so the table of built-ins is the table of signatures. Custom waveforms registered with `qp.register_waveform` are spelled the same way and need no format change. | Constructor | Parameters | |---|---| @@ -553,23 +387,9 @@ way and need no format change. | `IQRotation` | `base`, `phase` | | `IQZero` | `envelope` | -Arguments are either all positional or all named; the two are not combined. A -call carrying any `key=value` argument is constructed from its keyword -arguments alone and its positional ones are dropped, so -`Gaussian(0.5, duration=40, sigma=8)` fails with -`TypeError: Gaussian.__init__() missing 1 required positional argument: -'amplitude'` rather than filling `amplitude` in. The writer spells every -constructor argument as a keyword, so a written file never hits this; a -hand-written mix does. Sample arrays, meaning `Arbitrary` samples and the -values of a `Values` sweep source, are always written in full; the format never -truncates. - -Constructor arguments are numbers, quoted strings, bare variable references, -and nested constructors. An expression or a math function in an argument -position is not part of the constructor syntax, and the `.qp` language has no -assignment statement to compute the value on a line of its own. Both forms -report the same class of error, from the constructor-name lookup that never -finds a name: +Arguments are either all positional or all named; the two are not combined. A call carrying any `key=value` argument is constructed from its keyword arguments alone and its positional ones are dropped, so `Gaussian(0.5, duration=40, sigma=8)` fails with `TypeError: Gaussian.__init__() missing 1 required positional argument: 'amplitude'` rather than filling `amplitude` in. The writer spells every constructor argument as a keyword, so a written file never hits this; a hand-written mix does. Sample arrays, meaning `Arbitrary` samples and the values of a `Values` sweep source, are always written in full; the format never truncates. + +Constructor arguments are numbers, quoted strings, bare variable references, and nested constructors. An expression or a math function in an argument position is not part of the constructor syntax, and the `.qp` language has no assignment statement to compute the value on a line of its own. Both forms report the same class of error, from the constructor-name lookup that never finds a name: ``` play "flux_q0" FlatTop(amplitude=(amp * 2), duration=40, smooth_duration=5) @@ -579,19 +399,11 @@ play "drive_q0" Gaussian(amplitude=sin(phi), duration=40, sigma=8) # ParseError: Unknown waveform or sweep source type: sin ``` -Neither error carries a line number, because the lookup runs below the -parser's cursor. Fold the arithmetic into a Python number before serializing, -or move it into the sweep and pass the swept variable straight through. The -writer does emit these forms when the in-memory program holds them, and that -is how a `.qp` file that will not load again gets produced; see -[`SerializationError`](errors.md#serializationerror). +Neither error carries a line number, because the lookup runs below the parser's cursor. Fold the arithmetic into a Python number before serializing, or move it into the sweep and pass the swept variable straight through. The writer does emit these forms when the in-memory program holds them, and that is how a `.qp` file that will not load again gets produced; see [`SerializationError`](errors.md#serializationerror). ## Expressions -Arithmetic, comparison, and logical expressions appear inline in their -canonical **parenthesized** form. That is the only form the parser accepts: an -unparenthesized `100 - t` is a "too many arguments" error, never a silent drop. -Math functions and `where` use the function-call form: +Arithmetic, comparison, and logical expressions appear inline in their canonical **parenthesized** form. That is the only form the parser accepts: an unparenthesized `100 - t` is a "too many arguments" error, never a silent drop. Math functions and `where` use the function-call form: ``` wait "drive_q0" (100 - t) @@ -600,33 +412,13 @@ set_gain "drive_q0" where((amp > 0.5), amp, 0.0) set_gain "drive_q0" sin(phi) ``` -Expressions are argument values in their own right, not sub-expressions of a -constructor call: they appear where an operation takes a number, not inside a -waveform's or sweep source's argument list. - -The binary arithmetic operators are `+`, `-`, `*`, `/`; unary `-` and `+` are -written with no space between the sign and the operand, as `(-g)`. The -comparisons are `==`, `!=`, `<`, `<=`, `>`, `>=`, and the logical operators are -`and`, `or`, and `not`. The math functions are `sin`, `cos`, `tan`, `exp`, -`log`, `sqrt`, `abs`, `minimum`, and `maximum`, and the ternary is -`where(cond, a, b)`, which reports `where(...) requires 3 arguments -(condition, then, else); got 2` on any other arity. - -Inside the parentheses the parser accepts exactly three shapes: one token -opening with a sign, two tokens starting with `not`, or three tokens whose -middle one is an operator. Anything else reports `could not parse expression: -'(a b c d)'` or `unknown operator '%' in expression '(a % b)'`. - -A measurement field reference is written unquoted as `.`, and -`state` is the only field a condition may branch on: the enum lists what a -measurement may produce, while a condition needs a classified scalar, so -`m0.iq` raises a plain `ValueError`, not a `ParseError`: -`MeasurementRef field must be one of ['state'], got 'iq'`. The name has to be -a single clean token for the unquoted form to survive, which is -why the writer refuses to emit a `MeasurementRef` whose handle name carries -whitespace, a quote, `#`, a comma, a dot, or a bracket. A field reference to a -name no measurement in the file declared decodes as a plain string and then -fails as an operand: `cannot use 'm0.state' (str) as an expression operand`. +Expressions are argument values in their own right, not sub-expressions of a constructor call: they appear where an operation takes a number, not inside a waveform's or sweep source's argument list. + +The binary arithmetic operators are `+`, `-`, `*`, `/`; unary `-` and `+` are written with no space between the sign and the operand, as `(-g)`. The comparisons are `==`, `!=`, `<`, `<=`, `>`, `>=`, and the logical operators are `and`, `or`, and `not`. The math functions are `sin`, `cos`, `tan`, `exp`, `log`, `sqrt`, `abs`, `minimum`, and `maximum`, and the ternary is `where(cond, a, b)`, which reports `where(...) requires 3 arguments (condition, then, else); got 2` on any other arity. + +Inside the parentheses the parser accepts exactly three shapes: one token opening with a sign, two tokens starting with `not`, or three tokens whose middle one is an operator. Anything else reports `could not parse expression: '(a b c d)'` or `unknown operator '%' in expression '(a % b)'`. + +A measurement field reference is written unquoted as `.`, and `state` is the only field a condition may branch on: the enum lists what a measurement may produce, while a condition needs a classified scalar, so `m0.iq` raises a plain `ValueError`, not a `ParseError`: `MeasurementRef field must be one of ['state'], got 'iq'`. The name has to be a single clean token for the unquoted form to survive, which is why the writer refuses to emit a `MeasurementRef` whose handle name carries whitespace, a quote, `#`, a comma, a dot, or a bracket. A field reference to a name no measurement in the file declared decodes as a plain string and then fails as an operand: `cannot use 'm0.state' (str) as an expression operand`. ## Control flow blocks @@ -648,12 +440,9 @@ for amp in [0.0, 0.1, 0.3, 0.5, 0.7, 1.0]: play "drive_q0" "pi_pulse" ``` -The bracket literal is sugar for a `Values` source, and it is the form the -writer emits for one, since a source whose only parameter is the sweep itself -reads better as the sweep. +The bracket literal is sugar for a `Values` source, and it is the form the writer emits for one, since a source whose only parameter is the sweep itself reads better as the sweep. -Every other sweep source takes the constructor shape, resolved through the -sweep-source registry by class name, exactly as a waveform is: +Every other sweep source takes the constructor shape, resolved through the sweep-source registry by class name, exactly as a waveform is: | Source | Parameters | |---|---| @@ -666,33 +455,28 @@ sweep-source registry by class name, exactly as a waveform is: | `Rotate` | `source`, `by=1` | | `Concat` | `sources` (a bracket literal) | -The last three are combinators, and nest at any depth, because a nested -`Name(args)` argument resolves through the same registry lookup: +The last three are combinators, and nest at any depth, because a nested `Name(args)` argument resolves through the same registry lookup: ``` for amp in Concat(sources=[Range(start=0.0, stop=1.0, step=0.5), Rotate(source=[1, 2, 3], by=1)]): set_gain "drive_q0" amp ``` -`File` reads an external array from disk, which keeps a long sweep out of the -file: +`File` reads an external array from disk, which keeps a long sweep out of the file: ``` for amp in File(path="sweep_values.npy"): set_gain "drive_q0" amp ``` -A vendor source registered with `qp.register_sweep_source` is spelled the same -way and needs no format change. An unregistered name reports the whole -registry, so the spelling of the one you wanted is in the message: +A vendor source registered with `qp.register_sweep_source` is spelled the same way and needs no format change. An unregistered name reports the whole registry, so the spelling of the one you wanted is in the message: ``` Line 4: unknown sweep source 'Bogus'; registered sources are ['Concat', 'File', 'Linspace', 'Logspace', 'Range', 'Repeat', 'Rotate', 'Values'] ``` -A malformed header, and a source that is neither a bracket literal nor a call, -report separately: +A malformed header, and a source that is neither a bracket literal nor a call, report separately: ``` Line 4: invalid for loop header: 'for g range(0, 1, 0.1)' @@ -708,9 +492,7 @@ for freq in Range(start=4e9, stop=6e9, step=2e7) | for gain in Range(start=0.0, play "drive_q0" "pi_pulse" ``` -All loops in a parallel composition must have the same iteration count, 101 -apiece above, because they advance in lockstep. A mismatch is a `ParseError` -reported on the header line, and it names the counts: +All loops in a parallel composition must have the same iteration count, 101 apiece above, because they advance in lockstep. A mismatch is a `ParseError` reported on the header line, and it names the counts: ``` Line 4: parallel loops must have the same number of iterations to advance in @@ -729,15 +511,9 @@ else: sync ``` -The condition on an `if` or `elif` header is written without the outer -parentheses a nested comparison would carry, which is why the writer strips -them there. A fully parenthesized expression is accepted too, so -`if (not flag):` works when `flag` is a declared variable. A condition whose -operand is neither a declared variable, a number, nor a measurement field -reference reports `cannot use 'flag' (str) as an expression operand`. +The condition on an `if` or `elif` header is written without the outer parentheses a nested comparison would carry, which is why the writer strips them there. A fully parenthesized expression is accepted too, so `if (not flag):` works when `flag` is a declared variable. A condition whose operand is neither a declared variable, a number, nor a measurement field reference reports `cannot use 'flag' (str) as an expression operand`. -The chain ends at the first line at the header's indent that is not `elif` or -`else`. Every way of getting the chain wrong is reported: +The chain ends at the first line at the header's indent that is not `elif` or `else`. Every way of getting the chain wrong is reported: ``` Line 4: `if` requires a condition: `if :` @@ -746,9 +522,7 @@ Line 9: multiple `else` arms in the same conditional chain Line 7: 'elif' without a preceding `if:` at the same indent level ``` -The last one covers a bare `elif:` as well as a genuinely orphaned arm: a -condition-less `elif` never reads as part of the chain, so it is reported as -one that has no `if` above it. +The last one covers a bare `elif:` as well as a genuinely orphaned arm: a condition-less `elif` never reads as part of the chain, so it is reported as one that has no `if` above it. ### `average` @@ -758,10 +532,7 @@ average 1000: measure "readout_q0" "readout" "weights" name="m0" ``` -The shot count is a single positional integer, at least 1. Omitting it reports -`average requires a shot count`, a non-integer reports `average: invalid shots -count '10.5'`, and zero or a negative count reaches the block's own validation: -`Average shots must be an integer >= 1, got 0`. +The shot count is a single positional integer, at least 1. Omitting it reports `average requires a shot count`, a non-integer reports `average: invalid shots count '10.5'`, and zero or a negative count reaches the block's own validation: `Average shots must be an integer >= 1, got 0`. ### Generic `block` @@ -788,8 +559,7 @@ average 1000: ## Fragments -Reusable sub-programs are declared in top-level `fragment` sections (before -`body:`) and instantiated by bare `name(args)` call statements: +Reusable sub-programs are declared in top-level `fragment` sections (before `body:`) and instantiated by bare `name(args)` call statements: ``` fragment x_pulse(drive, amp): @@ -802,20 +572,9 @@ body: x_pulse("drive_q1", amp=(g * 0.5)) ``` -Fragment bodies use the same statement grammar as `body:`, including `var` -declarations (fragment-local), control flow, vendor operations, and calls to -*already defined* fragments. Define-before-use is enforced by the definition -table itself, which is what makes a written file list fragments in dependency -order; the writer computes that order depth-first over the call graph rather -than trusting registration order, and refuses to write a call cycle. +Fragment bodies use the same statement grammar as `body:`, including `var` declarations (fragment-local), control flow, vendor operations, and calls to *already defined* fragments. Define-before-use is enforced by the definition table itself, which is what makes a written file list fragments in dependency order; the writer computes that order depth-first over the call graph rather than trusting registration order, and refuses to write a call cycle. -Call arguments follow the Python calling convention: positionals in parameter -order, then `key=value` keywords. Argument tokens take the same shapes as -operation arguments, meaning numbers, quoted strings, bus paths, identifiers, -parenthesized expressions, and inline waveform constructors, and a bare -path-shaped token promotes to a `qp.BusRef` here too, so a fragment can take a -bus as a parameter. The writer emits every argument positionally, so the -keyword spelling a caller used at build time is not part of the wire form. +Call arguments follow the Python calling convention: positionals in parameter order, then `key=value` keywords. Argument tokens take the same shapes as operation arguments, meaning numbers, quoted strings, bus paths, identifiers, parenthesized expressions, and inline waveform constructors, and a bare path-shaped token promotes to a `qp.BusRef` here too, so a fragment can take a bus as a parameter. The writer emits every argument positionally, so the keyword spelling a caller used at build time is not part of the wire form. Every failure here is a hard `ParseError`: @@ -830,23 +589,18 @@ Line 7: fragment call 'x_pulse': duplicate keyword argument 'amp' Line 7: fragment call 'x_pulse': positional argument after keyword argument ``` -A whole-line call whose name is a registered waveform gets a pointed message -instead, since the mistake is a misplaced constructor: +A whole-line call whose name is a registered waveform gets a pointed message instead, since the mistake is a misplaced constructor: ``` Line 5: waveform constructor 'Gaussian' cannot stand alone as a statement; waveforms appear as operation arguments (e.g. `play "bus" Gaussian(...)`) ``` -A fragment name that is a [reserved keyword](reserved.md) raises -`qp.ValidationError`, not a `ParseError`. See the -[fragments guide](../guide/fragments.md) for the Python API and expansion -semantics. +A fragment name that is a [reserved keyword](reserved.md) raises `qp.ValidationError`, not a `ParseError`. See the [fragments guide](../guide/fragments.md) for the Python API and expansion semantics. ## Vendor operations -Vendor operations use dot notation, and the vendor is named in a `require` -line above the body: +Vendor operations use dot notation, and the vendor is named in a `require` line above the body: ``` require myvendor 0.1 @@ -857,15 +611,9 @@ body: myvendor.active_reset "readout_q0" "readout" "weights" "drive_q0" "pi_pulse" trigger_address=1 ``` -The same parsing rules apply: positional arguments first, optional keyword -arguments as `key=value`, bound to `inspect.signature(cls.__init__)`. Vendor -*blocks* work the same way and take a trailing colon, as -`myvendor.infinite_loop:`, and need no grammar change, because an operation and -a keyword-led block share one shape and it is the trailing colon that tells -them apart. +The same parsing rules apply: positional arguments first, optional keyword arguments as `key=value`, bound to `inspect.signature(cls.__init__)`. Vendor *blocks* work the same way and take a trailing colon, as `myvendor.infinite_loop:`, and need no grammar change, because an operation and a keyword-led block share one shape and it is the trailing colon that tells them apart. -Resolution goes through the vendor's registered operations, so an extension -that never got imported produces: +Resolution goes through the vendor's registered operations, so an extension that never got imported produces: ``` Line 5: unknown vendor operation myvendor.'acquire': no operation is @@ -873,13 +621,11 @@ registered under that name. Import the 'myvendor' extension package before loading, and check the file's `require myvendor ` declaration. ``` -That is the failure the `require` line exists to prevent; see -[`require` declarations](#require-declarations). +That is the failure the `require` line exists to prevent; see [`require` declarations](#require-declarations). ## A file exercising every section -The writer's output for a program with metadata, a schema, a fragment, an -averaged sweep, a conditional, and a two-index bus path: +The writer's output for a program with metadata, a schema, a fragment, an averaged sweep, a conditional, and a two-index bus path: ``` #!QProgram 0.2 @@ -912,9 +658,7 @@ body: wait q[0].drive 100 ``` -Reading that back and writing it again reproduces it byte for byte, which is -the property `tests/test_round_trip.py` and the hypothesis strategies in -`tests/test_round_trip_property.py` check across generated programs. +Reading that back and writing it again reproduces it byte for byte, which is the property `tests/test_round_trip.py` and the hypothesis strategies in `tests/test_round_trip_property.py` check across generated programs. ## Two-qubit CZ chevron @@ -1018,49 +762,19 @@ measurement_ref:= HANDLE_NAME "." FIELD # FIELD is `state` ## Canonical grammar and editor tooling -The machine-readable grammar ships with the package as -`src/qprogram/grammar/qp.lark`, in the Lark dialect, parsed LALR with a -two-space `Indenter`. Read it at runtime with -`qprogram.grammar.grammar_text()`, build the reference parser with -`qprogram.grammar.parser()`, or parse one document with -`qprogram.grammar.parse_text(text)`, which appends the trailing newline the -grammar expects and returns the Lark tree. `lark` is a development dependency, -so the last two raise `ModuleNotFoundError` unless it is installed; -`grammar_text()` reads the shipped file and needs nothing. - -The grammar is normative but over-approximates everything -semantic: any identifier is a valid operation or block keyword, section order -is free, and duplicate declarations and bus-path resolution are post-parse -checks the production parser performs and the grammar does not. It is exact -about token shapes, meaning quoting, parenthesized expressions, call adjacency -(`name(` with no intervening space), and the literal forms. - -CI cross-checks the two in `tests/test_grammar.py`: a corpus of writer output -and hypothesis-generated programs must parse under the grammar, and a corpus -of syntactic malformations must be rejected by both. That keeps them in step -over everything the corpus reaches, which is a narrower promise than "they -cannot drift". - -Two cases the corpus does not reach are worth knowing. An auto-allocated -measurement name embeds the bus path, so a conditional on a schema-backed -measurement is written `if q0/readout/m0.state == 1:`. The production parser -reads that back exactly; the Lark grammar rejects it, because its name -terminals exclude `/`. Pass an explicit `name=` to any measurement you intend -to branch on, since `if m0.state == 1:` satisfies both. And the two disagree on -tabs, as described under [Indentation](#indentation). +The machine-readable grammar ships with the package as `src/qprogram/grammar/qp.lark`, in the Lark dialect, parsed LALR with a two-space `Indenter`. Read it at runtime with `qprogram.grammar.grammar_text()`, build the reference parser with `qprogram.grammar.parser()`, or parse one document with `qprogram.grammar.parse_text(text)`, which appends the trailing newline the grammar expects and returns the Lark tree. `lark` is a development dependency, so the last two raise `ModuleNotFoundError` unless it is installed; `grammar_text()` reads the shipped file and needs nothing. + +The grammar is normative but over-approximates everything semantic: any identifier is a valid operation or block keyword, section order is free, and duplicate declarations and bus-path resolution are post-parse checks the production parser performs and the grammar does not. It is exact about token shapes, meaning quoting, parenthesized expressions, call adjacency (`name(` with no intervening space), and the literal forms. + +CI cross-checks the two in `tests/test_grammar.py`: a corpus of writer output and hypothesis-generated programs must parse under the grammar, and a corpus of syntactic malformations must be rejected by both. That keeps them in step over everything the corpus reaches, which is a narrower promise than "they cannot drift". + +Two cases the corpus does not reach are worth knowing. An auto-allocated measurement name embeds the bus path, so a conditional on a schema-backed measurement is written `if q0/readout/m0.state == 1:`. The production parser reads that back exactly; the Lark grammar rejects it, because its name terminals exclude `/`. Pass an explicit `name=` to any measurement you intend to branch on, since `if m0.state == 1:` satisfies both. And the two disagree on tabs, as described under [Indentation](#indentation). Editor support builds on the real toolchain rather than on the grammar: -- `python -m qprogram.lsp check ` writes a JSON array of diagnostics to - stdout, one object per diagnostic with `line`, `end_line`, `severity`, - `code`, and `message`. Lines are 0-based, for the LSP's benefit. The exit - status is 1 when anything of error severity was found. A parse failure - reports one `parse-error` diagnostic; otherwise reference-platform validation - runs and its `Diagnostic`s are mapped onto lines through the program's source - map. `--no-validate` reports syntax only. +- `python -m qprogram.lsp check ` writes a JSON array of diagnostics to stdout, one object per diagnostic with `line`, `end_line`, `severity`, `code`, and `message`. Lines are 0-based, for the LSP's benefit. The exit status is 1 when anything of error severity was found. A parse failure reports one `parse-error` diagnostic; otherwise reference-platform validation runs and its `Diagnostic`s are mapped onto lines through the program's source map. `--no-validate` reports syntax only. - `python -m qprogram.lsp explain ` writes the execution-plan tree. -- `python -m qprogram.lsp serve` runs an LSP server over stdio for any editor - that speaks the protocol, and needs the `qprogram[lsp]` extra. +- `python -m qprogram.lsp serve` runs an LSP server over stdio for any editor that speaks the protocol, and needs the `qprogram[lsp]` extra. ## Parser and writer API @@ -1074,69 +788,21 @@ text = qp.dumps(program) program = qp.loads(text) ``` -Files are read and written as UTF-8 regardless of the platform's locale. -`qp.loads` and `qp.load` both take `auto_activate`, which defaults to `True` -and controls whether a `require` line may import an extension through its -entry point. Both raise `qp.ParseError` for malformed input, -`qp.ValidationError` for a declaration the grammar accepts and the program -rejects (a reserved variable id), and a plain `TypeError` for a constructor -call that does not fit its class's signature. `qp.dumps` and `qp.save` raise -`qp.SerializationError`, including for a `qp.Fragment` passed directly, which -has no file form of its own: a fragment is emitted as a section of the host -program that calls it. +Files are read and written as UTF-8 regardless of the platform's locale. `qp.loads` and `qp.load` both take `auto_activate`, which defaults to `True` and controls whether a `require` line may import an extension through its entry point. Both raise `qp.ParseError` for malformed input, `qp.ValidationError` for a declaration the grammar accepts and the program rejects (a reserved variable id), and a plain `TypeError` for a constructor call that does not fit its class's signature. `qp.dumps` and `qp.save` raise `qp.SerializationError`, including for a `qp.Fragment` passed directly, which has no file form of its own: a fragment is emitted as a section of the host program that calls it. -The parser is recursive-descent, in pure Python, with no external dependencies -(no `pyyaml`, no `lark`). The writer walks the AST directly and emits text on -the fly. Both live under `qprogram.serialization`, whose `loads`, `load`, and -`ParseError` are resolved through a module `__getattr__` so that importing the -package does not close the parser-to-program import cycle. +The parser is recursive-descent, in pure Python, with no external dependencies (no `pyyaml`, no `lark`). The writer walks the AST directly and emits text on the fly. Both live under `qprogram.serialization`, whose `loads`, `load`, and `ParseError` are resolved through a module `__getattr__` so that importing the package does not close the parser-to-program import cycle. ## Versioning -The header version (`#!QProgram 0.2`) is the format version, and it is the -library version truncated to `major.minor`: `FORMAT_VERSION` reads the -installed distribution's version, so `qprogram` 0.2.1 writes `0.2`. New minor -versions add operations, waveforms, control-flow constructs, or sections in -backward-compatible ways, and major version bumps are reserved for changes that -break the older spelling outright. A reader refuses any version above its own, -whichever component moved, since it cannot know what a later release did. Tying -the format version to the library's means a release that does not touch the -format still moves the minor, which costs nothing under the contract, and that -the library's own major bump is the format's. A patch release moves neither: a -file's version is `major.minor` and carries no patch component, because a patch -cannot have changed the format. - -A file older than the running version is not refused, whatever its major. -Loading migrates it: a release that changes the syntax registers one migration -under its own version with -[`register_migration`][qprogram.serialization.migrations.register_migration], -and the reader applies every registered migration newer than the file's version, -oldest first, to the lines it is about to read. Both formats work this way, each -with its own table — `file_format="qp"` for a program, `"wfl"` for a waveform -library — bounded by the one version they share. The file on disk is untouched, -and writing the program back out writes today's version. A migration rewrites -lines one for one — it may not add or drop any — so a diagnostic's line number -and every `source_map` entry still name a line of the original file; a migration -that breaks that count raises `ValueError` and names itself. Two migrations may -share a version, and run in registration order. Since the chain has an entry per -breaking change rather than per release, a version with no migration behind it -needs none, and a file from a release that changed nothing loads as it is. - -Vendor protocol versions (`require myvendor 0.1`) are independent: they -describe the vendor's operation set, not the file format. The vendor extension -registers its own version on import, through `qp.register_vendor_version`. +The header version (`#!QProgram 0.2`) is the format version, and it is the library version truncated to `major.minor`: `FORMAT_VERSION` reads the installed distribution's version, so `qprogram` 0.2.1 writes `0.2`. New minor versions add operations, waveforms, control-flow constructs, or sections in backward-compatible ways, and major version bumps are reserved for changes that break the older spelling outright. A reader refuses any version above its own, whichever component moved, since it cannot know what a later release did. Tying the format version to the library's means a release that does not touch the format still moves the minor, which costs nothing under the contract, and that the library's own major bump is the format's. A patch release moves neither: a file's version is `major.minor` and carries no patch component, because a patch cannot have changed the format. + +A file older than the running version is not refused, whatever its major. Loading migrates it: a release that changes the syntax registers one migration under its own version with [`register_migration`][qprogram.serialization.migrations.register_migration], and the reader applies every registered migration newer than the file's version, oldest first, to the lines it is about to read. Both formats work this way, each with its own table — `file_format="qp"` for a program, `"wfl"` for a waveform library — bounded by the one version they share. The file on disk is untouched, and writing the program back out writes today's version. A migration rewrites lines one for one — it may not add or drop any — so a diagnostic's line number and every `source_map` entry still name a line of the original file; a migration that breaks that count raises `ValueError` and names itself. Two migrations may share a version, and run in registration order. Since the chain has an entry per breaking change rather than per release, a version with no migration behind it needs none, and a file from a release that changed nothing loads as it is. + +Vendor protocol versions (`require myvendor 0.1`) are independent: they describe the vendor's operation set, not the file format. The vendor extension registers its own version on import, through `qp.register_vendor_version`. ## The `.wfl` format -`.wfl` is the other text format the package reads and writes. It holds the -concrete pulses that the quoted waveform aliases in a `.qp` body resolve to, -and it is a separate file because the two change on different schedules: the -program is edited when the experiment changes, the library when the qubits are -recalibrated. Nothing in a `.qp` file references a `.wfl` file, and the `.qp` -grammar has no section that could carry one. A -[`WaveformLibrary`](api-qprogram.md#qprogram.WaveformLibrary) writes and reads -it through its own `dumps`, `loads`, `save`, and `load`, described under -[waveform libraries and the `.wfl` file](../guide/serialization.md#waveform-libraries-and-the-wfl-file). +`.wfl` is the other text format the package reads and writes. It holds the concrete pulses that the quoted waveform aliases in a `.qp` body resolve to, and it is a separate file because the two change on different schedules: the program is edited when the experiment changes, the library when the qubits are recalibrated. Nothing in a `.qp` file references a `.wfl` file, and the `.qp` grammar has no section that could carry one. A [`WaveformLibrary`](api-qprogram.md#qprogram.WaveformLibrary) writes and reads it through its own `dumps`, `loads`, `save`, and `load`, described under [waveform libraries and the `.wfl` file](../guide/serialization.md#waveform-libraries-and-the-wfl-file). A document is a header line and one entry per line: @@ -1158,37 +824,11 @@ coord := ELEMENT "[" (INDEX | "*") "]" "." KIND_NAME INDEX := DIGITS ("," DIGITS)* # a tuple for a multi-index element ``` -The coordinate between the name and the `=` is the entry's tier: a coordinate -with an index binds the name to that one bus, `[*]` binds it to every index of -that element and bus kind, and no coordinate at all binds it to every bus. -Resolution takes the most specific match for the bus being resolved. Indices -are non-negative decimal digits, optionally comma-separated, so `q[-1].drive` -is not a coordinate. The element and kind names are not checked against any -schema, since a library is a standalone artifact and knows nothing about the -program it will be applied to. - -The waveform after the `=` is exactly the constructor syntax -[inline waveform constructors](#inline-waveform-constructors) describes, looked -up in the same registry, so every built-in and every class registered with -`qp.register_waveform` is spelled the same way in both formats and a vendor -waveform needs its package imported before the load. Exactly one constructor -call is allowed after the `=`. The writer names every argument, while a -hand-written positional call such as `Square(0.1, 40)` is accepted and comes -back named. There are no variables in a library, so a bare token in an argument -position is read as a string rather than as a reference: a hand-written -`Gaussian(amp, 40, 8)` loads with `amplitude` set to the string `"amp"`. In the -other direction, a library holding a waveform built from a `Variable` cannot be -written at all, and `dumps` raises `qp.SerializationError`. - -The header must be the first non-blank line, and a comment ahead of it is an -error rather than a comment. After the header, blank lines and lines whose -first non-space character is `#` are skipped, and every other line must be an -entry. Leading whitespace on an entry line is ignored, since the format has no -nesting. A `#` further along an entry line is not a comment and fails as an -extra token. Entries are written in insertion order and read in file order, -and a name repeated at the same coordinate keeps the last value, so -`loads(dumps(library))` reproduces the library exactly. An empty library is the -header line by itself. +The coordinate between the name and the `=` is the entry's tier: a coordinate with an index binds the name to that one bus, `[*]` binds it to every index of that element and bus kind, and no coordinate at all binds it to every bus. Resolution takes the most specific match for the bus being resolved. Indices are non-negative decimal digits, optionally comma-separated, so `q[-1].drive` is not a coordinate. The element and kind names are not checked against any schema, since a library is a standalone artifact and knows nothing about the program it will be applied to. + +The waveform after the `=` is exactly the constructor syntax [inline waveform constructors](#inline-waveform-constructors) describes, looked up in the same registry, so every built-in and every class registered with `qp.register_waveform` is spelled the same way in both formats and a vendor waveform needs its package imported before the load. Exactly one constructor call is allowed after the `=`. The writer names every argument, while a hand-written positional call such as `Square(0.1, 40)` is accepted and comes back named. There are no variables in a library, so a bare token in an argument position is read as a string rather than as a reference: a hand-written `Gaussian(amp, 40, 8)` loads with `amplitude` set to the string `"amp"`. In the other direction, a library holding a waveform built from a `Variable` cannot be written at all, and `dumps` raises `qp.SerializationError`. + +The header must be the first non-blank line, and a comment ahead of it is an error rather than a comment. After the header, blank lines and lines whose first non-space character is `#` are skipped, and every other line must be an entry. Leading whitespace on an entry line is ignored, since the format has no nesting. A `#` further along an entry line is not a comment and fails as an extra token. Entries are written in insertion order and read in file order, and a name repeated at the same coordinate keeps the last value, so `loads(dumps(library))` reproduces the library exactly. An empty library is the header line by itself. Parse failures raise `qp.ParseError` carrying the 1-based line number: @@ -1203,32 +843,10 @@ Parse failures raise `qp.ParseError` carrying the 1-based line number: | `"x" = Square(amplitude=0.1, duration=40) # pi/2` | `Line 2: expected exactly one waveform after '='` | | `"x" = Bogus(1, 2)` | `Line 2: invalid waveform: Unknown waveform or sweep source type: Bogus` | -The last of those differs from `.qp`, where an unknown constructor name comes -back with no line number at all: the library parser wraps that lookup failure -in a `ParseError` that carries one. - -The header version comes from `WAVEFORM_LIBRARY_FORMAT_VERSION` in -`qprogram/waveform_library.py`, which follows the library version truncated to -`major.minor` exactly as the `.qp` `FORMAT_VERSION` does — both call -`library_major_minor` in `qprogram/_version.py`, so the two headers carry the -same number on any given release. The formats are still checked separately: a -`.wfl` file is read by the waveform library's own reader, and its version says -nothing about the `.qp` grammar. - -The rules that reader applies are `.qp`'s. A later version is refused, whichever -component moved, and an earlier one is migrated rather than refused, by the -migrations registered for the `"wfl"` format (see [Versioning](#versioning)). The two formats share the version scale but not -their rewrites, since the same line of text means one thing in a program body -and another in a library entry: a migration names the format it reads, and -vocabulary the two files do share — a renamed waveform constructor — is one -rewrite registered twice. The compatibility contract is the same as `.qp`'s -too: a minor version may add entry forms and waveform vocabulary, and a major -bump is reserved for a change an older reader cannot handle. - -The version is `major.minor` in both formats, so `#!WaveformLibrary 0.2.3` and a -bare `#!WaveformLibrary 0` are refused for the same reasons `#!QProgram 0.2.3` -and `#!QProgram 0` are. The token itself is the last whitespace-separated one on -the header line, so a header with anything after the version reports that -trailing token as an unsupported version. The writer always emits the current -version, which means an older file that is read and written back out comes back -carrying today's number. +The last of those differs from `.qp`, where an unknown constructor name comes back with no line number at all: the library parser wraps that lookup failure in a `ParseError` that carries one. + +The header version comes from `WAVEFORM_LIBRARY_FORMAT_VERSION` in `qprogram/waveform_library.py`, which follows the library version truncated to `major.minor` exactly as the `.qp` `FORMAT_VERSION` does — both call `library_major_minor` in `qprogram/_version.py`, so the two headers carry the same number on any given release. The formats are still checked separately: a `.wfl` file is read by the waveform library's own reader, and its version says nothing about the `.qp` grammar. + +The rules that reader applies are `.qp`'s. A later version is refused, whichever component moved, and an earlier one is migrated rather than refused, by the migrations registered for the `"wfl"` format (see [Versioning](#versioning)). The two formats share the version scale but not their rewrites, since the same line of text means one thing in a program body and another in a library entry: a migration names the format it reads, and vocabulary the two files do share — a renamed waveform constructor — is one rewrite registered twice. The compatibility contract is the same as `.qp`'s too: a minor version may add entry forms and waveform vocabulary, and a major bump is reserved for a change an older reader cannot handle. + +The version is `major.minor` in both formats, so `#!WaveformLibrary 0.2.3` and a bare `#!WaveformLibrary 0` are refused for the same reasons `#!QProgram 0.2.3` and `#!QProgram 0` are. The token itself is the last whitespace-separated one on the header line, so a header with anything after the version reports that trailing token as an unsupported version. The writer always emits the current version, which means an older file that is read and written back out comes back carrying today's number. diff --git a/docs/reference/reserved.md b/docs/reference/reserved.md index e601b17..fa472da 100644 --- a/docs/reference/reserved.md +++ b/docs/reference/reserved.md @@ -1,35 +1,18 @@ # Reserved keywords -QProgram refuses a set of identifier-shaped names as variable ids, fragment -names, and vendor namespaces. Fourteen of them are keywords the `.qp` format -already spells; the other fifteen are held back, so that giving one of them a -meaning later cannot change what a program that parses today means. The whole -set is `qp.RESERVED_KEYWORDS`, a `frozenset[str]` of 29 names, and it lives in -`src/qprogram/_reserved.py`. +QProgram refuses a set of identifier-shaped names as variable ids, fragment names, and vendor namespaces. Fourteen of them are keywords the `.qp` format already spells; the other fifteen are held back, so that giving one of them a meaning later cannot change what a program that parses today means. The whole set is `qp.RESERVED_KEYWORDS`, a `frozenset[str]` of 29 names, and it lives in `src/qprogram/_reserved.py`. ## Where the reservation applies -Three construction sites check a name against the set, and each reports the -rejection in its own way. +Three construction sites check a name against the set, and each reports the rejection in its own way. -A `qp.Variable` id, whether written as `program.variable("...")`, as a -`fragment.variable(...)` local, as a `fragment.parameter(...)`, or as a `var` -declaration in a `.qp` file, raises `qp.InvalidVariableIdError` with -`reserved=True`. A `qp.Fragment` name raises `qp.ValidationError`, since the -name is not a variable id and carries no `reserved` flag to set. A vendor -namespace name raises `ValueError`, from whichever registration function saw -it. +A `qp.Variable` id, whether written as `program.variable("...")`, as a `fragment.variable(...)` local, as a `fragment.parameter(...)`, or as a `var` declaration in a `.qp` file, raises `qp.InvalidVariableIdError` with `reserved=True`. A `qp.Fragment` name raises `qp.ValidationError`, since the name is not a variable id and carries no `reserved` flag to set. A vendor namespace name raises `ValueError`, from whichever registration function saw it. -Operation, block, and sweep-source keywords are not checked. Those names are -the syntax the reservations are held for, so a variable and an operation may -share a name without ambiguity: the parser knows an operation keyword by its -position at the head of a statement. +Operation, block, and sweep-source keywords are not checked. Those names are the syntax the reservations are held for, so a variable and an operation may share a name without ambiguity: the parser knows an operation keyword by its position at the head of a statement. ## The keywords in use -Each of these already means something in a `.qp` file, so a variable named -after one would collide with the grammar rather than with a hypothetical -future version of it. +Each of these already means something in a `.qp` file, so a variable named after one would collide with the grammar rather than with a hypothetical future version of it. | Keyword | Where it appears | |---|---| @@ -48,19 +31,11 @@ future version of it. | `null` | The literal for Python `None` | | `where` | Conditional expression: `where((amp > 0.5), amp, 0.0)` | -The first thirteen are hard keyword terminals in the canonical grammar -(`src/qprogram/grammar/qp.lark`), and `tests/test_grammar.py` asserts that -each one is in `RESERVED_KEYWORDS`, so the two cannot drift apart. `where` is -the exception: the grammar lexes `where(` as an ordinary call, and it is the -production parser that gives the name its meaning when it resolves the call. -It is reserved anyway, because `qp.where(cond, a, b)` already exists on the -Python side and the bare keyword is held against the day it grows into syntax -of its own. +The first thirteen are hard keyword terminals in the canonical grammar (`src/qprogram/grammar/qp.lark`), and `tests/test_grammar.py` asserts that each one is in `RESERVED_KEYWORDS`, so the two cannot drift apart. `where` is the exception: the grammar lexes `where(` as an ordinary call, and it is the production parser that gives the name its meaning when it resolves the call. It is reserved anyway, because `qp.where(cond, a, b)` already exists on the Python side and the bare keyword is held against the day it grows into syntax of its own. ## The keywords held back -The remaining fifteen have no meaning in the format today. The categories are -the ones the source file groups them under. +The remaining fifteen have no meaning in the format today. The categories are the ones the source file groups them under. | Category | Keywords | |---|---| @@ -71,46 +46,21 @@ the ones the source file groups them under. | Bindings | `let`, `const` | | Imports and aliases | `import`, `from`, `as` | -Two scenarios motivate holding them. A program carrying a `Variable("while")` -would go ambiguous the moment a `while` block entered the grammar: the parser -could not tell a statement about the variable from the head of a loop. That is -the collision `var`, `for`, `in`, and `if` already have with the syntax in -use. And a vendor named `if` would produce `.qp` lines like -`if.play "drive_q0" "pi"`, which read as pseudo-keywords rather than as vendor -operations. +Two scenarios motivate holding them. A program carrying a `Variable("while")` would go ambiguous the moment a `while` block entered the grammar: the parser could not tell a statement about the variable from the head of a loop. That is the collision `var`, `for`, `in`, and `if` already have with the syntax in use. And a vendor named `if` would produce `.qp` lines like `if.play "drive_q0" "pi"`, which read as pseudo-keywords rather than as vendor operations. -Future syntax would arrive through the same registries a vendor uses today. A -`while` block would land as a single -`qprogram.serialization.registry.register_block` call, and no existing file -could break, because no variable can be named `while` and the parser therefore -never faces the ambiguity. +Future syntax would arrive through the same registries a vendor uses today. A `while` block would land as a single `qprogram.serialization.registry.register_block` call, and no existing file could break, because no variable can be named `while` and the parser therefore never faces the ambiguity. ## What is not reserved -The structural words that head a section are not keywords in the identifier -sense. `metadata`, `schema`, `body`, `require`, `element`, `naming`, and -`info` are soft keywords: the grammar admits each of them in an identifier -position, so `var body` declares a variable named `body`, and both the -production parser and the canonical grammar accept it. +The structural words that head a section are not keywords in the identifier sense. `metadata`, `schema`, `body`, `require`, `element`, `naming`, and `info` are soft keywords: the grammar admits each of them in an identifier position, so `var body` declares a variable named `body`, and both the production parser and the canonical grammar accept it. -`barrier`, `align`, `align_left`, `align_right`, and `parallel` are not -reserved either, even though neighboring pulse-level DSLs use them as -keywords. `sync` and `wait` cover that ground in QProgram's model, and -parallel loops are spelled with the `|` operator rather than a `parallel` -block, so those five names stay available as variable ids and vendor -namespaces. +`barrier`, `align`, `align_left`, `align_right`, and `parallel` are not reserved either, even though neighboring pulse-level DSLs use them as keywords. `sync` and `wait` cover that ground in QProgram's model, and parallel loops are spelled with the `|` operator rather than a `parallel` block, so those five names stay available as variable ids and vendor namespaces. -Reservation is case-sensitive. `if` is reserved; `If` and `IF` are not, and -neither is a name that merely contains a keyword, such as `if_active` or -`returns_value`. +Reservation is case-sensitive. `if` is reserved; `If` and `IF` are not, and neither is a name that merely contains a keyword, such as `if_active` or `returns_value`. ## Vendor namespaces -A vendor namespace may not be any of the 29 keywords, and may not be `"core"` -either. That set, `RESERVED_KEYWORDS | {"core"}`, is -`qprogram._reserved.RESERVED_VENDOR_NAMES`, and it is what -`qp.register_vendor_operation`, `qp.register_vendor_block`, and -`qp.register_vendor_version` check: +A vendor namespace may not be any of the 29 keywords, and may not be `"core"` either. That set, `RESERVED_KEYWORDS | {"core"}`, is `qprogram._reserved.RESERVED_VENDOR_NAMES`, and it is what `qp.register_vendor_operation`, `qp.register_vendor_block`, and `qp.register_vendor_version` check: ```python import qprogram as qp @@ -121,14 +71,9 @@ qp.register_vendor_version("if", "1.0") # extension ``` -`"core"` is the sentinel for "no vendor". Core operations carry `vendor=None` -on the wire, and reserving the name keeps a vendor from registering operations -that would be written `core.foo`. +`"core"` is the sentinel for "no vendor". Core operations carry `vendor=None` on the wire, and reserving the name keeps a vendor from registering operations that would be written `core.foo`. -`qp.QProgram.register_vendor` applies a second rule on top of that set: it -also rejects a name that collides with a `QProgram` attribute. Vendor dispatch -happens in `__getattr__`, which runs only after normal attribute lookup fails, -so such a namespace would be unreachable on every instance: +`qp.QProgram.register_vendor` applies a second rule on top of that set: it also rejects a name that collides with a `QProgram` attribute. Vendor dispatch happens in `__getattr__`, which runs only after normal attribute lookup fails, so such a namespace would be unreachable on every instance: ```python import qprogram as qp @@ -139,16 +84,7 @@ qp.QProgram.register_vendor("play", MyNamespace) # vendor dispatch ``` -The test is `hasattr(QProgram, name)` plus the two public instance attributes -assigned in `__init__`, `label` and `description`, which are invisible on the -class but shadow dispatch on every instance. That rules out `play`, `measure`, -`sweep`, `average`, `body`, `schema`, `variables`, `buses`, `source_map`, and -the rest of the public surface, along with every inherited dunder. The -forbidden set is therefore computed rather than enumerable, and it is wider -than the keyword half: `register_vendor_version("play", "1.0")` is accepted on -its own, while `register_vendor("play", ...)` raises. Pick a vendor name that -is neither reserved nor a `QProgram` attribute and the difference never comes -up. +The test is `hasattr(QProgram, name)` plus the two public instance attributes assigned in `__init__`, `label` and `description`, which are invisible on the class but shadow dispatch on every instance. That rules out `play`, `measure`, `sweep`, `average`, `body`, `schema`, `variables`, `buses`, `source_map`, and the rest of the public surface, along with every inherited dunder. The forbidden set is therefore computed rather than enumerable, and it is wider than the keyword half: `register_vendor_version("play", "1.0")` is accepted on its own, while `register_vendor("play", ...)` raises. Pick a vendor name that is neither reserved nor a `QProgram` attribute and the difference never comes up. ## Checking at runtime @@ -162,16 +98,11 @@ import qprogram as qp sorted(qp.RESERVED_KEYWORDS)[:3] # ['and', 'as', 'break'] ``` -The private module `qprogram._reserved` also carries `is_reserved_keyword` -and `is_reserved_vendor`, which wrap the two sets. Neither is re-exported at -the top level, and `is_reserved_vendor` reports only the keyword half of the -vendor rule, so `is_reserved_vendor("play")` is `False` even though -`register_vendor("play", ...)` raises. +The private module `qprogram._reserved` also carries `is_reserved_keyword` and `is_reserved_vendor`, which wrap the two sets. Neither is re-exported at the top level, and `is_reserved_vendor` reports only the keyword half of the vendor rule, so `is_reserved_vendor("play")` is `False` even though `register_vendor("play", ...)` raises. ## What the errors say -A reserved variable id and a malformed one raise the same class, and -`InvalidVariableIdError.reserved` separates them: +A reserved variable id and a malformed one raise the same class, and `InvalidVariableIdError.reserved` separates them: ```python import qprogram as qp @@ -199,9 +130,7 @@ qprogram.RESERVED_KEYWORDS). Pick a non-reserved id such as 'if_var', or carry the original name in the optional `label` argument. ``` -Taking that advice keeps the name a reader sees while giving the format an -identifier it accepts, since `label` is free text and is written to the `.qp` -file as a quoted string: +Taking that advice keeps the name a reader sees while giving the format an identifier it accepts, since `label` is free text and is written to the `.qp` file as a quoted string: ```python import qprogram as qp @@ -210,10 +139,7 @@ program = qp.QProgram() program.variable("if_var", label="if") ``` -A fragment takes a `label` too, but a `.qp` fragment section is headed by the -name and the parameter list alone, so the label does not survive -serialization and cannot carry the original name into the file. The rejection -is reported directly: +A fragment takes a `label` too, but a `.qp` fragment section is headed by the name and the parameter list alone, so the label does not survive serialization and cannot carry the original name into the file. The rejection is reported directly: ```python import qprogram as qp @@ -225,8 +151,4 @@ qp.Fragment("while") ## Related pages -[Variables and expressions](../guide/variables.md) covers the rest of the -identifier rules, [Errors](errors.md#invalidvariableiderror) places -`InvalidVariableIdError` in the hierarchy, and -[Vendor extensions](../developer/vendor-extensions.md) walks -through picking a namespace name. +[Variables and expressions](../guide/variables.md) covers the rest of the identifier rules, [Errors](errors.md#invalidvariableiderror) places `InvalidVariableIdError` in the hierarchy, and [Vendor extensions](../developer/vendor-extensions.md) walks through picking a namespace name. diff --git a/pyproject.toml b/pyproject.toml index 5e6a20c..92d5ced 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,6 @@ keywords = [ "pulse programming", "dsl", ] -urls = { "Homepage" = "https://github.com/qilimanjaro-tech/qprogram", "Documentation" = "https://qilimanjaro-tech.github.io/qprogram/", "Source" = "https://github.com/qilimanjaro-tech/qprogram", "Issues" = "https://github.com/qilimanjaro-tech/qprogram/issues" } classifiers = [ "Development Status :: 3 - Alpha", "Environment :: Console", @@ -34,10 +33,17 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "numpy>=2.1", + "numpy>=2.3.2", "xarray>=2026.4.0", ] +[project.urls] +Homepage = "https://github.com/qilimanjaro-tech/qprogram" +Documentation = "https://qilimanjaro-tech.github.io/qprogram/" +Source = "https://github.com/qilimanjaro-tech/qprogram" +Issues = "https://github.com/qilimanjaro-tech/qprogram/issues" +Changelog = "https://github.com/qilimanjaro-tech/qprogram/blob/main/CHANGELOG.md" + [project.optional-dependencies] viz = [ "matplotlib>=3.10.9", @@ -150,6 +156,9 @@ ignore = [ "unnecessary-dunder-call", "too-many-statements", "pytest-raises-too-broad", + # Closing every figure a test opened, or handing each test an empty registry, + # has to happen for all of them, which is what autouse is for. + "pytest-fixture-autouse", "os-path-join", "S", "private-member-access", diff --git a/uv.lock b/uv.lock index e8e4167..73c2e65 100644 --- a/uv.lock +++ b/uv.lock @@ -24,27 +24,24 @@ wheels = [ [[package]] name = "cattrs" -version = "26.1.0" +version = "26.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "attrs" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a0/ec/ba18945e7d6e55a58364d9fb2e46049c1c2998b3d805f19b703f14e81057/cattrs-26.1.0.tar.gz", hash = "sha256:fa239e0f0ec0715ba34852ce813986dfed1e12117e209b816ab87401271cdd40", size = 495672, upload-time = "2026-02-18T22:15:19.406Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/b2/42f4524e5479b090040b5fd8bb316dd8c65a079bb6492494ce2079dc91be/cattrs-26.2.0.tar.gz", hash = "sha256:3cf49f69df8326bcf17a3cb3d3d3ec4a856858fe3a7473746c9044c317d3ba55", size = 524993, upload-time = "2026-09-06T20:31:58.055Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/80/56/60547f7801b97c67e97491dc3d9ade9fbccbd0325058fd3dfcb2f5d98d90/cattrs-26.1.0-py3-none-any.whl", hash = "sha256:d1e0804c42639494d469d08d4f26d6b9de9b8ab26b446db7b5f8c2e97f7c3096", size = 73054, upload-time = "2026-02-18T22:15:17.958Z" }, + { url = "https://files.pythonhosted.org/packages/f5/4d/6b8460462012a52075ba97932197f8c141be71b8e5f7e918af08ee73424b/cattrs-26.2.0-py3-none-any.whl", hash = "sha256:c680f17cd1df3a1c038ad1db8d90a9c595568cc54c4e8098e7ceb8d7b9bd826b", size = 74830, upload-time = "2026-09-06T20:31:56.327Z" }, ] [[package]] name = "click" -version = "8.4.2" +version = "8.5.0" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, + { url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" }, ] [[package]] @@ -62,7 +59,7 @@ version = "1.3.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, - { name = "numpy", version = "2.5.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" } wheels = [ @@ -141,116 +138,116 @@ wheels = [ [[package]] name = "coverage" -version = "7.15.4" +version = "7.16.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/be/c3/4f2195f512fb172aa425a8803a874b2baa9ba7f80ff7b6080998761fc701/coverage-7.15.4.tar.gz", hash = "sha256:0548198fff07ccf4faf469520bce1c2eceb1ce3e62891921138dec10907f9d00", size = 936952, upload-time = "2026-08-06T13:50:24.442Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d1/f5/deb1a27aa20746c0278ac998c4179e272004699b2d33959ce020c5ac1615/coverage-7.16.0.tar.gz", hash = "sha256:077f0964087883176ff6ab9b074694cae29f8c708273b13ca62c183c6ed716cd", size = 945620, upload-time = "2026-08-28T21:54:37.74Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/66/edcec7d7a0b524aa8923e22925fde6fe50ce005a113dca13ae1581455c4c/coverage-7.15.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:bbac5abad70df71019988f83f26ac7092ff2642975def4429e98dc7585ef3490", size = 222367, upload-time = "2026-08-06T13:47:15.578Z" }, - { url = "https://files.pythonhosted.org/packages/e6/c6/ab8de429e2e8548faf58ec7e1674a4ce00414b4113942d3fe87109cf0f68/coverage-7.15.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:357a173465c7ce028d07a95cc2b63b5bf59f50ecdd5ad75c5cbb78ada984048e", size = 222874, upload-time = "2026-08-06T13:47:16.961Z" }, - { url = "https://files.pythonhosted.org/packages/be/c4/3b7b49587e8a6b9af79b3eb468d443d6042b6d65b47aa26586846a0d6566/coverage-7.15.4-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:21b803935e2efc3acebe9697197a294fccf5dc4e5382bd6369542ff7a7d2a1d7", size = 253287, upload-time = "2026-08-06T13:47:18.291Z" }, - { url = "https://files.pythonhosted.org/packages/fb/65/ec03b743a2a229c72cc1eff3e57be9d3564e9c6b4d5aba2d70744a3fc0d8/coverage-7.15.4-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7a2b580774a4786c1053157c0165e04476e03ff293993d7c148eee784a94bae6", size = 255199, upload-time = "2026-08-06T13:47:19.765Z" }, - { url = "https://files.pythonhosted.org/packages/41/4b/5163729e4b6582d61975cfd3ccab45b4ec53e21cf156d9941cb025188468/coverage-7.15.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a9464451c4efffe8d47ace5a540b10b0dc10e879066290f8600872b7f54a419d", size = 257308, upload-time = "2026-08-06T13:47:21.206Z" }, - { url = "https://files.pythonhosted.org/packages/86/08/2167a0f08fb87d702fa423a48578a32865464b7c9e1db3911ad7812ab414/coverage-7.15.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:de602f34123c2f4af1c1869c6dbbbd60da6d5983bf01937367295d135cccbfce", size = 259268, upload-time = "2026-08-06T13:47:22.503Z" }, - { url = "https://files.pythonhosted.org/packages/1e/e5/68eebae3053dbd48508edea559c21b23fbdf3460784f91370c83a86a6acd/coverage-7.15.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6879ded16a27f3eeca19b900c147e81616e7054db451471a611b2755ee5249f7", size = 253392, upload-time = "2026-08-06T13:47:23.88Z" }, - { url = "https://files.pythonhosted.org/packages/1a/46/fd4ced40a2b691c774e515c9b69500bfa64c7960b67fcee4b2f6fad97fc3/coverage-7.15.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:986be58c3ab54aae8d3496a6225eea74f760fdbe739b38bd442c7e8d133aa53b", size = 255001, upload-time = "2026-08-06T13:47:25.469Z" }, - { url = "https://files.pythonhosted.org/packages/53/25/ae2e5fa710bb6957a9aadeb9e3598d3b3e4af6587ce857ad42e8639a3f30/coverage-7.15.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c6103639613fe6c1e989082948419bc77a2d26b6c825c99d7fad25f7d3d87afc", size = 253061, upload-time = "2026-08-06T13:47:26.845Z" }, - { url = "https://files.pythonhosted.org/packages/d7/31/67ddc0365db2c6e93ac8580bc4bbc50f65273262f973f63ebcdbc15c0495/coverage-7.15.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d3af93dddb5659276c63bc16ac6466ac2033a70ca816097bbc06345b8ccdf571", size = 256831, upload-time = "2026-08-06T13:47:28.217Z" }, - { url = "https://files.pythonhosted.org/packages/f6/78/82b8fd18f57fb13f12d98fe874995bb2c4f9f17be8aff762c426323fdb96/coverage-7.15.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:b10075e5421d04265766a6d1dac809bbeb8a946fbb23c8f82c227409b2190719", size = 252781, upload-time = "2026-08-06T13:47:29.712Z" }, - { url = "https://files.pythonhosted.org/packages/0a/eb/6c74ef4dd12b252e573c49bdef9e2ac265bf3dbb79b8d7feb3266e084e9e/coverage-7.15.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a67a9f78b2942d87ba8ce3059c642164d2aedd65337377fb52fe9803656bc5c7", size = 253692, upload-time = "2026-08-06T13:47:31.192Z" }, - { url = "https://files.pythonhosted.org/packages/5a/66/eb9aed1c3fd2d36ee00eb173f434b14fa607fc056739c9a89ff4244010ea/coverage-7.15.4-cp311-cp311-win32.whl", hash = "sha256:69484d1aca26e322e1c3ce03f09341e84524ababad2d7202161738d83cc9f82e", size = 224461, upload-time = "2026-08-06T13:47:32.572Z" }, - { url = "https://files.pythonhosted.org/packages/e2/6d/81fa4161dfb3ed9d74e40d58647eff83a56b7612e78352581280fce2f477/coverage-7.15.4-cp311-cp311-win_amd64.whl", hash = "sha256:63fd6fcd1dd6e158f7eb78606e72933b3f6d01e7b747f99c6c12d764307a0fdc", size = 224937, upload-time = "2026-08-06T13:47:34.205Z" }, - { url = "https://files.pythonhosted.org/packages/5b/c1/d8dacf683c6cad3cf85ce68fd3774a6774ec402128822fdfaed920f11e6a/coverage-7.15.4-cp311-cp311-win_arm64.whl", hash = "sha256:ea82116c9893fa89e929b7f197ee5a1950a76e91cc5c85ba503fc02379d04890", size = 224479, upload-time = "2026-08-06T13:47:36.118Z" }, - { url = "https://files.pythonhosted.org/packages/1d/48/bc8d4ba7b37551a767bd863f15b3f80182b271c2f55975356f5f7dbe94c2/coverage-7.15.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d4fedd1f7f428f9fe83b1ead5e7cc87a43427be31aadafbac3ac0636dc7abb22", size = 222543, upload-time = "2026-08-06T13:47:37.562Z" }, - { url = "https://files.pythonhosted.org/packages/20/dd/88d6f83f1fffc974a3691a34a97951c5b12df7512a6782c5963883cbc058/coverage-7.15.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:37e2f0cdf58e2e1fed4e4d5a8f8786ae2f7eb80b478016876667dc4a01d60a97", size = 222905, upload-time = "2026-08-06T13:47:38.927Z" }, - { url = "https://files.pythonhosted.org/packages/bd/5c/54ee0d4748585bb0acab9891cd8d92f2d3593165b4e59fc9de113bfb3140/coverage-7.15.4-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fb55d0e70bb15f2e81477613627286581414693d74ac7963c93a790dd453ca9d", size = 254407, upload-time = "2026-08-06T13:47:40.488Z" }, - { url = "https://files.pythonhosted.org/packages/8c/3f/f0642a372f494bd0d7dad3b497083b910194a5f1c88be2c94fef707c3b59/coverage-7.15.4-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:899b9da30f3c6c336566e3707495bb23e8302d39d862f01fa78c48b99b9437e2", size = 257145, upload-time = "2026-08-06T13:47:41.931Z" }, - { url = "https://files.pythonhosted.org/packages/71/17/8b46d0ed68251016002ec972c8fc0119961a765d0984cafb8bf317c43758/coverage-7.15.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d15715e8c46552827e5e4f30a35575a2dbcad14454cf3284c54483946bd16931", size = 258257, upload-time = "2026-08-06T13:47:43.527Z" }, - { url = "https://files.pythonhosted.org/packages/30/b8/8498a0e72d0adbe15477dd07463d2b3bb2c9f6a4815e8589e50939e2c3ae/coverage-7.15.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:002a438859f7b430bc99afeaf01a6d187dad1d0dc907b64cdeffc632a5db8fd8", size = 260517, upload-time = "2026-08-06T13:47:45.121Z" }, - { url = "https://files.pythonhosted.org/packages/41/e1/7dce19c3bdb1e3dd63e769508216500edad81bd5f69a26d724e32aceaf78/coverage-7.15.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e4193a04b518f7968f3099755f5509ee7cccc6dc2b92a6b14841934d22e222c9", size = 254785, upload-time = "2026-08-06T13:47:46.541Z" }, - { url = "https://files.pythonhosted.org/packages/dd/b1/e1494703c675a2561723cd9b89f45c9168782c31280c611b1f767851e57c/coverage-7.15.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e98dcc55d572b38e69d117da7e8e8efb8500f1f5eaf81ecd460a63220790b839", size = 256176, upload-time = "2026-08-06T13:47:48.155Z" }, - { url = "https://files.pythonhosted.org/packages/73/76/a5629d270fb638a43a4b10466f51e2f49d532c1aa4da2913cbbb150bbe0a/coverage-7.15.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:af6c538498ce66c10d3fd541c2a8d5b03da5850355add34e6cba564210cb9e72", size = 254321, upload-time = "2026-08-06T13:47:49.757Z" }, - { url = "https://files.pythonhosted.org/packages/ff/4f/9c44447218435d5766b911534f9d798144a5560f85e9a54ebe5f3f5d19f9/coverage-7.15.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1d10025d96ea89fc2f73714dbc4cbd433fe012c1ac9e23f895d7728b238b6e52", size = 258390, upload-time = "2026-08-06T13:47:51.248Z" }, - { url = "https://files.pythonhosted.org/packages/de/36/c1e127616fb3fa18a9ff71e76c417f2fd7424332a4870015ac224ef4c039/coverage-7.15.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d802e1947603162ded419bff83ac7489820355d2b856dfb09206574e3a37ac0c", size = 253894, upload-time = "2026-08-06T13:47:52.816Z" }, - { url = "https://files.pythonhosted.org/packages/e9/b9/fdb92c8ae7a8bb9b850cc253b7b3b9c8526f68130002048b5671cd510d09/coverage-7.15.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c2de40895718f91951b86712b4c5b694acaf9a0a49be13874896f599a1eed3f4", size = 255763, upload-time = "2026-08-06T13:47:54.296Z" }, - { url = "https://files.pythonhosted.org/packages/6f/c0/a7d51b2587c7bdb76e71b0896d2565bf7d60436b5122fc83e511adb1f7cd/coverage-7.15.4-cp312-cp312-win32.whl", hash = "sha256:5c3431b2161279b7db5c2a1aa58ae02e5cb8c3c42d93a5094be3f5537bd5b11b", size = 224597, upload-time = "2026-08-06T13:47:56.074Z" }, - { url = "https://files.pythonhosted.org/packages/49/b9/5c5f80cc55f5acaaca6dee677626bfcec8c87204a7809b438b08e84f4571/coverage-7.15.4-cp312-cp312-win_amd64.whl", hash = "sha256:6befeab5fb2b51c958ca4ac6c5d141a1e8240f4f76e46350f1911963deda49cd", size = 225135, upload-time = "2026-08-06T13:47:57.52Z" }, - { url = "https://files.pythonhosted.org/packages/47/e4/2a4561f89ff6bf7c925c287d0f2cce8bdf139c3a33735c87e3203401cf94/coverage-7.15.4-cp312-cp312-win_arm64.whl", hash = "sha256:67bc345491ab55b837277d76f5775d057e8c7f1ac44d890d8c2c82adde258c6f", size = 224515, upload-time = "2026-08-06T13:47:58.977Z" }, - { url = "https://files.pythonhosted.org/packages/f1/84/651a9310859673aaa3b3203f1aa1641ca60fcf2494683e1c9474c7172780/coverage-7.15.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c705b28feb2775dc82a25f1d473a370bc37ff93f5177f4e29ce2425f560f6921", size = 222565, upload-time = "2026-08-06T13:48:00.796Z" }, - { url = "https://files.pythonhosted.org/packages/82/f9/4dcf700137e8af550670f4d74d1b63828ce93e1e2b05e5f10710eb2ea987/coverage-7.15.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3ff205ab5e3ecc670f6a4dd19d9cbf12ede53dd41cfc1e15716ec961ea6d314e", size = 222936, upload-time = "2026-08-06T13:48:02.391Z" }, - { url = "https://files.pythonhosted.org/packages/07/4a/612ff1e780b3fbfd637486f542f84adc5503873d8b5d279dec1ffeef9414/coverage-7.15.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5172326e861a38b48b48befca15e0f477a26b283337a33a739c8fed229934e36", size = 253926, upload-time = "2026-08-06T13:48:04.382Z" }, - { url = "https://files.pythonhosted.org/packages/b0/04/d1cff1c2ead4708a6a79c01d3736b6a25bd38a36678398f72a8dd33dfad9/coverage-7.15.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:12b59c90084e3234fb11184886bf4a40f4f16a8c8f867be2e087b81f8e8868d4", size = 256523, upload-time = "2026-08-06T13:48:05.996Z" }, - { url = "https://files.pythonhosted.org/packages/b9/80/d34e13fb4b293cbdb9665838cf5522077b8ad14ef947550631a4bced36a5/coverage-7.15.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:349062d66f00b40fa2c1c222438bad25fabf755631b5d82937fe985c8008615c", size = 257759, upload-time = "2026-08-06T13:48:08.036Z" }, - { url = "https://files.pythonhosted.org/packages/0f/e7/2c5fe7636fdb0732fe0f09f308a5b066864078b7fc61f6678e8478554f2e/coverage-7.15.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4256ced708e598e05209bc1a8ab4074e04a51dba4c62fb45926a229af675ace7", size = 259890, upload-time = "2026-08-06T13:48:09.834Z" }, - { url = "https://files.pythonhosted.org/packages/92/28/9689f0858dfff59c2ea688938ab9fa2925631235df67126a42b6c5c70ae1/coverage-7.15.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d80f974b20782d9612c8b4c9beeca867074c7cf4079d1419843fa25a26428b25", size = 254121, upload-time = "2026-08-06T13:48:11.459Z" }, - { url = "https://files.pythonhosted.org/packages/f9/e2/785077c230c157243eb5aa9a26c3be260ecd02001bead54a3cada3df8e03/coverage-7.15.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2e179f19bfe1d31f8eeeaa12990194d761c4f62f0759661000bca6cd8729f40b", size = 255891, upload-time = "2026-08-06T13:48:13.209Z" }, - { url = "https://files.pythonhosted.org/packages/d4/90/e20371b17b40f912f21305c2db2f30efa3de306f7320fc916804872c85a4/coverage-7.15.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8bc16bb47b7679670eceff71d78bfb7d6e5b143f6c2cd117487ec7c75e0d4b78", size = 253859, upload-time = "2026-08-06T13:48:14.736Z" }, - { url = "https://files.pythonhosted.org/packages/05/49/25371987ee459a5f67c0427fb75c74f9358e65f2c71fe75bf41c1b6c5fcb/coverage-7.15.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1cd685005cd2c4200adfc14cf39a603b9320efab3f18a8f7f156d20c9cc3345f", size = 258011, upload-time = "2026-08-06T13:48:16.464Z" }, - { url = "https://files.pythonhosted.org/packages/30/6e/32e67467f6154bf4f1c4f63b05acc5097cba4237d45bbeeea446b52e8ac1/coverage-7.15.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:337399ad2c93b3acd2a937627dae8b3e86b66707cd3d3e856347999aadf1ef8d", size = 253676, upload-time = "2026-08-06T13:48:18.493Z" }, - { url = "https://files.pythonhosted.org/packages/03/c1/8b24192e89286399765155251f99ee9f070a9d637109018ac23d99b99f6f/coverage-7.15.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:96e257121228ec5cd2bb919276e94ac11074471bc37d68dbae0e8308cce15fff", size = 255453, upload-time = "2026-08-06T13:48:20.057Z" }, - { url = "https://files.pythonhosted.org/packages/16/6f/8b41ebdf67c87854e17c035336a90f1cfbad0c14c2a584301be6ff148718/coverage-7.15.4-cp313-cp313-win32.whl", hash = "sha256:c65a9e0dfc6143491879da4e13b5e30f8be192055de508d737fb14601edbd22c", size = 224605, upload-time = "2026-08-06T13:48:21.655Z" }, - { url = "https://files.pythonhosted.org/packages/e0/e2/2946c7f0b42b152ecb21ff1bdad72e3d301e790c0c487e4a86e8c9f69347/coverage-7.15.4-cp313-cp313-win_amd64.whl", hash = "sha256:2ff8f5e9b8f7a94f0c11c45631eee103dbcb7d63274edd12c56efe1be690b3b4", size = 225148, upload-time = "2026-08-06T13:48:23.376Z" }, - { url = "https://files.pythonhosted.org/packages/9e/83/3f4a69957f48ae7a0aba76c34743f88963d607b19e03f3f8e66f91cae0f9/coverage-7.15.4-cp313-cp313-win_arm64.whl", hash = "sha256:6e0a8a5083b096487d6cfced94cdd514d8f5db6f113610fb36c0620edb1028cf", size = 224536, upload-time = "2026-08-06T13:48:25.117Z" }, - { url = "https://files.pythonhosted.org/packages/ea/ac/748cf29eeb2d6be34a3176ce26a4f49e38085ee08e8935f05f6f26ed7e0f/coverage-7.15.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:770e9325ab5ea6d56f77e59b29ecfe0ac20b57a82a601876f90494a4dda0386f", size = 222608, upload-time = "2026-08-06T13:48:26.806Z" }, - { url = "https://files.pythonhosted.org/packages/0b/02/1abbf5c984677b0aa439cdacaccbf38d248939d8ef8fe1cc7a50d73edb77/coverage-7.15.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d12b33a3a50a1676b7784dc8d00a0c6d66a9f2add4b85a041c19b6a7e53ef23c", size = 222940, upload-time = "2026-08-06T13:48:28.432Z" }, - { url = "https://files.pythonhosted.org/packages/eb/e1/ff8f9f53d9fcf586125b55d0b1f04ec1c14955fee41e83d5814bee141bb5/coverage-7.15.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5669c8378ebde86f5def7a25d29586631b58acc27ffde04399f678f3dfc6e082", size = 253985, upload-time = "2026-08-06T13:48:29.995Z" }, - { url = "https://files.pythonhosted.org/packages/a1/26/595759762e514e81be1d7d01ed03444303bcd152226a6529998d253f9201/coverage-7.15.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ff97a14362eef486483ed44042ca2027ea257df6ff768e62358ee0c9776925ac", size = 256492, upload-time = "2026-08-06T13:48:31.634Z" }, - { url = "https://files.pythonhosted.org/packages/24/68/b79aabac54d482be23b5fcdd4f4662bff24a78edc4ee29201726929936d5/coverage-7.15.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a325e815318638aed1655d9c06e6d7c2d3d46c09231ce988070428a8762d734", size = 257837, upload-time = "2026-08-06T13:48:33.186Z" }, - { url = "https://files.pythonhosted.org/packages/09/0f/bf7f297885a5bf6fd71e5782404e0ff059ca09e8711ceb3a08544abde45a/coverage-7.15.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:474223409d88eb20d2d6a0d37ea60e8647a65a90cc008dc1f0410af5f64f1e0d", size = 260152, upload-time = "2026-08-06T13:48:34.75Z" }, - { url = "https://files.pythonhosted.org/packages/fd/f1/296744e854ff8368542343457414380465e9ceefb9192342feb9d3bc461d/coverage-7.15.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7f2f62ae3cd189dd2e13aece758c57b3eecbd27be070dbd4cbd10936049e5dbf", size = 253978, upload-time = "2026-08-06T13:48:36.434Z" }, - { url = "https://files.pythonhosted.org/packages/55/b0/bbdb2e9057493e66220a2e149ca2d301ba0e3a58a83bd6b90de9826d16f3/coverage-7.15.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:39ece820e29e0a2ba34b3ecb3be83c27e997eed8926f2ba6fe7ce7a0bda5843b", size = 255846, upload-time = "2026-08-06T13:48:38.317Z" }, - { url = "https://files.pythonhosted.org/packages/96/e4/38015b2b6d21258713bd17e76b59d033b191efb5703589cffd037dfbca20/coverage-7.15.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f21b56dcace11dfe013014201f577dcd592b2a9b72182d930361b47cf6f73f25", size = 253808, upload-time = "2026-08-06T13:48:39.993Z" }, - { url = "https://files.pythonhosted.org/packages/0b/64/0d515c1e60ee6fbfd1a0e79c07cd87d388a233b7adc37758735677203808/coverage-7.15.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:93a3a0b662abcc10c73a47cbc72cd60f63618d6989fb2d1286e50eacd974f303", size = 258081, upload-time = "2026-08-06T13:48:41.971Z" }, - { url = "https://files.pythonhosted.org/packages/91/71/04d9e7a3642146c6351338aef4ef85ab11dbbb54744c13245caba1aad1c0/coverage-7.15.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:141fae2cabf5569b782c10afc4c850ce10f618c13f8db54765cba99cc839da1f", size = 253624, upload-time = "2026-08-06T13:48:43.731Z" }, - { url = "https://files.pythonhosted.org/packages/b4/a7/6c28b74c81ebff66987b0e2522ba5cffa3e90b0c33cb6a2eb264d4ee8cf1/coverage-7.15.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:81294c7e6ab30c5f74c0353b11b2fd6320e72d9bee6ac73b357caa8b916323a5", size = 255280, upload-time = "2026-08-06T13:48:45.58Z" }, - { url = "https://files.pythonhosted.org/packages/52/af/bc19996a7014b98d7bbb0f0939453c67074af65784a3aa16a789a07381fa/coverage-7.15.4-cp314-cp314-win32.whl", hash = "sha256:7bbd7d6418e0dab31a206af5203bd43ae36edb8e7fba1940b055d3e9249290d7", size = 224768, upload-time = "2026-08-06T13:48:47.525Z" }, - { url = "https://files.pythonhosted.org/packages/ee/90/219484e476d6e101ba0a444852579e05f5b75c37c611a42ed1190f73ef62/coverage-7.15.4-cp314-cp314-win_amd64.whl", hash = "sha256:f0204ed122758782970526057093f448051a39db9d810d4e344bb87a3546f425", size = 225259, upload-time = "2026-08-06T13:48:49.513Z" }, - { url = "https://files.pythonhosted.org/packages/b7/66/fa77daf4e383e5f776dac62c2409b6af81910ae6fe326bd5170dba74cc63/coverage-7.15.4-cp314-cp314-win_arm64.whl", hash = "sha256:9e71e7bc71c686a123347ae47a0de33a175e797a85bb57b791492adf4eec8ed8", size = 224684, upload-time = "2026-08-06T13:48:51.235Z" }, - { url = "https://files.pythonhosted.org/packages/58/5b/f03bf0ce362bbf3f785fa5219620d00778d4ac6fc9e407734828e9c672f6/coverage-7.15.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7c922735321eef3f87c280a3d39afff6b646723a2880b862cda4ac7a093b8aa8", size = 223338, upload-time = "2026-08-06T13:48:52.896Z" }, - { url = "https://files.pythonhosted.org/packages/0f/76/e77d0ae22501831cc9f92193e8a957a5caa1dd177f90a6d1d9b106242d92/coverage-7.15.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f41c17c4668a655ce96d090d8d5ffdc24ef64b5a02f9753884d08483e8a4a41a", size = 223609, upload-time = "2026-08-06T13:48:54.688Z" }, - { url = "https://files.pythonhosted.org/packages/82/1a/b1f089da8d38ac612fa2dd6dc7f4a1a7657d12f3e261d2996edd3a838d0b/coverage-7.15.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:46822e9b6ff1c6a72b518c162c44a8f45a61a1d609c51084bf5b16c023c5037b", size = 264970, upload-time = "2026-08-06T13:48:56.403Z" }, - { url = "https://files.pythonhosted.org/packages/bf/31/e66d98d6e9c7fcc88470f1e234eaf6b1950dc0dfbf797f7282c1c861da24/coverage-7.15.4-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3d6f4955b73b5445271379a59e3792b0d978f42d4a01e0cf7a67d9c33a3bb0a5", size = 267088, upload-time = "2026-08-06T13:48:58.41Z" }, - { url = "https://files.pythonhosted.org/packages/59/a1/ae94eb2c541add426378408379f233591e069040b1e2cdb33df9498a0682/coverage-7.15.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3fc9e047706fb4a9abb54f719d3aa643e80e5bb3818182c40aee01ac0f0247ba", size = 269508, upload-time = "2026-08-06T13:49:00.42Z" }, - { url = "https://files.pythonhosted.org/packages/9c/c7/88a10694a1c6a213569766aba9f25847b28155d4ac731b13226db216356d/coverage-7.15.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05e491d4f3165d62d4f5c8fd48dfeabf2ae8f42cbbd484319af33ea851b78982", size = 270629, upload-time = "2026-08-06T13:49:02.234Z" }, - { url = "https://files.pythonhosted.org/packages/b3/34/d8b8232e5e55169933b59aabcef2fedfa4b9d8897361bb80fcbda146505f/coverage-7.15.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:226c66e80ec0598d3b9b4874123df167ccca342aca8714f77cac6829688ee09c", size = 264043, upload-time = "2026-08-06T13:49:04.102Z" }, - { url = "https://files.pythonhosted.org/packages/7e/35/58b009dbf8c471c7224716478b9fed4a7e1af15320e1ed41660978504663/coverage-7.15.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ac41cc14bebda0dbfb0628036b7f75706935c95bcc07fefe9a0f93614aa60a57", size = 266963, upload-time = "2026-08-06T13:49:05.821Z" }, - { url = "https://files.pythonhosted.org/packages/62/aa/57fbda1b42c892968273c56b6ee9dc0f1310850859230a507bc7873b1f65/coverage-7.15.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8af623e5cd92080acddd02b38f2f406a2c3a0893c38950b211890361448fbf26", size = 264569, upload-time = "2026-08-06T13:49:07.706Z" }, - { url = "https://files.pythonhosted.org/packages/98/8a/360e6e7f24d477b7e889703af0afa878d15b6d4d8d2a822b2835c169a879/coverage-7.15.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:07545711d4f0f32852a18f18ad11f76f0109909d09e78b9008b4cfc67e829429", size = 268299, upload-time = "2026-08-06T13:49:09.587Z" }, - { url = "https://files.pythonhosted.org/packages/4e/89/6f701261aee21b6b5fa8f7872229406dc917e125069448292223bf213606/coverage-7.15.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a0865421cfdc53654b342d515e5a233187590882d20b95752150e53f65460017", size = 263413, upload-time = "2026-08-06T13:49:11.604Z" }, - { url = "https://files.pythonhosted.org/packages/3f/0f/6f04036edc260ed425af83e834f627fad48941ce97b50bfe6edd8b6fa623/coverage-7.15.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:460115e32ee40566476db5048f9bec1e842c127ad8e6f8be745aad3ac9cbc839", size = 265725, upload-time = "2026-08-06T13:49:13.38Z" }, - { url = "https://files.pythonhosted.org/packages/c4/ce/d19b5d4d5c49a7bfb925fd74310fee7d28bc99520ac3367ccbc54e662518/coverage-7.15.4-cp314-cp314t-win32.whl", hash = "sha256:cbde877ef9dd7baf272b9bfef2b8a25edd45d9170fc326951dd20eb480335e85", size = 225079, upload-time = "2026-08-06T13:49:15.265Z" }, - { url = "https://files.pythonhosted.org/packages/26/bb/7aa1b3b173faee0679037ca950bbbe1247273656697994d8d13f80f8d4b4/coverage-7.15.4-cp314-cp314t-win_amd64.whl", hash = "sha256:3da9e92d1c551fd7563833e9ade686efb0c4b7363ab7681a94283958c950bf5e", size = 225911, upload-time = "2026-08-06T13:49:17.279Z" }, - { url = "https://files.pythonhosted.org/packages/81/1c/4ea9e47426d80038d9222db3c4534cb6021a74b237d3ff97ffd33b6600dd/coverage-7.15.4-cp314-cp314t-win_arm64.whl", hash = "sha256:3a54f5a0d85050c73a38f6793090ee83974531e67fe5e57a1da9bee11398aa5e", size = 225219, upload-time = "2026-08-06T13:49:19.293Z" }, - { url = "https://files.pythonhosted.org/packages/2b/c4/dc5d2ac8f9142e7ec7de66e7bf0591db29d78955a040bd915870d9c0e657/coverage-7.15.4-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:2c9872e4d9dc5d3cf616bf4b382f5a00359305a5be666a3dd0b5cdb4e49597f9", size = 222604, upload-time = "2026-08-06T13:49:21.279Z" }, - { url = "https://files.pythonhosted.org/packages/70/39/33e63df81fe2ee100897451841c821467635923e58e37c6bd4b46dd8106c/coverage-7.15.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:e101dbb4b9b72f0cddd8cdc8c9c5b47f456766f5e0ac82dbfb75e5c55409b78a", size = 222944, upload-time = "2026-08-06T13:49:23.187Z" }, - { url = "https://files.pythonhosted.org/packages/99/1f/ef3ffb5557febc75a0d97aa459d0266d7d741110265121cc6d8539343d44/coverage-7.15.4-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7d1abebdb047729e852b9c77a00497dfbeb11eb3a117e037d7dbc3ac8e5f5c54", size = 254050, upload-time = "2026-08-06T13:49:25.008Z" }, - { url = "https://files.pythonhosted.org/packages/6f/f5/1f0f6f77698c3601ca0ae7431e34b24c62ca2f06fecb23b73ed1f651d2be/coverage-7.15.4-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d28a4a899354d0ea6214cc59b4fa19eefbce1b9ff1688ab579acf49e894bd3fb", size = 256967, upload-time = "2026-08-06T13:49:26.896Z" }, - { url = "https://files.pythonhosted.org/packages/03/7a/2ed9bed79925f4367c83c77f66a89e5ca7229c288d2d19ad5f36d1ca0070/coverage-7.15.4-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ffb3c2aacea411cc7e1d27712490c11108e2de1d39019ae32915493a59a8b9ed", size = 258587, upload-time = "2026-08-06T13:49:28.692Z" }, - { url = "https://files.pythonhosted.org/packages/45/8c/fa34044f71b7cc4ecb6da9c2408770959b0591fa9b5fb6fb6bca38f94298/coverage-7.15.4-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a9447978a92f405d301123cfd39ff49895490efb769a758fe2734c7f631bf8ce", size = 260785, upload-time = "2026-08-06T13:49:30.472Z" }, - { url = "https://files.pythonhosted.org/packages/4f/54/d5727ce36b4524a7394ab9f5f1df378e1f23affcdab01037dc8655185cc7/coverage-7.15.4-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:050467a7983b8e2fe7dd41a78bb30c3e7f8c0b8cafda14b1c46f8b5e3cf2dd3c", size = 254545, upload-time = "2026-08-06T13:49:32.271Z" }, - { url = "https://files.pythonhosted.org/packages/dc/e6/6e3783e576719590194bdffb6dd6d85490801785b7c331e35a245d8cb8b5/coverage-7.15.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:d003b7a5708ddad5c206c79607a6b92abb6fc13c57d99d8a4468cc03a2941ced", size = 256682, upload-time = "2026-08-06T13:49:34.089Z" }, - { url = "https://files.pythonhosted.org/packages/dc/f2/bacdbde18b69ed2de424fcf64d9fb0a4913753d4f0eca8bae9daad69f4bd/coverage-7.15.4-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c38efe30fd74e5c19e9433f11fb1f5dc9c6522770971b7c6145bbaa413dc8800", size = 254560, upload-time = "2026-08-06T13:49:36.052Z" }, - { url = "https://files.pythonhosted.org/packages/6c/a3/1fb927196e3477c1b48831169ab58ba08f451ba87ae311ff1de68b26a616/coverage-7.15.4-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:1f4f826d70f772ab8b0c052329580d7fe8b8abd191e4ce0c8f81aec6614665d3", size = 258792, upload-time = "2026-08-06T13:49:38.01Z" }, - { url = "https://files.pythonhosted.org/packages/41/58/30d4c149c69053de0edfe325614c1d28d508f62b1783e0e4a234d2e49136/coverage-7.15.4-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:4a4bf917c9953f57c957be31c1cd504e3bd2f34d4a352b9d391a3025336f6768", size = 253968, upload-time = "2026-08-06T13:49:39.934Z" }, - { url = "https://files.pythonhosted.org/packages/89/e4/77f639371b918aad30dda4051f95404b43578f7f2e2f87ba73e02ed1ff37/coverage-7.15.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:1c9bf40ebef178a45192c75c4964760bb261b0e6ad725da5fc4c93f674f19753", size = 255893, upload-time = "2026-08-06T13:49:41.825Z" }, - { url = "https://files.pythonhosted.org/packages/5c/62/13be29b3ddab35f14c87967a4820a05106d2a3eccb4fa4ff550bf30b75e0/coverage-7.15.4-cp315-cp315-win32.whl", hash = "sha256:43619d04c3671792d2c4706ae8bf45e265dc87bbd4078189ef8b847ea1e74be2", size = 224768, upload-time = "2026-08-06T13:49:44.08Z" }, - { url = "https://files.pythonhosted.org/packages/a1/70/af0c6be0f964af6954f6b74bc109b0dbca02824696d2520fb17fe1ab06e3/coverage-7.15.4-cp315-cp315-win_amd64.whl", hash = "sha256:be619439dbcd31a2eab10b32de9fff62c26ed4bab69dc32b8363fdaaa0882809", size = 225242, upload-time = "2026-08-06T13:49:45.899Z" }, - { url = "https://files.pythonhosted.org/packages/4f/2d/f3bd3aab899fc9efc18b53133ee68f5f98574ef480649b23e12962226387/coverage-7.15.4-cp315-cp315-win_arm64.whl", hash = "sha256:def597967dafc2e8d97c9097ea453c464e0bb8ed38f193a43070f10dc623bb6d", size = 224674, upload-time = "2026-08-06T13:49:48.322Z" }, - { url = "https://files.pythonhosted.org/packages/f5/ca/f69251cd63eabc6438321aea22148754cce758a26bde07dd490e3fe7cfc5/coverage-7.15.4-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c7dbc748ac8a1e3e59a2b28bea47675e6e778081dbbf081bde0d75def2fcbe1d", size = 223333, upload-time = "2026-08-06T13:49:50.293Z" }, - { url = "https://files.pythonhosted.org/packages/a7/a7/037b53b2885b0d8447064432491a4d5a1014cd9f97a594d53acd0c04541a/coverage-7.15.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:2413074a5ecbb61a01a7888fc72db0ca324d13588c5b38bc0dd8564cdcdfea26", size = 223630, upload-time = "2026-08-06T13:49:52.637Z" }, - { url = "https://files.pythonhosted.org/packages/80/4f/152b8a4779ae90da11bb24f7467df8a59f0be48a5c52acb856325ca48289/coverage-7.15.4-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4e6f6f632b7b2f714bf7a1346e8f97b650ee71f3c298aaad42a2ab60f0f07645", size = 264489, upload-time = "2026-08-06T13:49:54.52Z" }, - { url = "https://files.pythonhosted.org/packages/10/2d/84b4b9e0e1dd6528a51920ff7031f35b789382e467a28ec6a5a578cb8812/coverage-7.15.4-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8df457da2249d3c75ca2e5e835d59c725abfe92d27fdff6cd99eed85b51d5e9a", size = 267567, upload-time = "2026-08-06T13:49:56.721Z" }, - { url = "https://files.pythonhosted.org/packages/53/fc/ba01cc25299f9f8a2c8b02d3b28c53f3543d9fbfbe4e74fa2760b48f163e/coverage-7.15.4-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:050f66a08805acb5b8a23c6d4a517b1ecf82c08e81ed0e4bd727df065e5c6624", size = 270123, upload-time = "2026-08-06T13:49:58.736Z" }, - { url = "https://files.pythonhosted.org/packages/cf/d0/db2647cbf40b14f8c308f94ff7bf89c06d564e59f396906edf50086ec788/coverage-7.15.4-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1587fb771d1ccceef708fdde1e5af8c7ed24b486b61d13a321acb7d8145390aa", size = 271107, upload-time = "2026-08-06T13:50:00.811Z" }, - { url = "https://files.pythonhosted.org/packages/70/ff/4d2d17924552c458bb4f77dd631f0e3bc92fbbdf2d2d916cd4b33bbfd5b1/coverage-7.15.4-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8b4f1c3a69ca580f3fbd6b2046915f536d7f586874f25c1bb23add2a3c88d50f", size = 264955, upload-time = "2026-08-06T13:50:03.023Z" }, - { url = "https://files.pythonhosted.org/packages/ee/de/dc010c7a3691f396d93bbc26bfcafa1c2a3a351cd520470f15faf5795bd5/coverage-7.15.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:ffb58d7eff5b7f6ecc6fa21d6288ab7f968a212cb67d682c269c09b9eba3b66f", size = 267949, upload-time = "2026-08-06T13:50:05.557Z" }, - { url = "https://files.pythonhosted.org/packages/78/ea/dc96a11375e83c045c2f7c61fb6918277cfe9401db7c0f7b1d111a84b2e5/coverage-7.15.4-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:d9df165544774574ee004b953023d1bebada1894a80b1052a43d798b0f676e67", size = 264421, upload-time = "2026-08-06T13:50:07.612Z" }, - { url = "https://files.pythonhosted.org/packages/c8/86/b77131a0f9503ce461cd577076147d7a9040f0c5dda772686f729e2cc9cb/coverage-7.15.4-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:f9de0a24a4079b53e523b5c5e2c5945ec251ab486652659955187cf255a259bc", size = 269121, upload-time = "2026-08-06T13:50:09.58Z" }, - { url = "https://files.pythonhosted.org/packages/24/24/944bc35007862955e7ebf05754e645419dcf5d7526c52735cfa2715e8ebf/coverage-7.15.4-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:150089274bdc9f940628552cb92844e0223c987f1902ab8efe9f45a2ec758d88", size = 264565, upload-time = "2026-08-06T13:50:11.722Z" }, - { url = "https://files.pythonhosted.org/packages/c7/cc/a3bb9f93e7e740659163e2ea584f8196ddcd2c456a5dbe15f6c50105fec1/coverage-7.15.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:a58a94fed5da6997d258e8f7668c1e195fbd04a691d781b7558f1e468f9e68bc", size = 266522, upload-time = "2026-08-06T13:50:13.786Z" }, - { url = "https://files.pythonhosted.org/packages/49/dd/e0e40f3560d878d888c580698ff5ad1179f5e1c3ac949684ef66b41a3817/coverage-7.15.4-cp315-cp315t-win32.whl", hash = "sha256:ebd5a6d8466ff30836572f3ba2cae8a5e8f85029b1c6d5e2ed338dc472a5166a", size = 225068, upload-time = "2026-08-06T13:50:15.825Z" }, - { url = "https://files.pythonhosted.org/packages/c6/7e/37732ea80eebc30e976e4cdab15c190bc42d96959a42e38ddf6f8c60468f/coverage-7.15.4-cp315-cp315t-win_amd64.whl", hash = "sha256:288bde2a2d7ab6b6c2d7252fcde8b524387f2d970bdba9658fc6f8bbcaef0f9b", size = 225895, upload-time = "2026-08-06T13:50:17.928Z" }, - { url = "https://files.pythonhosted.org/packages/c6/08/1e00f7923eaaba45fb3d51dd794125fc766304b1df264f3a9c6557bfb30e/coverage-7.15.4-cp315-cp315t-win_arm64.whl", hash = "sha256:68be5e1de60ff13c9095bbec0e5a7fa45b33b101752215b91345ea1f61c4a278", size = 225213, upload-time = "2026-08-06T13:50:19.981Z" }, - { url = "https://files.pythonhosted.org/packages/b4/d9/e70c286c979378f061d8266e279b686ab0b0b688e1fe0af864684f23a77d/coverage-7.15.4-py3-none-any.whl", hash = "sha256:964730a1e9de9c0cf11be6a1a3c79ce419c34882842abd256086ba4698705e84", size = 214332, upload-time = "2026-08-06T13:50:22.192Z" }, + { url = "https://files.pythonhosted.org/packages/53/d2/c76bf165ff01664ca8b1ca7f2b2b5f311353d3959dbac1187dd21c6cc7f8/coverage-7.16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:22d8802827404be32f5a4d6ddc037f6fa0074b7d06702c0224cb598def8b665d", size = 223019, upload-time = "2026-08-28T21:51:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/16/7d/a47cebf71cb789b6e25de07035d350bff110d02f9c28bf32f92b4c818874/coverage-7.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a739bf08cdca0fad51b73322e4fade0102dd87794e278450b5ee87ef827954db", size = 223524, upload-time = "2026-08-28T21:51:03.632Z" }, + { url = "https://files.pythonhosted.org/packages/51/b3/42e46d7e247ba33758156a0cc88dc64715f7e7b04640fbe430c4da437ab1/coverage-7.16.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f99d12f8234c00b88b8077fedf288b25c77f746de312053b7db90fa756ecbdb3", size = 253934, upload-time = "2026-08-28T21:51:05.365Z" }, + { url = "https://files.pythonhosted.org/packages/9a/27/ade10badacc00076854f0c5086fcf8975bb1a379d5288b587509e6ee9763/coverage-7.16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7cae7715afa51dd7c9c42e6603bb46daf424c3449fdf06519cc658aa8d46e2e4", size = 255846, upload-time = "2026-08-28T21:51:06.922Z" }, + { url = "https://files.pythonhosted.org/packages/c5/50/38e5d8cf45af5db7419e9580bba4017113f8f1e2697cb6c52213bf7e7e40/coverage-7.16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55957d350452017f523b9b03ffac078f9a214e23c04a3d0a674569203550c719", size = 257953, upload-time = "2026-08-28T21:51:08.51Z" }, + { url = "https://files.pythonhosted.org/packages/9b/bb/2f44b99723d0306095dacdf90f994631e299ff8f087a384b42ecc2d1ccb9/coverage-7.16.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b670bd5fa93d9b6855b2837217b45a90863118e2de5e9e033aebd46d07cd08d3", size = 259915, upload-time = "2026-08-28T21:51:10.155Z" }, + { url = "https://files.pythonhosted.org/packages/ab/7d/3f1c312944d88b2d3cae8af72007c15dcf5f92bda6da6d433c2d5f050ee7/coverage-7.16.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fe5aa402d02318db2f41e471320b2ecca6085b8f595a034c037085732e49c04a", size = 254028, upload-time = "2026-08-28T21:51:11.845Z" }, + { url = "https://files.pythonhosted.org/packages/7e/f6/52a7e26baeeca7f3114b15da5e840bebcfe6491eb234f6922d33c79ee8fc/coverage-7.16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fddd26ed9a2527a7e23f7e4c1fd0734c4a5b45f77b261da1c536b20a7d2e6f0c", size = 255648, upload-time = "2026-08-28T21:51:13.614Z" }, + { url = "https://files.pythonhosted.org/packages/c2/d1/0673e78d9ca29d56f663623791338647753c673f0bc964e860086da07bce/coverage-7.16.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:b2af58ecdcec37fe633d4865fccbc8c00d8aa3b31c099bcacb2720c9a0be6ab9", size = 253708, upload-time = "2026-08-28T21:51:15.19Z" }, + { url = "https://files.pythonhosted.org/packages/6c/23/b74c87828369059415b20884b6f48260f049bff750d6eb454be8554732ab/coverage-7.16.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:a3cd34b9025d62180ce2b5dae8a985bfa6cb8c05ecd57fd34ffc1ff751b5a74d", size = 257479, upload-time = "2026-08-28T21:51:16.988Z" }, + { url = "https://files.pythonhosted.org/packages/a9/b4/09e172472c45a956e226dddf82d449f245764208b7cea47b32a73df955a3/coverage-7.16.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:ebaf39dd13f8af65fe5f0316b81046228ef4d91d3c3766192b418753649896d6", size = 253428, upload-time = "2026-08-28T21:51:18.803Z" }, + { url = "https://files.pythonhosted.org/packages/62/22/e378e4f7ffa290ea4775b34e319fa182640bba650a2c6781af791b66b79a/coverage-7.16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5dad64d9c17cb1983adef07998e6e2e1cf870a156f1ea80f81ce1970f4c545ce", size = 254337, upload-time = "2026-08-28T21:51:20.785Z" }, + { url = "https://files.pythonhosted.org/packages/51/6f/9a6ca653d86e46c3383a905f726a28bcf7bb2528088794d30a53687b381c/coverage-7.16.0-cp311-cp311-win32.whl", hash = "sha256:38b8e1e73750b8965d1154ed733f5303acd4e24ee2d5ee872bb1bfab744a31ce", size = 225103, upload-time = "2026-08-28T21:51:22.685Z" }, + { url = "https://files.pythonhosted.org/packages/08/0c/6d4627be89ac02f579d88806875a5d6e328c59d7d79c594643c7a4460ef6/coverage-7.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:cc12e5e32acdd62fe5895939695579560639853219288519685c75b7e968d63a", size = 225577, upload-time = "2026-08-28T21:51:24.334Z" }, + { url = "https://files.pythonhosted.org/packages/f2/3d/d7be38564d00a17775426685776b4bf18e8a6048a085eccf65d75eb0fa5a/coverage-7.16.0-cp311-cp311-win_arm64.whl", hash = "sha256:17fc3628f99812fec24f40092af34c1c73274d331babab3d1d768a75de650cf7", size = 225126, upload-time = "2026-08-28T21:51:26.101Z" }, + { url = "https://files.pythonhosted.org/packages/bc/9c/8d2688694f53dc0b0f0e4783c7eb3c4bb1e79beaf1411879f6dabedf4607/coverage-7.16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d1c77c3579ac42798f8b7eed6d3dd258debacca32c8753fc8a1f6eaf1db644f5", size = 223194, upload-time = "2026-08-28T21:51:27.767Z" }, + { url = "https://files.pythonhosted.org/packages/ca/11/f002163dd688aa3fa49ac6a424b7c2705c7fcf80fba18ec9f586d77827ca/coverage-7.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1f81cb1554c3712e41649ed5dc98656b50b958e4da12f0f5adb681ce3db92831", size = 223553, upload-time = "2026-08-28T21:51:29.46Z" }, + { url = "https://files.pythonhosted.org/packages/81/65/f9d469e97c4554372a710650a109004a2434dfc56f577142e5d6057fa0cc/coverage-7.16.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e701938ec9081d3e400a0c9a9a8ae0f7ca44214741daeac4454b1c6ef6dbd19", size = 255054, upload-time = "2026-08-28T21:51:31.54Z" }, + { url = "https://files.pythonhosted.org/packages/95/29/dd89fd39af1a3b6e9a9c3eddeaf03f6376ba517d43d6cbf8b519177e2a10/coverage-7.16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:719a3feb6220dd32ed932d4c3676d17fb8739e2643b29c0e7c3af400ff80ac44", size = 257790, upload-time = "2026-08-28T21:51:33.374Z" }, + { url = "https://files.pythonhosted.org/packages/0a/64/208d26cedc525d6b5db9c492cf9130784c42d9eb08d22badaa7b806005ad/coverage-7.16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:87771ecf986cff55e87413238cd5e4f54d949c2074bd6fc1657d26a56314ee24", size = 258904, upload-time = "2026-08-28T21:51:35.096Z" }, + { url = "https://files.pythonhosted.org/packages/1f/98/28e2752aa9a8baee5798edade9c95602ca200f4e7eeb503eb64df42e5921/coverage-7.16.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:47d5e1fc0b321c8308a2aacee0497c435b08acaa629b7059798fdf6fc3006352", size = 261165, upload-time = "2026-08-28T21:51:36.744Z" }, + { url = "https://files.pythonhosted.org/packages/eb/77/fa6ae699a0ea2bc12acb38a85d96b786fea0f833c12b5756056350e0e547/coverage-7.16.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:01b18b8a6c9cec8d5f45550e2501426ed982cf2c35016b0acd2ba9b5d8b2fb06", size = 255416, upload-time = "2026-08-28T21:51:38.495Z" }, + { url = "https://files.pythonhosted.org/packages/89/c8/5ee46d1de7d34cb00ba08b5c50da1971114dbc09ca9898ccc32975ec74dd/coverage-7.16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:32c56b5b47c50635081445ac404dd08c2d591b9c837c22570aa9e182c3b42cd4", size = 256825, upload-time = "2026-08-28T21:51:40.27Z" }, + { url = "https://files.pythonhosted.org/packages/15/f6/d59e1c0693ad48855fe20169fbf6ee5befefe5887a7fabf5f0bcb464a2dc/coverage-7.16.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:6ad3bbad240ab937512156bc944fdee63ac4dd34a7558a3094548fd4c1150c02", size = 254970, upload-time = "2026-08-28T21:51:43.136Z" }, + { url = "https://files.pythonhosted.org/packages/df/7b/b51bbe05b3a7565927fccfb1be42b8b3c1f4ab15e53d91b303e9923969aa/coverage-7.16.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4c1f16d5555a195295d0dc9c902612270e3dfed6a11f3bf7bc470b7b6a79ed3c", size = 259039, upload-time = "2026-08-28T21:51:44.983Z" }, + { url = "https://files.pythonhosted.org/packages/fa/04/d513f816456a8a43c1859abe88a37d01d7d2515b6c3e24ebb3c9b1dd44ec/coverage-7.16.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:f6c9c21a8bf0d19788f3c5f3e020c90317a0a63ef60521b376003801e21250fb", size = 254539, upload-time = "2026-08-28T21:51:46.733Z" }, + { url = "https://files.pythonhosted.org/packages/dc/54/5542190ceb97e0d1333a4ce0c8f95b2ef2efe790f1ad018a4b61766f849e/coverage-7.16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:06f20145a9eb5bf1fd1dde3c0bc2af2e7c22135ab07ca6284d6ada7cc3904c4e", size = 256410, upload-time = "2026-08-28T21:51:48.363Z" }, + { url = "https://files.pythonhosted.org/packages/ee/28/78643f361ff6bb5b2ade90f8bfc8395fe9ca367a18c101f8991215b4c65b/coverage-7.16.0-cp312-cp312-win32.whl", hash = "sha256:916cf8d25c1ce148f7eceb1d45afc9724841200110adc4e53250391852debd91", size = 225239, upload-time = "2026-08-28T21:51:50.22Z" }, + { url = "https://files.pythonhosted.org/packages/67/61/8e76b36c36b1a033dc933dd2480db96b04ce3be975793ce3fad122e7174d/coverage-7.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:78f8b56261d608be102c62edd3a60b66bcd0b581f3f86fdcabaf8b8d95adc950", size = 225775, upload-time = "2026-08-28T21:51:51.912Z" }, + { url = "https://files.pythonhosted.org/packages/c8/f3/bb4787a4b81c1792ca69b502f5f730dbbb609f73fed552ab074c6b92cb8b/coverage-7.16.0-cp312-cp312-win_arm64.whl", hash = "sha256:577c2ac8c0036f6f8edd3a7783a9e67302b17771d1abf0fd2ed246e3158be51b", size = 225159, upload-time = "2026-08-28T21:51:53.667Z" }, + { url = "https://files.pythonhosted.org/packages/54/c5/e62c87f4799d1e3647d5b2ae16ea1d12205d72fde1ea8529e13fe050f678/coverage-7.16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1545c52ce756b8a97007f439a220297f1cd72a2cbbcdffccdf1c1f70e74f9a42", size = 223215, upload-time = "2026-08-28T21:51:55.628Z" }, + { url = "https://files.pythonhosted.org/packages/89/e9/5e62fda9397175fb206f75368b6e85da06d831c181b6d0f67ca073cd2f89/coverage-7.16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0598aadae641f30a0796b75b45c0b9c5de8619bd5cfb251bb0cc254e86e6dd13", size = 223585, upload-time = "2026-08-28T21:51:57.355Z" }, + { url = "https://files.pythonhosted.org/packages/b9/40/bede08621b1ba67e88c4d3336c22b52cb7911ff1fa4ef055344b6670e58a/coverage-7.16.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4080ad6bad9f14690e6b2104f5e8d137ccc65a4b5427a36662090637d4bd16d5", size = 254575, upload-time = "2026-08-28T21:51:59.233Z" }, + { url = "https://files.pythonhosted.org/packages/12/d8/ab0bdaa45dfd6b8cbf1a3ec548fdf827684b1997f9724375c5b3e89144fb/coverage-7.16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e9883a2f8206ce3af59117dc278e5d043fea06912bca3f199816129e5e2de354", size = 257172, upload-time = "2026-08-28T21:52:01.015Z" }, + { url = "https://files.pythonhosted.org/packages/1d/bb/135de81784bbd7dfedcab2b92b03d71d75b09b0815b42d6dabb052def5a6/coverage-7.16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:984e5430fc6f858385009e92549955157d79335b1f3e13e1031e0f89d1284261", size = 258410, upload-time = "2026-08-28T21:52:02.76Z" }, + { url = "https://files.pythonhosted.org/packages/ad/72/ce44ecc062fb2e43d9447bb76154d091c2139232f20c125297c4b58f4c6a/coverage-7.16.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b1374099dd1ad0d31fbb6c95d00a56a3c5e85fb3343dca14fc12f78323a2b42a", size = 260539, upload-time = "2026-08-28T21:52:04.821Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c4/9389c36a41e59406ca2bba493807c2294d2e5186a7e9ebcc2e63a0f2a711/coverage-7.16.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:34d8686bce035c8465b318a8c2890e69ba14a00801a27f4eb6bdc97c23944d87", size = 254756, upload-time = "2026-08-28T21:52:06.68Z" }, + { url = "https://files.pythonhosted.org/packages/ad/0f/7762447b15e01fb84263608540123c4d9941f06303265ee74d801ccbec0e/coverage-7.16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:857fceba6ff4b507ee0ad98798a33d544a8473df0c542bf04251ee4ed5ee6292", size = 256540, upload-time = "2026-08-28T21:52:08.529Z" }, + { url = "https://files.pythonhosted.org/packages/e6/fa/c60dc75a8346c1dbebebc7279b19971c88f70dd575f0bc10bc0cb16f92d5/coverage-7.16.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:bbf08d951abaa1ce89e28c998361d56b952413846b459cd017f116ad4c9adbfa", size = 254508, upload-time = "2026-08-28T21:52:10.323Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f0/4e0834f3a1fccaa8bf625a2a1d73bde0fa32577dc3249853c0dd0e7f2b20/coverage-7.16.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1a03e78f53e4d2ab13adac19958a89322d1829913e5623d642627bf60b35da21", size = 258659, upload-time = "2026-08-28T21:52:12.124Z" }, + { url = "https://files.pythonhosted.org/packages/b4/ec/fe712d3a11fd6e874565a5fa5497c48b8ece561d9611da040b44cdcf8386/coverage-7.16.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:dcd3dafcdd78305d27c59a1006b53a4990acb89e68d8fbe0992f4f83503c827f", size = 254326, upload-time = "2026-08-28T21:52:14.181Z" }, + { url = "https://files.pythonhosted.org/packages/e7/78/093e12072e01034c65ff380f76c74b79dd83e44fa92b689a2154389be734/coverage-7.16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c1bcfe470a796fbea6234accd81d258a31574dc0b7bf569e16be757572c4de17", size = 256102, upload-time = "2026-08-28T21:52:16.003Z" }, + { url = "https://files.pythonhosted.org/packages/9b/c0/265176117ca5d06e3f65575842884cdda96cf213350a31e9d41c80d65854/coverage-7.16.0-cp313-cp313-win32.whl", hash = "sha256:1420370276f1694b663207b8245c3628aafb9624fe3cebf313a13d860e55ee67", size = 225250, upload-time = "2026-08-28T21:52:17.82Z" }, + { url = "https://files.pythonhosted.org/packages/1f/01/8a87f2c04fde322430b45d16d8f543693e9894c5b2d2ca238a287c00beca/coverage-7.16.0-cp313-cp313-win_amd64.whl", hash = "sha256:496277c8d7beed695e02c7be53516a0152e4caef8738a0feab6a638546cce449", size = 225790, upload-time = "2026-08-28T21:52:19.641Z" }, + { url = "https://files.pythonhosted.org/packages/23/40/c21feacd9edfe7063195bf9cc84d650e9938fc6a23063e4f027199b160e1/coverage-7.16.0-cp313-cp313-win_arm64.whl", hash = "sha256:181c2906b9b3759955c1c33c51fbb91c754fbd0b82ea49e2c81061f5a052082c", size = 225180, upload-time = "2026-08-28T21:52:21.613Z" }, + { url = "https://files.pythonhosted.org/packages/ea/73/850675f262391b322c4c988b6cdc32cdc6629288f0fb158687b587a393a8/coverage-7.16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:54b7fba6a74d010de34319a0419d5b65af8c00f539ad0b6f39fc6f342ab99697", size = 223258, upload-time = "2026-08-28T21:52:23.558Z" }, + { url = "https://files.pythonhosted.org/packages/61/c1/4f54c6d47c80d1cc58ef8fe6b74e6eb50f9e2c0f6e2de6cf38dbca2937b8/coverage-7.16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fa4ff0b3dd52208d2b30903022d5087f82000507b504753dfeee83e4f32d6883", size = 223587, upload-time = "2026-08-28T21:52:25.627Z" }, + { url = "https://files.pythonhosted.org/packages/3c/be/298f2456230fb44e272a4e53a41b3f3c39f0821c242d7b7daa9787b4d6f7/coverage-7.16.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:35a9676bf86097f790113ebd9fb67681804ef54d40941d2f10ba68c02239e575", size = 254632, upload-time = "2026-08-28T21:52:27.689Z" }, + { url = "https://files.pythonhosted.org/packages/a3/9c/a1bda6439c19c4783d50df896142b67b9e7d432db36675d339a32778669d/coverage-7.16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f98d438add63546745e5e847192e3e9ab897ed6f2ca96f8281e2f5a15958ae62", size = 257139, upload-time = "2026-08-28T21:52:29.741Z" }, + { url = "https://files.pythonhosted.org/packages/f8/cd/cd735c9be757f97237c305f36897a5e5b348bdbc12ebed3b2b80060dd8a9/coverage-7.16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:151855767480be14db595cbc2040f6a4db965cdfeebd354d79b0256742b029e0", size = 258484, upload-time = "2026-08-28T21:52:31.68Z" }, + { url = "https://files.pythonhosted.org/packages/e4/04/84b2e1e8aae9db3f549782f28ce25bba5fd6a9c7bfba3782ffe8b4cd2559/coverage-7.16.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:183613f664718b340589d7f005c7e92b4b601cffd20a8a4117cfda3e983b080f", size = 260798, upload-time = "2026-08-28T21:52:33.642Z" }, + { url = "https://files.pythonhosted.org/packages/8a/4f/e04cf52483619a4dc5dd6367b30c9a8ac52243567fdfacec9b11a441565c/coverage-7.16.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:785b114356c99c0dd5b3f57b9696cfd57b7704f4c53847df8dc88c6cc0d9bcb6", size = 254612, upload-time = "2026-08-28T21:52:35.543Z" }, + { url = "https://files.pythonhosted.org/packages/da/33/627c4113f66bfffd43807f54dbf080c4632ecf12e4ef7a3bdd4ec38e46a2/coverage-7.16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:30f5aee6d1d517abcdfd4f9cad027969ff79a1440a22da263f9514e31b5b66e9", size = 256495, upload-time = "2026-08-28T21:52:37.485Z" }, + { url = "https://files.pythonhosted.org/packages/3c/38/aaca432f4e008a88f2bc4d1459aa7016d8d1bbbe801f7e4fa3cf2746557b/coverage-7.16.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:190ffa0f5af966254c249fb3aeaca2cef389785e3e287fd577d39e134d20f8a3", size = 254454, upload-time = "2026-08-28T21:52:39.425Z" }, + { url = "https://files.pythonhosted.org/packages/cc/db/8430aa87ef0a508f4c17c1b8fa7e0cf80231988d9081aa36c194036592d6/coverage-7.16.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:0ccc37c00e1a5d30840902c54557e104d04aead872cedf6d2281c8725a467e06", size = 258728, upload-time = "2026-08-28T21:52:41.32Z" }, + { url = "https://files.pythonhosted.org/packages/76/88/cd8aa8c82493ffbd291d3ef5554452fffc634c6c6098a04ac848c79c98f3/coverage-7.16.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:6c60cde430c0e7e3be612973af39b4cff90ec2e2defe7b2b701daea3a0ffff04", size = 254271, upload-time = "2026-08-28T21:52:43.278Z" }, + { url = "https://files.pythonhosted.org/packages/a8/49/fe16c811ea9314a84b48f34e4bf5a3d9013091093b285a74b2272fc863d7/coverage-7.16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c5297028c8df849a61b29129cadfe682f90b5b396f528eb319a57d7678eefdad", size = 255927, upload-time = "2026-08-28T21:52:45.461Z" }, + { url = "https://files.pythonhosted.org/packages/d1/45/d0bd410e78cfbf768acc8099b335e1d5c0d5c26103c796d2bebdee001715/coverage-7.16.0-cp314-cp314-win32.whl", hash = "sha256:136988df5bc5a48795d9c42c75c4bbda5d9a78e750a080c1233010edff93a1af", size = 225424, upload-time = "2026-08-28T21:52:47.658Z" }, + { url = "https://files.pythonhosted.org/packages/17/78/1ce6ce4646822e9308dcdb1942eaf31bfd7da43247b8886338b0d6fe3767/coverage-7.16.0-cp314-cp314-win_amd64.whl", hash = "sha256:ce2ba5e9f1842fe09165825abfb3bc6b527c71a27bc2eb3a10f2284ced64506d", size = 225918, upload-time = "2026-08-28T21:52:49.692Z" }, + { url = "https://files.pythonhosted.org/packages/f9/cd/e1323fe3a7dfcdd709451a43fe708ca1dfd36a7fc07b34eb7bd1dfdfb52d/coverage-7.16.0-cp314-cp314-win_arm64.whl", hash = "sha256:a89d07e48d9baead9a15599923a02f62c6df6c3d85aa84ef34be3c9fd6aeb91f", size = 225344, upload-time = "2026-08-28T21:52:51.665Z" }, + { url = "https://files.pythonhosted.org/packages/39/fb/1c15460d4cf915f09ae3ad3862fef4f901838991c5641b0cec545050d810/coverage-7.16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:6e2854b62601c89a63814ad5def3b90d99c6724cc4cb977f75b725e5fca4b1e3", size = 223986, upload-time = "2026-08-28T21:52:53.572Z" }, + { url = "https://files.pythonhosted.org/packages/9f/73/347d2d0009ac211f79ee2a2364fd2aa19d6b9628dc22ed13a9b9386097ab/coverage-7.16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f093faf23df888518d273be6da65f0ec5a25b5d8b670231e4d87de07361042e7", size = 224254, upload-time = "2026-08-28T21:52:55.59Z" }, + { url = "https://files.pythonhosted.org/packages/5a/2f/51442e6ad9d705369596f08496021647e276d5b57311818fd4312d93509b/coverage-7.16.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b7dbbbf6551eb94618e7bc76ab61cc2740a5b3d13294171bd6adb36e12346c3c", size = 265619, upload-time = "2026-08-28T21:52:57.645Z" }, + { url = "https://files.pythonhosted.org/packages/ea/8e/0f752276f6d13efbd019ab6d90792e20d6272c44cda039dc5c6d27b91e7f/coverage-7.16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:51e7d0e311d2fba3915f971236cbdd4ad821fc7a23988221c0b33c964b0eba22", size = 267734, upload-time = "2026-08-28T21:52:59.611Z" }, + { url = "https://files.pythonhosted.org/packages/fa/02/4df3baef8029881c9d1a380859f2be73f90080d430def567d182e8566a35/coverage-7.16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0bb04ee77e557d7476471969d35fbbfb5fc8a4152e9409aa5811780c36d9b23e", size = 270156, upload-time = "2026-08-28T21:53:01.658Z" }, + { url = "https://files.pythonhosted.org/packages/9f/30/ce10fdb74055ebbfb5c8a025d8845dc19c76e4b2c42bb5c755b56678990c/coverage-7.16.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c72c9b201dc0e8c2c8821d49858fd865010d08181bf877d2320971b6464ebfd5", size = 271279, upload-time = "2026-08-28T21:53:03.698Z" }, + { url = "https://files.pythonhosted.org/packages/71/19/c7e1fc9504d90da848493bad4018dd235c713a80633e48c5f0a41b63d45e/coverage-7.16.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0fca700cae4635656668ba6e2b66a85aac9f2622d7b2bcf82e844c409eaa1313", size = 264677, upload-time = "2026-08-28T21:53:05.741Z" }, + { url = "https://files.pythonhosted.org/packages/a4/f3/4021519dd41583ab396c81955387f927779641f6bac26818b6918a45aafc/coverage-7.16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:584896fb8b650e999e24ef57e9513e482c12f8e15a73ee9d4584e23c99465867", size = 267610, upload-time = "2026-08-28T21:53:07.763Z" }, + { url = "https://files.pythonhosted.org/packages/55/fc/df65aac93938d8f506434c8e96440c1d696f6be0a6a01d3c6bfe5d49403e/coverage-7.16.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:949eae7e0f562b1518355aaef4b03523e49a6d3fea12aa3542d9e36c863f8267", size = 265217, upload-time = "2026-08-28T21:53:09.786Z" }, + { url = "https://files.pythonhosted.org/packages/32/2d/dc9a5e62715165fcb4c715f965f411e324917c9daeddde16536e9d36ce3f/coverage-7.16.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:64f0611ee05364fc85cc3e5bc371804117a76fd337720e6017332fc7c534257a", size = 268948, upload-time = "2026-08-28T21:53:11.866Z" }, + { url = "https://files.pythonhosted.org/packages/8b/4e/fe73a5560f25fca52acda76fc1554f30de081793ae4de97e920f8ab161d7/coverage-7.16.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:050a291b3cfe5e0df5999ef2fa5a7aff6e2db329f069d47eb63f02bde2e7e96b", size = 264061, upload-time = "2026-08-28T21:53:13.996Z" }, + { url = "https://files.pythonhosted.org/packages/b3/f7/bb78cc4b97085ebbd77fa18cbc25abfab462814efa3e2363b4e50885c775/coverage-7.16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a336b1e2990a64f5c356a9b8380fb9c029d56c832b801255250c44d603271bfd", size = 266371, upload-time = "2026-08-28T21:53:16.233Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ec/84b4af5cd4ad498477b3bfb2217e47b048da919451053790efda66f7383c/coverage-7.16.0-cp314-cp314t-win32.whl", hash = "sha256:058631257350b31784ed43ceb808298b6f074edf4ebca4c7ce5082e6bf873a61", size = 225736, upload-time = "2026-08-28T21:53:18.632Z" }, + { url = "https://files.pythonhosted.org/packages/7e/43/50fc0e6c675c3ef14895a74bab2d6120cb5d6f4b562a3d3f5046797758dc/coverage-7.16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:ed35097438dfa980c1ec75bc83edf8acbe7a374d7007e571957a257fbd0e2fb3", size = 226570, upload-time = "2026-08-28T21:53:20.754Z" }, + { url = "https://files.pythonhosted.org/packages/fc/24/9effce7bcd3c6eeb4da3561905837509e582dcdde7a7f07d6ef2c8512f76/coverage-7.16.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0466f4a5c0370461b7d8c7eb259d7d1db0b5756f13d66230b04d22a1d380ee11", size = 225879, upload-time = "2026-08-28T21:53:22.747Z" }, + { url = "https://files.pythonhosted.org/packages/4a/2c/318e4379106bc8047ba235e3732ddc87d1b393ac3db9776f5405ff14f322/coverage-7.16.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:80d7d5d744a041f08637df743ac086204ec5acbcd8432a42b00b49e607358024", size = 223257, upload-time = "2026-08-28T21:53:25.376Z" }, + { url = "https://files.pythonhosted.org/packages/81/4d/a5c54d9144e9db6505749758ba50a28be624148873751728a59cbb72d27a/coverage-7.16.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:c5feffce90c3d602e149de1c477578efc34dee5f069f9764cc15808ce01ee15c", size = 223596, upload-time = "2026-08-28T21:53:27.461Z" }, + { url = "https://files.pythonhosted.org/packages/bc/97/38e93a10899c9315964c0a4e729b3e5867f8f46e977808f9c6fbda52525a/coverage-7.16.0-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:acadbf2f2a18d7f9c7f119ac798c00c540d7c79c93abd71ed648c87891303633", size = 254699, upload-time = "2026-08-28T21:53:29.715Z" }, + { url = "https://files.pythonhosted.org/packages/fa/7a/acddda030b4630f68167f3daa94b41d22071847822a70d8178d43dcf678e/coverage-7.16.0-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4212cec9b42fd9929e70b462732fefd8b13406371871c82f3c14397499d6550b", size = 257614, upload-time = "2026-08-28T21:53:31.948Z" }, + { url = "https://files.pythonhosted.org/packages/15/7e/225b182497c1ce6d3f0d76a3074a4dbc9f272300e92bb100df53b03de0aa/coverage-7.16.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c5a43cc0ef101637ae920a9eed24cf0549ef815621eae68b3ad577ec5a7ad2f", size = 259236, upload-time = "2026-08-28T21:53:34.291Z" }, + { url = "https://files.pythonhosted.org/packages/2e/19/76641ddc50cb2410ebbd0ed7fe1052614d0e5612e802a2817521adb9febb/coverage-7.16.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c76a9b50a344261fe4a9bd20c322b48d3913cc48e8c37f78c21a596008296e68", size = 261433, upload-time = "2026-08-28T21:53:36.401Z" }, + { url = "https://files.pythonhosted.org/packages/12/9e/5f89de8b7c2017f36b68b4e4a25940723a748b21474820bf61e8bce0891c/coverage-7.16.0-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:80cf547379ad6b1878fd03b033b51188beab4b41824c96e7839e014a4cb947be", size = 255182, upload-time = "2026-08-28T21:53:38.496Z" }, + { url = "https://files.pythonhosted.org/packages/1a/c1/ce94b2ec502e79775efb5efa22c741ebb0bd2be10bdd29650825ff57bdcb/coverage-7.16.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:4b1d09cb5d8dc2c7164450f5217e6f0717497de9c588806a0780d352abef904a", size = 257329, upload-time = "2026-08-28T21:53:40.87Z" }, + { url = "https://files.pythonhosted.org/packages/86/8d/3f5374df3a6ca19ee5f98a6bd21dbb05f1e9d399bd9978e9821d260eab5e/coverage-7.16.0-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:cd1e85abed2d2499c16664137ac802356316f92b4e2bf3c150bdf0c45f5dd9ae", size = 255210, upload-time = "2026-08-28T21:53:43.393Z" }, + { url = "https://files.pythonhosted.org/packages/8e/b8/1bc5751496d0be6fd9dde8ca547d9a8a9f07847856aba3f3ae5ac594cd81/coverage-7.16.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:360967a6fd77794c167529eec2d16ff8e38216110619d23acc3fd466a1648bee", size = 259442, upload-time = "2026-08-28T21:53:45.725Z" }, + { url = "https://files.pythonhosted.org/packages/7a/dc/8aca78e47e1e6fcc761cd28a20daf4a84bd847a7369e2701a93ccfc3d1fd/coverage-7.16.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:92cbc2bf4f7f67c79f1d3ca4fe8c50faddf48e852a3d07eaaf02dc014889832f", size = 254618, upload-time = "2026-08-28T21:53:48.292Z" }, + { url = "https://files.pythonhosted.org/packages/73/fd/787842cdf6ce16ac5c1bd8a26549bab3b3f27b02500075bc540dc7853bca/coverage-7.16.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cce4dc8528453128c6fae523b15f3887fbea1d4d7c9eb9639d3d4fdcbe570c73", size = 256541, upload-time = "2026-08-28T21:53:50.805Z" }, + { url = "https://files.pythonhosted.org/packages/ef/79/8df302cbef373dd1f3401044cdb94dfc74517e5af2af27b4d0e721557e0e/coverage-7.16.0-cp315-cp315-win32.whl", hash = "sha256:5205baea687133613dced668a3d0168ea1479349615bfc255849a7944988c889", size = 225429, upload-time = "2026-08-28T21:53:53.177Z" }, + { url = "https://files.pythonhosted.org/packages/85/87/5bad7ac45f76b3728ca211028ee561c2ede3ba44da401129e28bb8737291/coverage-7.16.0-cp315-cp315-win_amd64.whl", hash = "sha256:4fcb5f07a9b7083bfb715115d27ce263ba2b5b89dddeee536b295ba0e3c2c627", size = 225903, upload-time = "2026-08-28T21:53:55.535Z" }, + { url = "https://files.pythonhosted.org/packages/cc/ea/67d84b11caf240f059ec313f616d82212df5004e8bc85802c1edfc50bb3d/coverage-7.16.0-cp315-cp315-win_arm64.whl", hash = "sha256:d568a8adcec0eda42ec23e5e65dfb8c184fc255120f9e99b484f7c869d923fb9", size = 225334, upload-time = "2026-08-28T21:53:57.769Z" }, + { url = "https://files.pythonhosted.org/packages/65/21/a88349cce3ff720729b754916ac47e2e3646a8137552e4fa7cdd5967cc7f/coverage-7.16.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:3e8037e8213adf882e9d7eedd2c5c557933ab0b9632c42d98fe98ec9bcdb4025", size = 223980, upload-time = "2026-08-28T21:54:00.082Z" }, + { url = "https://files.pythonhosted.org/packages/fd/02/4d54abf3e6a4d8b7675921b20e91163b1064a5a9dbefebb71c05065dd136/coverage-7.16.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:289f2ed4d56eebf029b649e7dfc3c1153b111962a75e294cdd8e4a1598a04cc3", size = 224276, upload-time = "2026-08-28T21:54:02.381Z" }, + { url = "https://files.pythonhosted.org/packages/f6/39/10dbc96d95d20b9b041045d293480bd49e536180e93af62dd7662376284d/coverage-7.16.0-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9b83f6ac575530783771c8dcf05284f7c8b5b12f1e7cb226d63445aac4497a3a", size = 265135, upload-time = "2026-08-28T21:54:04.558Z" }, + { url = "https://files.pythonhosted.org/packages/e7/3b/6b326544afd1a8aef3a495bbae109a7ab5baf23e04a2741d8d64e2df2ba2/coverage-7.16.0-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c3ff6580f2dfc5bec34717b85b2e6cf5ec993b721e7bb58a794babd525a8178", size = 268216, upload-time = "2026-08-28T21:54:06.97Z" }, + { url = "https://files.pythonhosted.org/packages/54/34/1dc8265f3ed990690e24d5f31ff79bc9fb9b25d54f9f89bebad5a6a8b7a1/coverage-7.16.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:507596cee23e9968b1934fe86d799b76166541af0a293930918b1b48a5c84bd2", size = 270772, upload-time = "2026-08-28T21:54:09.234Z" }, + { url = "https://files.pythonhosted.org/packages/66/a7/3a8463713a402b44044ec832f4a76e442ce4b3a207804303f4d1dc1a9bb4/coverage-7.16.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:edc2be98e6c55ccc5ff7832bb64f023a4b03dba39dfa84b850046cf08a8249b0", size = 271752, upload-time = "2026-08-28T21:54:11.701Z" }, + { url = "https://files.pythonhosted.org/packages/25/3b/dd5e795cfbe1842f69899189089ae289a96d6a68de312960ea668542e33c/coverage-7.16.0-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9c0690994b84a15a53bdd39e0b2fdb539b22533820623eb86ba75b93760c645b", size = 265589, upload-time = "2026-08-28T21:54:14.12Z" }, + { url = "https://files.pythonhosted.org/packages/1b/b6/fd90636cbd95cb018312f6ca1ca2bbd70fbe8e4ee6f3992fc36a4230364e/coverage-7.16.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:de24c62bf798940a14674a47489a81b79915ec4134f556d5199830e065225dd0", size = 268596, upload-time = "2026-08-28T21:54:16.303Z" }, + { url = "https://files.pythonhosted.org/packages/91/10/ef2d59264f3b3b358cc5885ca375e6cdbda7c195e78304d5aae800a72d9d/coverage-7.16.0-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:69474d81f198774c9d2937599ca5da04c9e1c5de5032da23c607ce4960ce360e", size = 265072, upload-time = "2026-08-28T21:54:18.597Z" }, + { url = "https://files.pythonhosted.org/packages/3f/5b/400891c364c0170408d172501b340b18611800f4c42d8fbb16f9f5497c24/coverage-7.16.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:72a0795cc6d34acc2b03dfeabdc82b61b72087f2737018b56ac92c1cf5446c54", size = 269768, upload-time = "2026-08-28T21:54:20.985Z" }, + { url = "https://files.pythonhosted.org/packages/98/93/9792c80271df04d287d21ed5d662fd8fa58b1737888d817679b1ce5d2fab/coverage-7.16.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:d9a218d3f9c7d6916684ed5ba94f620661117a730e733cd6ef5e87accc5872eb", size = 265211, upload-time = "2026-08-28T21:54:23.344Z" }, + { url = "https://files.pythonhosted.org/packages/81/67/5b8f827cfa6616e6bd7ba9397acfe7e3c4fd5b9fca4125511d5089f55d5a/coverage-7.16.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:49fa72ead28c8216f8916398a4f3c4669acb30a061822810ee20a727a1be2897", size = 267170, upload-time = "2026-08-28T21:54:25.85Z" }, + { url = "https://files.pythonhosted.org/packages/5c/ee/c135d2d2cb617d744bc3e13c922f2fae66964494176ddef225dc4656bd2c/coverage-7.16.0-cp315-cp315t-win32.whl", hash = "sha256:27461af9f3ed7d2cf2411eb083784f87055ebf42211789ae3a216c48609bc743", size = 225731, upload-time = "2026-08-28T21:54:28.151Z" }, + { url = "https://files.pythonhosted.org/packages/8a/4d/dc3d53eadf155916e183bf5dfacbfc4aa5bfb7f13b7da11c01caa7a05cbc/coverage-7.16.0-cp315-cp315t-win_amd64.whl", hash = "sha256:c5612cc20ca76abc883e50269af47c1494b42958bb63dbb9aa79729a1ab5f7d3", size = 226562, upload-time = "2026-08-28T21:54:30.42Z" }, + { url = "https://files.pythonhosted.org/packages/2f/00/ac9da1a60a4e84c3ad0f7db4723fd327154a8f9add210c0dcd2db3ec5156/coverage-7.16.0-cp315-cp315t-win_arm64.whl", hash = "sha256:2ddaa9e2af4760a329d80008b7a3b4762fbb0dbcb169199360f9a5179c32f2dc", size = 225872, upload-time = "2026-08-28T21:54:32.806Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5a/234e8fadf85c3cc48cb31c247b9e8e0c7f06ece80f5b29f9b8c241f9da4c/coverage-7.16.0-py3-none-any.whl", hash = "sha256:245f7de6d023a5bba375dbec9f2e0869bfa26ac0cc639bbb7b4c814884000b73", size = 214977, upload-time = "2026-08-28T21:54:35.189Z" }, ] [package.optional-dependencies] @@ -269,60 +266,76 @@ wheels = [ [[package]] name = "deepmerge" -version = "3.0" +version = "3.0.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b7/6c/9f4577a36d5f463a3a3f8322bd65d33e1a1a6b6ba1d692a5ebc3cba19015/deepmerge-3.0.tar.gz", hash = "sha256:14ed69f063de64b7743985c732ccff5d6c34ff4560946e7fbfd99086b853b9ce", size = 22279, upload-time = "2026-08-17T05:50:53.161Z" } +sdist = { url = "https://files.pythonhosted.org/packages/38/6e/5cb3548b4d3112fea529375e55e6f3cdc52b8054e3a66f203b1f888ba885/deepmerge-3.0.1.tar.gz", hash = "sha256:35b39a4cb92cf328d6eca61cbbf65f68a37c2ceb3085f0f853cbb2e52a59fc23", size = 22328, upload-time = "2026-09-01T14:09:44.383Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a8/d7/7f19bedd30b90b72865aeec3a29127bed6dee6c9ef0324bb5b4d424bb0e3/deepmerge-3.0-py3-none-any.whl", hash = "sha256:c8541c3e186dc88d19a5513ad3a0b2d0b22beaa780969fc0c13b995a64265365", size = 14855, upload-time = "2026-08-17T05:50:52.218Z" }, + { url = "https://files.pythonhosted.org/packages/20/91/600003aaad107e27553fbc9cbfc57e96fa37e0223d2ef9d7d3a0e8d8d070/deepmerge-3.0.1-py3-none-any.whl", hash = "sha256:35c96f6a68fcf90719a5b31d9f8042ecef6c00fb56836660d33455d0f5cfda65", size = 14909, upload-time = "2026-09-01T14:09:43.364Z" }, ] [[package]] name = "fonttools" -version = "4.63.0" +version = "4.64.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/84/69/c97f2c18e0db87d2c7b15da1974dace76ae938f1cfa22e2727a648b7ed43/fonttools-4.63.0.tar.gz", hash = "sha256:caeb583deeb5168e694b65cda8b4ee62abedfa66cf88488734466f2366b9c4e0", size = 3597189, upload-time = "2026-05-14T12:04:30.958Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d4/41/0f072a712dc74496e03710e462a18a4cfd8a258ad055a4e22d28b43a7abd/fonttools-4.64.0.tar.gz", hash = "sha256:ecb2e59a7bc692fee64dda6010deb66222335693b30046f15cccf81233aa715f", size = 3664266, upload-time = "2026-08-31T15:44:33.685Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/75/2b/a7f1545bdf5da69c4bda0cea2a5781f0ad2a6623e0277267672db43c5fe6/fonttools-4.63.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:2b8ae05d9eacf6081414d759c0a352769ac28ce31280d6bb8e77b03f9e3c449f", size = 2881793, upload-time = "2026-05-14T12:02:56.645Z" }, - { url = "https://files.pythonhosted.org/packages/49/50/965308c703f085f225db2886813b27e015b8b3438c350b22dd65b52c2a2c/fonttools-4.63.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:79cdc9f567aec74a72918fd060283911406750cbc9fd28c1316023deb6ce31a9", size = 2428130, upload-time = "2026-05-14T12:02:58.891Z" }, - { url = "https://files.pythonhosted.org/packages/d8/38/6937fbd7f2dc3a6b48725851bc2c15ec949b9af14d9bbcb5fe83cdf9bdf9/fonttools-4.63.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c14b4fd138c4bafcca294765c547914e1aa431ae1ca94ab99d8db08c958bd3b", size = 5111952, upload-time = "2026-05-14T12:03:01.263Z" }, - { url = "https://files.pythonhosted.org/packages/0b/43/a81f20050a3115b57d62c8e781446949512eac36690dc384ccea65ff4cc1/fonttools-4.63.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d76ac49f929aecaf82d83250b8347e099d7aecba0f4726c1d9b6df3b8bb5fe18", size = 5082308, upload-time = "2026-05-14T12:03:03.211Z" }, - { url = "https://files.pythonhosted.org/packages/67/00/cdd9d4944ca6ae280d01e69cc37bde3bf663630b837a6fc6d2cd65d80e0e/fonttools-4.63.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:dcf076a4474fe0d7367e5bbf5b052c7284fa1feca729c04176ce513521afd8a0", size = 5087932, upload-time = "2026-05-14T12:03:05.147Z" }, - { url = "https://files.pythonhosted.org/packages/f5/f1/0aa0dbea778c75adbef223c42019fd47d22262b905974d62d829545d485f/fonttools-4.63.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7dd683fef0663e9f0f45cf541d788d24caa3ec9db50796b588e1757d8b3bc007", size = 5213271, upload-time = "2026-05-14T12:03:07.238Z" }, - { url = "https://files.pythonhosted.org/packages/a8/99/253e4056e1f0e67b9390125a154b73b5eb73ad521bece95c004858fdeec2/fonttools-4.63.0-cp311-cp311-win32.whl", hash = "sha256:afefc1ed0a59785a7fb06ea7e1678e849c193e1e387db783579bc7b3056fcfcb", size = 2304473, upload-time = "2026-05-14T12:03:09.271Z" }, - { url = "https://files.pythonhosted.org/packages/08/60/defa5e69641db890a63be281f41345f4c33b157824eaf0b9fad3e08b0dcb/fonttools-4.63.0-cp311-cp311-win_amd64.whl", hash = "sha256:063e08bd17bd5a90127a14123de0d6a952dbc847695fd98b63c043d58057f90c", size = 2356389, upload-time = "2026-05-14T12:03:11.53Z" }, - { url = "https://files.pythonhosted.org/packages/08/ef/b3c6b9b5be2f82416d73fe2ed2e96e2793cd80e7510bd6a17ca79cdd88ec/fonttools-4.63.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:37dd23e621e3b0aef1baa70a303b80aaf38449632cfc8fd2a55fb285bbccfc02", size = 2881131, upload-time = "2026-05-14T12:03:13.386Z" }, - { url = "https://files.pythonhosted.org/packages/44/a0/c815bea63117fa63e4e1c01f8a1110d2112fa003f838e6467094ec2432ce/fonttools-4.63.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a9faff9e0c1f76f9fd55899d2ce785832efebab37eb8ae13995853aef178bef0", size = 2426704, upload-time = "2026-05-14T12:03:15.801Z" }, - { url = "https://files.pythonhosted.org/packages/44/04/0b91d8e916e92ad1fac9e4624760baf0fd5ff2ead614c2f68fb21373f03f/fonttools-4.63.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef3048ef05dbb552b89817713d9cac912e00d0fde4a3105c00d29e52e10c89af", size = 5044298, upload-time = "2026-05-14T12:03:18.085Z" }, - { url = "https://files.pythonhosted.org/packages/77/c7/2342da9830e3e9d4870305ca5d2091d2a83284f2953079b7bdd3b5e029d8/fonttools-4.63.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:58dc6bb86a78d782f00f9190ca02c119cf5bbe2807536e361e18d42019f877d8", size = 4999800, upload-time = "2026-05-14T12:03:20.161Z" }, - { url = "https://files.pythonhosted.org/packages/e6/6d/67fe16c48d7ce050979b33f47e0d28a318f02da030602e944c34f7a16ef3/fonttools-4.63.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ee08ebfa58f6e1aeff5697ab9582105bb620008c1caafb681e4c557e7483027b", size = 4982666, upload-time = "2026-05-14T12:03:22.87Z" }, - { url = "https://files.pythonhosted.org/packages/f2/00/3bbab338c07c71fa56269953845e92c951a61457bbbb0f1022551ea266d9/fonttools-4.63.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:27fdc65af8da6f88b9c6121c47a464cbe359fcfff7ff6fc2d37a1f395d755b78", size = 5133598, upload-time = "2026-05-14T12:03:25.168Z" }, - { url = "https://files.pythonhosted.org/packages/62/f2/aa27c7f98db5b064883dadcc5283947e81e034de42e22a33675878d98b54/fonttools-4.63.0-cp312-cp312-win32.whl", hash = "sha256:af2fd1664d00a397d75f806985ddb36282091c2131a73a6485c23b4a34722263", size = 2292575, upload-time = "2026-05-14T12:03:27.496Z" }, - { url = "https://files.pythonhosted.org/packages/87/36/cccb9bc2a6ab63d1b2980374f0dca72ce95ae267c9b4cfe77455bb70d0d4/fonttools-4.63.0-cp312-cp312-win_amd64.whl", hash = "sha256:59ac449f8cca9b4ffa08d2e7bbadad87ce710d69d1eda5c3c1ce579baa987272", size = 2343211, upload-time = "2026-05-14T12:03:30.057Z" }, - { url = "https://files.pythonhosted.org/packages/0f/8d/d8fec3dcde2963f8c908fb315e5ff2cd0ac34f82394bbbf73a2aa5145ce3/fonttools-4.63.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:cd7e9857e5e63738b9d9fd707bc1f59c8b09e5177726d23664db393c59bb08bd", size = 2876062, upload-time = "2026-05-14T12:03:32.554Z" }, - { url = "https://files.pythonhosted.org/packages/ef/71/d935dc54e4ff121bfdd11e08702db63a7e6f25af21d8a3d7b7212df53641/fonttools-4.63.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c2a2a42198b696a6f48fad91709afb55176e66a5e566131219dba372fb7f8c59", size = 2424594, upload-time = "2026-05-14T12:03:34.86Z" }, - { url = "https://files.pythonhosted.org/packages/8e/40/e76320afa1df918e146155ef239b1719ee266092e96f5423bfd075affba1/fonttools-4.63.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e874792a8212b44583ea02189d9e693906b2f78b261f372f95d6c563210ac1d", size = 5024840, upload-time = "2026-05-14T12:03:36.745Z" }, - { url = "https://files.pythonhosted.org/packages/ce/36/0b805d8c485f872f65a509cbe3b58a5d0d17bee855333b54a150c79d3061/fonttools-4.63.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:22135da48a348785c5e2d5d2d9d6bec5ed44adacbaeb9db12d9493bf6c6bfa68", size = 4975801, upload-time = "2026-05-14T12:03:38.833Z" }, - { url = "https://files.pythonhosted.org/packages/c8/26/2cee03d0aa083ab022da5c07aff9ed3f689da1defb81ad6917c9627896da/fonttools-4.63.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ccf41f2efdf56994d22d73bef4ced1052161958169428d06ba9724ea9e9a64be", size = 4965009, upload-time = "2026-05-14T12:03:41.494Z" }, - { url = "https://files.pythonhosted.org/packages/7e/48/cc4b66d9058c0d0982c833fad10127c4b0e9324606aafa41382295ca4102/fonttools-4.63.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9ced0bd02ac751dd6319b0da88aaef24414e3b0dbc32bb4f24944821a3741a27", size = 5105892, upload-time = "2026-05-14T12:03:43.525Z" }, - { url = "https://files.pythonhosted.org/packages/d8/1f/a98a30a814b9ddef3a2e706025f90b9e0bc94890e6cb15254bc86547d11a/fonttools-4.63.0-cp313-cp313-win32.whl", hash = "sha256:85be818f5506e8a7753153def2c9550178f0ecae6a47b5e0e8dbb23f7cc90380", size = 2291313, upload-time = "2026-05-14T12:03:45.594Z" }, - { url = "https://files.pythonhosted.org/packages/92/46/5177b01f3b4abfdd4409f31cca4ab279c9343a26efbe9ec78c97fc612e02/fonttools-4.63.0-cp313-cp313-win_amd64.whl", hash = "sha256:ba04cb5891d4c0c21b6da95eda8d7b090021508a294fff33464fc7d241e0856b", size = 2342299, upload-time = "2026-05-14T12:03:47.414Z" }, - { url = "https://files.pythonhosted.org/packages/27/d2/23d25e3f247b328be58d04a4c9f894178a0d1eda7d42867cfb388adaf416/fonttools-4.63.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fd1e3094f42d806d3d7c79162fc59e5910fcbe3a7360c385b8da969bc4493745", size = 2875338, upload-time = "2026-05-14T12:03:50.052Z" }, - { url = "https://files.pythonhosted.org/packages/cd/58/7dfa0c761cb3b2964e2a84c4dc986c926a87de0cb9fb60d5b28ded3f2914/fonttools-4.63.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6e528da43bc3791085f8cb6141b1d13e459226790240340fcbb4625649238b03", size = 2422661, upload-time = "2026-05-14T12:03:52.154Z" }, - { url = "https://files.pythonhosted.org/packages/dd/87/64cfa18a7a1621d17b7f4502b2b0ed8a135a90c3db51ea590ee99043e76b/fonttools-4.63.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b2248c5decb223562f7902ff6325077a073f608ee8e33e88ad88db734eb9f49", size = 5010526, upload-time = "2026-05-14T12:03:54.647Z" }, - { url = "https://files.pythonhosted.org/packages/36/e1/a8933a72c45a87177fbde2696e0d0755c8c9062f8c077a961c6215fa27b1/fonttools-4.63.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:308f957cdeaf8abe4e5f2f124902ef405448af92c90f80e302a3b771c2e6116b", size = 4923946, upload-time = "2026-05-14T12:03:56.984Z" }, - { url = "https://files.pythonhosted.org/packages/27/60/872e6e233b8c5e8b41413796ff18b7fe479661bd40147e071b450dfad7a1/fonttools-4.63.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bf00f21eb5fb721dbaf73d1e9da6d02a1af7768f2ebcf9798be98beab8ba90f6", size = 4962489, upload-time = "2026-05-14T12:03:59.443Z" }, - { url = "https://files.pythonhosted.org/packages/30/c4/83c24f2ec38b90cfda84bf4b1a1f49df80e84a1db4e7ac6e0d41bf23bc39/fonttools-4.63.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c1aaa4b9c75798400ac043ce04d74e7830376c85095a5a6ed7cba2f17a266bf4", size = 5071870, upload-time = "2026-05-14T12:04:02.122Z" }, - { url = "https://files.pythonhosted.org/packages/de/40/3ae22b60ff1d41ce0bd044b31238cdc72cef99f28b976f1e128ebd618c9b/fonttools-4.63.0-cp314-cp314-win32.whl", hash = "sha256:22693918177bd9ceabec4736d338045f357769416fc6b0b2508eefef75b08616", size = 2295026, upload-time = "2026-05-14T12:04:04.47Z" }, - { url = "https://files.pythonhosted.org/packages/c3/d4/98078064ccc76b45cb0f6c002452011e93c4bd26f6850344f0951cc1fe89/fonttools-4.63.0-cp314-cp314-win_amd64.whl", hash = "sha256:7d782fac32985914c351556f68ac0855391572bcd87de50e05970d3cd4c96fc5", size = 2347454, upload-time = "2026-05-14T12:04:06.752Z" }, - { url = "https://files.pythonhosted.org/packages/49/4e/652d1580c5f4e39f7d103b0c793e4773129ad633dce4addd0cf4dfebde02/fonttools-4.63.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:6db5140a60a5d731d21ec076745b40a310607731b0a565b50776393188649001", size = 2958152, upload-time = "2026-05-14T12:04:08.706Z" }, - { url = "https://files.pythonhosted.org/packages/0e/55/ad864c9a9b219f552eb46b32cd7906c466e5a578ba0c3abfcc0fe7413eb6/fonttools-4.63.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7d76edbff9014094dbf03bd2d074709dfa6ec7aba13d838c937a2b33d2d6a86e", size = 2460809, upload-time = "2026-05-14T12:04:10.783Z" }, - { url = "https://files.pythonhosted.org/packages/ea/2b/0aa8db70f18cf52e49b4ed5ecec68547f981160bf5ded3b5aed6faa0a6f9/fonttools-4.63.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0eac00b9118c3c2f87d272e45341871c5b3066baa3c86897fa634a7c3fb59096", size = 5148649, upload-time = "2026-05-14T12:04:12.747Z" }, - { url = "https://files.pythonhosted.org/packages/7f/63/18e4369c25043096f1048e0c9915951adc4f842bd81c6b18155824d6fa99/fonttools-4.63.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51394295f1a51de8b5f30bdb1e1b9a4231536c7064ef5c6e211eec19fa36036f", size = 4932147, upload-time = "2026-05-14T12:04:14.806Z" }, - { url = "https://files.pythonhosted.org/packages/a1/3f/67f3eac2ffd8a98446c5022f8ed3864eac878a5ff7af8df4c8286dba16cc/fonttools-4.63.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9e12f105d2b6342c559c298afb674006bb2893afc7102dcf8a1b55b0486b4e40", size = 5027237, upload-time = "2026-05-14T12:04:17.675Z" }, - { url = "https://files.pythonhosted.org/packages/1a/ba/4e6214cb38a7b04779e97bb7636de9a5c7f20af7018d03dee0b64c08510a/fonttools-4.63.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:796f27556dbe094c4824f75ca85267e4df776c79036c8441469a4df37038c196", size = 5053933, upload-time = "2026-05-14T12:04:20.818Z" }, - { url = "https://files.pythonhosted.org/packages/34/3b/214dcc19ee31d3d38fb5ad2755c11ef0514e5dc300bbaf41c0b69f393799/fonttools-4.63.0-cp314-cp314t-win32.whl", hash = "sha256:948428a275741f0b64b113c955425a953314f4b9ab9997f73a72c83e68e569c8", size = 2359326, upload-time = "2026-05-14T12:04:24.22Z" }, - { url = "https://files.pythonhosted.org/packages/dd/1e/3ff1a9b523058c2eeb6a9d50f5574e2a738200d0d94107d5bc4105e8da3f/fonttools-4.63.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6d4741eb179121cab9eea4cb2393d24492373a260d7945006358c08cfbf45419", size = 2425829, upload-time = "2026-05-14T12:04:26.829Z" }, - { url = "https://files.pythonhosted.org/packages/2c/47/c99d5268f354002ce80f8d029cd9d7d872969da1de8b93d32de4dc56d6f4/fonttools-4.63.0-py3-none-any.whl", hash = "sha256:445af2eab030a16b9171ea8bdda7ebf7d96bda2df88ee182a464252f6e05e20d", size = 1164562, upload-time = "2026-05-14T12:04:29.092Z" }, + { url = "https://files.pythonhosted.org/packages/07/f7/a222d1e20d460a09d08fe0b612a6f373235168c6c3228ff6a913cc8ff9ea/fonttools-4.64.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:dac25768be4c03a990c359f408cb7e8958ed0e93061e495b3642ce7909761205", size = 3087538, upload-time = "2026-08-31T15:42:26.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/fd/571331ac2b9ea43403ba43f27f5b45427a13b5c559d4379c99f3f9437b59/fonttools-4.64.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d652592c71683941b768306fa1c7c6ce1bb9b072505043feafe86305d71030b7", size = 2582847, upload-time = "2026-08-31T15:42:28.664Z" }, + { url = "https://files.pythonhosted.org/packages/dd/bc/583ad5e4d6fbc600b1d9eb3c2e8b4eac5ca1c204fa29e4ef0ff344d8aa60/fonttools-4.64.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:801fd04899d72eab34f02ab78d0451525621b3bd589da9d2d480dfffe951b643", size = 5493826, upload-time = "2026-08-31T15:42:30.991Z" }, + { url = "https://files.pythonhosted.org/packages/f8/3d/4edf079bdb01791abe87753b7c8fdca4e8ec709276e7b030a1aa84a88a51/fonttools-4.64.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ff7aff4637fbf71394df139c63ccfe08a47aa4252d2f91224ddb3335c716c925", size = 5455609, upload-time = "2026-08-31T15:42:33.149Z" }, + { url = "https://files.pythonhosted.org/packages/7e/b6/e36ea109c8cfdb0f174c09d8464b4ddf6b446f97313118d69b6a28cd7189/fonttools-4.64.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f521d79d6acda4923b264805541696f452079db0952a5bb96f9ff742f50629ec", size = 5460840, upload-time = "2026-08-31T15:42:35.423Z" }, + { url = "https://files.pythonhosted.org/packages/6c/bf/0d8bb1fbb96c621c1da5f93e288bec8424802df10265e790dddc66626b1f/fonttools-4.64.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a0afa8bac675445dc0e2ba2891ecbedd9be89cb437afa94c823e0290cc2c4bc5", size = 5591341, upload-time = "2026-08-31T15:42:37.874Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e1/6f8a1a01e5ed4408fdffb934c77ce65d978a2df6fd6eaaec5e6b864b10f5/fonttools-4.64.0-cp311-cp311-win32.whl", hash = "sha256:c3c1fb656063a2f762db5378ea8d38ad5f7836b4f3fb8c4652270ded43df2935", size = 2439647, upload-time = "2026-08-31T15:42:40.196Z" }, + { url = "https://files.pythonhosted.org/packages/21/41/b575f14a653911f33f17ef62bbeaa818c94bc7e694579cc9ddec6935d5b2/fonttools-4.64.0-cp311-cp311-win_amd64.whl", hash = "sha256:e63b63b8b5fdb8e29318dff2b15c5f852be46e972775b466f75b848f6eed4502", size = 2497436, upload-time = "2026-08-31T15:42:42.36Z" }, + { url = "https://files.pythonhosted.org/packages/82/23/4ea251977fef70ed14193785e1b2949355f1f5927dc0ef1dada675c0bbb2/fonttools-4.64.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9ecb2b206b5b2386f6968721a0770226b66bdd54adc4279bfff3ddf62873eed8", size = 3095501, upload-time = "2026-08-31T15:42:44.299Z" }, + { url = "https://files.pythonhosted.org/packages/c4/32/943e9034f49797e1a25dbbd60c8047ce0c38c3585c5d1b2db38ce64059c5/fonttools-4.64.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8c631303bb1fd7be3067c47536a30ff1fcb4846d6008c112bc52a03f7cd6965", size = 2583091, upload-time = "2026-08-31T15:42:46.57Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4b/332cb3105d5d550cb5728e669151601c4f7698cbedafd134154eb806ef83/fonttools-4.64.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b2763e452b025ee8e990f0462e76052de9bb094ebc21d296f62c6dfe958886b4", size = 5423449, upload-time = "2026-08-31T15:42:48.615Z" }, + { url = "https://files.pythonhosted.org/packages/dc/8f/e5f2906ea833d362916c64a2f6b1922a07b19442744fd3cd89563f429bff/fonttools-4.64.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:66a83f93579fb3493e458c4449d1d566a7b2a1c7b19915cd0fa3c9b8b5a8540b", size = 5400981, upload-time = "2026-08-31T15:42:50.862Z" }, + { url = "https://files.pythonhosted.org/packages/fe/46/1b325ebb20aef8bb05f803052ea65931d73638ae3e0d02a6658e3d14e0f8/fonttools-4.64.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:cf67f96dc0bfe9607f5f2b734cedfbe2f6f995231adee4ccefa12872044d452d", size = 5359588, upload-time = "2026-08-31T15:42:53.341Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d3/ac56fa880e01339aa6ad0bcb457cc30bb4d6c5ca79922e1bd650e4a3a396/fonttools-4.64.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6786bed88581e19bc4f28ea7a64ad531e8f54acf50327fddca942688824a60bd", size = 5520698, upload-time = "2026-08-31T15:42:55.919Z" }, + { url = "https://files.pythonhosted.org/packages/d7/40/1251fef04c308836a3a7db523703a7ca8ab010dc1e3b9a9bf08e0861c7a8/fonttools-4.64.0-cp312-cp312-win32.whl", hash = "sha256:da4c9bdeaf6b06c12d13d0addfc8ef15aa9695d26574a6dc10751258bef72f30", size = 2430689, upload-time = "2026-08-31T15:42:58.48Z" }, + { url = "https://files.pythonhosted.org/packages/6f/1c/ce0e89183c0235ff6cfbf0603d593023a5afa5333efb35ff569cc0be9ce2/fonttools-4.64.0-cp312-cp312-win_amd64.whl", hash = "sha256:06b6409b868494556a831ae33b2d9a090476c37516b38d70f45a9720b460d423", size = 2482071, upload-time = "2026-08-31T15:43:00.598Z" }, + { url = "https://files.pythonhosted.org/packages/44/7b/49b0054a79b9ed918c018e70e09b56eb5678ee8b44e59e126c79de4d8d73/fonttools-4.64.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9443eefff58aad558608f352092e1be6d278980e8c3b4e8621fcbfda97818500", size = 3092750, upload-time = "2026-08-31T15:43:02.705Z" }, + { url = "https://files.pythonhosted.org/packages/83/e2/08e73bd2f6e6248f071d3e9debe4c2b4cecda3a7dc057c56b44e255421c3/fonttools-4.64.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:09657817b75575822bcd6098ef0ebf0386f34430839ee53109e70fd40a7f6539", size = 2583001, upload-time = "2026-08-31T15:43:04.923Z" }, + { url = "https://files.pythonhosted.org/packages/2f/58/6dc0ff0963fc1e12f53bac59175617ec5927342ee7a4ffb871b44d56d81b/fonttools-4.64.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d7995b906666037d7114c20a5566a372902747452af7d5bd4cd6bca8f1a2550", size = 5392765, upload-time = "2026-08-31T15:43:07.096Z" }, + { url = "https://files.pythonhosted.org/packages/7a/dd/4d3911049680da7b3aecf97cb5094e3a05554f5d94829358fc7664640456/fonttools-4.64.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2c42237b7e8c6813643e57d3efed3be094d4c06339dc2166b626e2cc5c12ee93", size = 5373969, upload-time = "2026-08-31T15:43:09.617Z" }, + { url = "https://files.pythonhosted.org/packages/48/0a/a2cf94121fd3ca9166bdb4863f71d6db32a5606b223c81e2ba99832a0612/fonttools-4.64.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:498f02ea92c9ca18c0f9c581ea93184a9d56c25b0af14189b0767adaf34235d8", size = 5333854, upload-time = "2026-08-31T15:43:11.985Z" }, + { url = "https://files.pythonhosted.org/packages/44/e2/632f4a8b94e6d7eea41e6d82f0ed337f64f5fb96f9910e2ac6b1689910e2/fonttools-4.64.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8252f20108e557532f91d7d6dd9af87c16ed6fa930f65516aa480fa2cfed3363", size = 5492828, upload-time = "2026-08-31T15:43:14.288Z" }, + { url = "https://files.pythonhosted.org/packages/ff/25/7fee1978fdedc1a2d978b76dddbea88785416f5774e7222327b867712d71/fonttools-4.64.0-cp313-cp313-win32.whl", hash = "sha256:45e3ecc3888f1637094fd75cd8fc727f3a4b06d1ddf89181126c071e244fd2a5", size = 2429011, upload-time = "2026-08-31T15:43:16.463Z" }, + { url = "https://files.pythonhosted.org/packages/dd/ed/bb1c217c9e1fbfa59f42b266af57df937a703882a73e59a309773752228f/fonttools-4.64.0-cp313-cp313-win_amd64.whl", hash = "sha256:e4812f71c39d77ec5041348dafa400532adf7bf8f1fffa9aa6495fce5876d7b8", size = 2480199, upload-time = "2026-08-31T15:43:18.774Z" }, + { url = "https://files.pythonhosted.org/packages/a0/0a/59b9074b8ab165cc28d3e08bcf7a8eff8e1dc5932a5a41c87b15952fa3af/fonttools-4.64.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:6f1ce9ef9a1b13098efdc2e43a2ed96d9851bbde7b31c652a87552c4efe9b422", size = 3096790, upload-time = "2026-08-31T15:43:21.125Z" }, + { url = "https://files.pythonhosted.org/packages/17/39/af1077a36feefe79f36d67d75aee637a96673c0a74eeb2c6f24266f5a20b/fonttools-4.64.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:83cc48d1411d2ff388dab99973dca81172cc9ceae9c9799da9548d494cfb38cb", size = 2584288, upload-time = "2026-08-31T15:43:23.148Z" }, + { url = "https://files.pythonhosted.org/packages/00/c4/1ea58af0eb78264d28e5871bba0810bf0943cd93d641fdeae92553ad3410/fonttools-4.64.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e412767d1c9765cf1b82f7b00f1686c6ca5809ebb77af363b3f9f2325a465c01", size = 5378061, upload-time = "2026-08-31T15:43:25.193Z" }, + { url = "https://files.pythonhosted.org/packages/93/99/5c7e36d770407b66f7fc8378d2bb47f1530d083d8b9247ff011ec9b4dd70/fonttools-4.64.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b4a7af455ffed980925bc0ebf5b8d6239e6c3e797d9d755b6db192fb3080d614", size = 5319458, upload-time = "2026-08-31T15:43:27.696Z" }, + { url = "https://files.pythonhosted.org/packages/a8/09/89e8d600e92723309d4ddd3944c4ca565197a5df0236ae4115ccbe797375/fonttools-4.64.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:398b14f89ca950b288bd290875f07e4e10685644fa4ac668546fb107b1ada4d4", size = 5316656, upload-time = "2026-08-31T15:43:29.842Z" }, + { url = "https://files.pythonhosted.org/packages/0e/b3/56719ab37e1592ceac89724574146622bedec036a207be80f3c7b1c14cbe/fonttools-4.64.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:dc96150f99e05a317cb1f042b92c4cf8bc93cdb1f9f85717322e202ecdf2e505", size = 5448380, upload-time = "2026-08-31T15:43:32.311Z" }, + { url = "https://files.pythonhosted.org/packages/3b/b0/15951b7e3006073f3260da4f8983732d18903905cfe26daa5138a0bbf682/fonttools-4.64.0-cp314-cp314-win32.whl", hash = "sha256:1c3661324f3f0fa4539a32288a3e0711a5f3ccf020036e760bb558ae9811a16f", size = 2432778, upload-time = "2026-08-31T15:43:34.421Z" }, + { url = "https://files.pythonhosted.org/packages/8e/f3/0c97402b29411f4f6d31b32f2af5b4bcd5babe5a93c64558a8fd3654f140/fonttools-4.64.0-cp314-cp314-win_amd64.whl", hash = "sha256:043f6c572bf236f2a76e762c25f841daea11e8fc03e78088d7be66e0c5b4e4c0", size = 2485394, upload-time = "2026-08-31T15:43:36.506Z" }, + { url = "https://files.pythonhosted.org/packages/00/da/82192c7bee5314f04129a068dddae11082a63295d4e3b5ce08657de96608/fonttools-4.64.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:4691a122b8c1d0d82d6e7510ce59d5c42146518240274b53e912e255573924f7", size = 3170118, upload-time = "2026-08-31T15:43:38.579Z" }, + { url = "https://files.pythonhosted.org/packages/42/2d/0b4f608d754f625fecc7d94b3b26af6f65f6ab4527b386bb0c6141cdd9ea/fonttools-4.64.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:3200180abc69639483cf54a17cca2e13c31ede5f665979ea0a9c829d093f372f", size = 2617333, upload-time = "2026-08-31T15:43:40.934Z" }, + { url = "https://files.pythonhosted.org/packages/5e/df/9f7448c38dea05458acfee81c443b14ef97d6d66c636af053d41cf8a32dd/fonttools-4.64.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53eee22af5b5a305c1ee2652955ed46b148e881456fcec1e7f0eb27f642f6bb4", size = 5542345, upload-time = "2026-08-31T15:43:43.114Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e8/cbf4a81e8be322bc4bee4ba31cd33a7b6336ef8030fa13b53b3e9c09fbf9/fonttools-4.64.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:08f172961e11f4eb4f80f2f20049e09b0ea8e044fa6d456fed8346eb8588f360", size = 5349981, upload-time = "2026-08-31T15:43:45.363Z" }, + { url = "https://files.pythonhosted.org/packages/d8/dc/106fd9e93e962dbb60482d30c0913af3d108f5762429dfe93265a850bdc5/fonttools-4.64.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6eae4376adb104c2acfa76fd9ea0cb12b572ca1d70eceac709871f638ff76e93", size = 5409249, upload-time = "2026-08-31T15:43:47.817Z" }, + { url = "https://files.pythonhosted.org/packages/b0/2d/c7be990abf74c9c5c5367ae3dc65953ffce49fe00f62abd129cf725a2397/fonttools-4.64.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2730946ca8f12c356bd98eb9b2b095c8e761ed05bed5afb0d5b380cebe4f6370", size = 5443781, upload-time = "2026-08-31T15:43:49.909Z" }, + { url = "https://files.pythonhosted.org/packages/9b/cc/bffec3dccb9e3f4f6097a2baedaef5a8dec2cb816522ee163e4bb7f54b44/fonttools-4.64.0-cp314-cp314t-win32.whl", hash = "sha256:d30c966bea2deffa19c738c81776f7182da5ccabd97e666bae4f3d6ba87341d9", size = 2466542, upload-time = "2026-08-31T15:43:52.088Z" }, + { url = "https://files.pythonhosted.org/packages/96/49/e31f97dfc94e0648999f04bfccb37a97062d978f87df60654f496942c89f/fonttools-4.64.0-cp314-cp314t-win_amd64.whl", hash = "sha256:917fd520bb60809d83c14d43cfe48d5ad2516abaf2c073d65a431800dade2d29", size = 2516703, upload-time = "2026-08-31T15:43:53.954Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ec/e39b9e56db7dcc859983caa24cf2a36209dd71f6cc8ddbbf00d7342d7988/fonttools-4.64.0-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:8dd18fdff0ac9759b8d67a714730abee07b2312e3656c20ba5affb0107094762", size = 3091150, upload-time = "2026-08-31T15:43:56.309Z" }, + { url = "https://files.pythonhosted.org/packages/a8/e1/3e6a409d6f99efb66c2f6d0a71986432ff9cc9aca0df9ff4a8bed1d850f6/fonttools-4.64.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:5af87d1a6d247d7467ee082ae977a5443b2c45f8cd4d59375b6daa38d523c2de", size = 2582833, upload-time = "2026-08-31T15:43:58.348Z" }, + { url = "https://files.pythonhosted.org/packages/91/7e/e8aec0e6eaf93267450535c1be9c9ae63e1d9c49659c2ffdb52c19982114/fonttools-4.64.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:769fb64412ca237547ca73f111a64252d9e32c9d938bed51ed537bc9146a8f54", size = 5375327, upload-time = "2026-08-31T15:44:00.417Z" }, + { url = "https://files.pythonhosted.org/packages/61/d1/fa9ce5c1c3c0ef858a2cbe5632e9b6b137deca010b28d5d9a677e4aa89a7/fonttools-4.64.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e662f874ab2c7da9861584db44a13573e0936df087215f63013138f6e5eba083", size = 5338647, upload-time = "2026-08-31T15:44:02.62Z" }, + { url = "https://files.pythonhosted.org/packages/0d/13/e4d4fa3c166f7f5a8f9b6403ad9dcb3887cd05f8b9d2638bd2b43d2889ef/fonttools-4.64.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:2524a26f8fdb9051b0d778d052f5d238285ca9f91a7dc004514c7d6cf38d35f4", size = 5313557, upload-time = "2026-08-31T15:44:05.037Z" }, + { url = "https://files.pythonhosted.org/packages/91/c8/5a352d69608ea7606f677080ede971090fc7340b9f143c0b4fa00cfbf63e/fonttools-4.64.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:1e4e84b47839d35be24dbf476845a34f2ccf99707b66df125c1c414d3e86d25d", size = 5462363, upload-time = "2026-08-31T15:44:07.13Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f1/b15b845f66b559a24a9c40859391bbd3048af3ac0fd04e45b57dca11c4c7/fonttools-4.64.0-cp315-cp315-win32.whl", hash = "sha256:be084d19a3ac0c8b2aba696680642d703118d3b1f18cf83f5b7dbaf0ffc62ab6", size = 2431682, upload-time = "2026-08-31T15:44:09.293Z" }, + { url = "https://files.pythonhosted.org/packages/c6/b2/909beff0d2e2e1adac896e36cff7f9d76fd154bcd57e303d8f09aee8f72b/fonttools-4.64.0-cp315-cp315-win_amd64.whl", hash = "sha256:de8acaa5f4160f537a3cf41b031171d51004b9f4aebfa6c194f18dffa9533d03", size = 2484393, upload-time = "2026-08-31T15:44:11.117Z" }, + { url = "https://files.pythonhosted.org/packages/40/47/06a51becf651cc071daa88e18ecb9f45ace9e3a570d8191ed8b3cae353fa/fonttools-4.64.0-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:5b90ad6637237b636d15c9ae8b7c4a7a1c194f33def378677e468c13fd4542f8", size = 3162235, upload-time = "2026-08-31T15:44:13.182Z" }, + { url = "https://files.pythonhosted.org/packages/dd/96/8b3faf58fd7ec3bb11943126658ad15b3f385068b23248deffccf2327ac1/fonttools-4.64.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:fa75c7970bc6bca340cc6e20f20f069201bfcb50094c31a536fd99724d1d01ca", size = 2613507, upload-time = "2026-08-31T15:44:15.123Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3a/223d1437e72d79f1549be7bafb3cc08f397e1d9c354a241a8ec8573b7d33/fonttools-4.64.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:236e59bc7e2a63557a4d7b013f9cb9e28d9aebc45bc09f85e545e6bf091db626", size = 5518005, upload-time = "2026-08-31T15:44:17.359Z" }, + { url = "https://files.pythonhosted.org/packages/9d/9f/6f1a4b40ae533b9e907b7e627e397be632d0d9fd6f6be7756cf458870279/fonttools-4.64.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a515f664cad988f2295056833a59f62220bc3e46afdaffe389a29060f6712355", size = 5341654, upload-time = "2026-08-31T15:44:19.869Z" }, + { url = "https://files.pythonhosted.org/packages/52/f6/9ca30ba98730a22b527bcc1e02034f0b13b3be2ede764bb0fe8b8e0408d6/fonttools-4.64.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:5bfdaada437e7730c17d366bd7bb8c4a16639963ddbfc1b2f302a68a17a290e7", size = 5385994, upload-time = "2026-08-31T15:44:21.979Z" }, + { url = "https://files.pythonhosted.org/packages/09/27/baf1b61bff983bfec72cbb7b32162c4b68d76dc824aaaa48a1c26fa10b6b/fonttools-4.64.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:c60be0aed97a32c6ba8cee21f0d0477136e495451bd97910f589ac892db120d4", size = 5432407, upload-time = "2026-08-31T15:44:24.469Z" }, + { url = "https://files.pythonhosted.org/packages/21/ff/2eab37d43f2e2ccc0993959d8f2605d9a68470082c592684ac98611ee70a/fonttools-4.64.0-cp315-cp315t-win32.whl", hash = "sha256:f8669ce37851b597d3435b91fefa51139e58d506ca449ca0e5bb68c63b8b6d2b", size = 2463614, upload-time = "2026-08-31T15:44:27.198Z" }, + { url = "https://files.pythonhosted.org/packages/dc/8d/8bdff3ea656592197c8ef5062774221885468ffa01d8b0dc782dae23a83a/fonttools-4.64.0-cp315-cp315t-win_amd64.whl", hash = "sha256:89356c0793b474af7e49ec90d39fb2363e2341516a90460e38231df5ebe8acd5", size = 2512422, upload-time = "2026-08-31T15:44:29.478Z" }, + { url = "https://files.pythonhosted.org/packages/82/f8/7188153c4b265c899cd035de6a062677d51f67118a4ba640902bd9683e90/fonttools-4.64.0-py3-none-any.whl", hash = "sha256:4a05783ff54ce4c7a28f18e5772efdf63c219374bd9ffc55452182e1cef8be60", size = 1195327, upload-time = "2026-08-31T15:44:31.741Z" }, ] [[package]] @@ -339,95 +352,95 @@ wheels = [ [[package]] name = "griffelib" -version = "2.2.0" +version = "2.3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f0/b4/a767e91c606deefc447a96eaf59edd77397960b1d677dffd833ee8449831/griffelib-2.2.0.tar.gz", hash = "sha256:e1bc36fe9cd21d4b6b659b456346755e4cfdc5676c0a5214083126ee12612b3c", size = 227048, upload-time = "2026-08-16T14:04:58.383Z" } +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/f6/b6/f65ac785d4ac90dcf7c831ac6256f5dd4a19780f4e1575b2c0d6eeebe319/griffelib-2.2.0-py3-none-any.whl", hash = "sha256:d71c3bc2bbed9f958488634fe788b843a9f705d6d2838ca32cd6c25eeb64dfc4", size = 166779, upload-time = "2026-08-16T14:04:54.365Z" }, + { 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 = "hypothesis" -version = "6.165.10" +version = "6.167.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "sortedcontainers" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5c/e2/0fad246d2b6330e1f78479bfc566b5c22be82aee8a865cde9a08f648487d/hypothesis-6.165.10.tar.gz", hash = "sha256:68b45e09834cd80523cb1eb274463073c7a9af4e4ef7cff34d9615f355572d32", size = 503703, upload-time = "2026-08-16T22:56:15.404Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8cee74c1390b2932406faaab76980f18946f258fa5a8afca17189b3bc655/hypothesis-6.167.1.tar.gz", hash = "sha256:62eefcb4d2791423626e9901c3027a6e0c5ffda2ac0b44b3c7e797ab9d2d5a4c", size = 505849, upload-time = "2026-08-30T19:53:09.05Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/05/c1/9a9538e6d185baf5cc7f15bc3b76e08efbb3de4b3c782f234356449c0dd7/hypothesis-6.165.10-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:f839d29d0cc12048cf073d88ca4fdf94d420bc2b8afd69641ff6d496422ccd4f", size = 783243, upload-time = "2026-08-16T22:55:44.058Z" }, - { url = "https://files.pythonhosted.org/packages/a1/30/b70d9d79e871a75cbdeccd9067f20ecdb9eb2a1dfa03c630be3ad13b8b30/hypothesis-6.165.10-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:e10858f57ed0e74baa04393845f469fe8ad502c16ece4499bef7700c575611bd", size = 778815, upload-time = "2026-08-16T22:55:46.948Z" }, - { url = "https://files.pythonhosted.org/packages/db/52/6f0a9b7aab24b0635e2238f3fbddea5b54b17879ac813df42a3cc3384c5c/hypothesis-6.165.10-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76a7be86d986223b9f1bdb7e7cbcdb048649901fdb956c598ef73bdab1786cd5", size = 1108009, upload-time = "2026-08-16T22:54:53.082Z" }, - { url = "https://files.pythonhosted.org/packages/f6/06/8d0d4e11ff02350d09ec9f9e90af354158e59e16a8907ba5199a4ff2d7e8/hypothesis-6.165.10-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:717aea574e0e5edba2868aa66b1caae335d8f1ad3fb29f01dd6502953fa823a1", size = 1136596, upload-time = "2026-08-16T22:54:54.443Z" }, - { url = "https://files.pythonhosted.org/packages/59/dd/01a1e440f2e38dc1ccf5d597af5b8a0bee5f21b674c99c123b5554de9690/hypothesis-6.165.10-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4334058033e0214475f019e15492a50f3854fe8728cf51fe25c6191a2c3f8e52", size = 1135234, upload-time = "2026-08-16T22:55:08.911Z" }, - { url = "https://files.pythonhosted.org/packages/7d/18/8a26c24d3d9db20265f39df341ab265858c094e209571e3179cf237935f4/hypothesis-6.165.10-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2abb50cf1cf77d721de0a24c3f99d9c4ffdeb2cbd1e12aebb5a7a93e2b6b6d1f", size = 1157528, upload-time = "2026-08-16T22:56:02.159Z" }, - { url = "https://files.pythonhosted.org/packages/ea/8e/ce3c829b1937402d7944420ca26a05a0c8563e894dcff03d34ffa279d306/hypothesis-6.165.10-cp310-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:3de69aa8b924b400291a3cc42aaf78e6ab65c905a3e7e1a5dc39d95ef1b428cb", size = 1112870, upload-time = "2026-08-16T22:54:55.919Z" }, - { url = "https://files.pythonhosted.org/packages/f2/1b/4c4926d6c9a2b5d7cc090cc1e91219d6796102aa2a2c4b8f961c939e60b5/hypothesis-6.165.10-cp310-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5841331c504e02d7c334591681cb8587cdd59dee7e149db6d3db8e3f9e9f02eb", size = 1149683, upload-time = "2026-08-16T22:55:30.567Z" }, - { url = "https://files.pythonhosted.org/packages/cb/f9/df24eb28412f82465e2b7707f0ff1ec274d580bce389d4d9156617dc7bba/hypothesis-6.165.10-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2d0e0f8263d34dd8fa3b39eaa9a50bba56a8470b3dd9ebf6672d10840abe063e", size = 1283402, upload-time = "2026-08-16T22:54:18.054Z" }, - { url = "https://files.pythonhosted.org/packages/4d/07/c2b2a761300cf60b90ccebba4328175331e67d34f4fbd39429a7ddcdce49/hypothesis-6.165.10-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:0c4e6869817c3cfdf5a2b4d348497b95159bdecb3365be732c9b8570e36a4eef", size = 1409948, upload-time = "2026-08-16T22:54:22.343Z" }, - { url = "https://files.pythonhosted.org/packages/f4/ec/1c2bf1acdd0e273d81f833f85caf0ae5423db68a783554992fca36e6c541/hypothesis-6.165.10-cp310-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:9f07ae36c3b093e13687a894e79fe69e98a94c0b67fef656c575247682218143", size = 1265023, upload-time = "2026-08-16T22:54:41.402Z" }, - { url = "https://files.pythonhosted.org/packages/3d/a8/7f984908b7391160c7801b84e51ca8e4ba88c89e8d8811aa1aa7c03de73c/hypothesis-6.165.10-cp310-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:aff1f584c9538e8979cd180b1d70bf99bc16be19d4666414f49e5942b21a4f2c", size = 1282698, upload-time = "2026-08-16T22:56:06.998Z" }, - { url = "https://files.pythonhosted.org/packages/48/78/3a5d91c2d0250521736c42dfa2402b75049bc5fe2fb716c10bc84bb91ed1/hypothesis-6.165.10-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1f2c4db25fb8ec1a16a8dba580666337b8ffb1887c4cf1750cc954313897cef7", size = 1324816, upload-time = "2026-08-16T22:54:46.675Z" }, - { url = "https://files.pythonhosted.org/packages/6f/99/27450763853a034bca1574d3e0a315164b33ff49c3862df6872dda45e25e/hypothesis-6.165.10-cp310-abi3-win32.whl", hash = "sha256:b33dc30170a7402e03c180f2c5ef69dc077152f35b91621e9cebcde9c7d71746", size = 669039, upload-time = "2026-08-16T22:55:11.962Z" }, - { url = "https://files.pythonhosted.org/packages/2c/fc/ff2988b72b5705ad9ca500444bf3f43e3c2f41edfa034bbfeb23b215791a/hypothesis-6.165.10-cp310-abi3-win_amd64.whl", hash = "sha256:e9f924aa610c0618445e1e8738c822c3190ce2a2699a0cb48ec3a351a96761f2", size = 675213, upload-time = "2026-08-16T22:55:01.697Z" }, - { url = "https://files.pythonhosted.org/packages/c5/8b/821810d36f78d9d9421cd2c5d9d36983b45bb3575c3086276cc5c76f9f73/hypothesis-6.165.10-cp310-abi3-win_arm64.whl", hash = "sha256:1d305448e9bd8e2f4f3cea0eafd809efdaab4e998a0019bc615650c8463e42f1", size = 673537, upload-time = "2026-08-16T22:54:47.898Z" }, - { url = "https://files.pythonhosted.org/packages/ed/c2/b9546ace11f241c9c02d389f258cb80c14447a8c885771c9f1f0bc1d85ca/hypothesis-6.165.10-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:592107a0faf6c9c3a63a8dbf13dfb1cbda1cf599b0bc11c953221b00204b9ce1", size = 783716, upload-time = "2026-08-16T22:55:36.624Z" }, - { url = "https://files.pythonhosted.org/packages/37/10/27c2fdd574fd798caf5e91eb51f7834b098f5d840ce733efb3fba79ef86e/hypothesis-6.165.10-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f9180c362bde06fd05380298ded4e234fbc0d6ede0a864835bfd91c1e24283d5", size = 779507, upload-time = "2026-08-16T22:55:07.633Z" }, - { url = "https://files.pythonhosted.org/packages/5e/b6/70bc23695f3783c4b0486b6cad47b08a20f791db4a3c1b25250add9659fa/hypothesis-6.165.10-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d623801ae3dcd97b77b983400ef3d48bf976648e4efff19929175322eaae074d", size = 1108406, upload-time = "2026-08-16T22:55:39.653Z" }, - { url = "https://files.pythonhosted.org/packages/71/4c/32e200bd7a352af4b7f4e3729aaa4cd002cb5fe8c4c6aef5599d0019f152/hypothesis-6.165.10-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:20f6236cfb90b7817bb1a6a087589ca4aa46d73170f0dd62963952ed5dadc589", size = 1157850, upload-time = "2026-08-16T22:55:24.394Z" }, - { url = "https://files.pythonhosted.org/packages/03/a5/8efc2a9a484822efc0d0da466f50094e0f2c068187faaf33831fc905873e/hypothesis-6.165.10-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ad0764730e8e3421601c2cc7e1f054a9206c60ea0917165d8d9193dc453f34f1", size = 1283704, upload-time = "2026-08-16T22:54:27.279Z" }, - { url = "https://files.pythonhosted.org/packages/46/2a/90cc8d7463929c04786f29600de45f3227c12fa9bed1d5b7ce319b05e1c9/hypothesis-6.165.10-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:10d9a650a4666b0914831f769703d36140ed8039fd19bf9b71f615b8541eccf2", size = 1325077, upload-time = "2026-08-16T22:55:16.561Z" }, - { url = "https://files.pythonhosted.org/packages/82/ac/bc16faba4b42883e3d290bfaceff51e258b63fbbdf789bf9fe88df1ce537/hypothesis-6.165.10-cp311-cp311-win_amd64.whl", hash = "sha256:5671d2b2bf83bd4b6f02e55b32d432506eff5358c82f39b460a849ce19a2666e", size = 674920, upload-time = "2026-08-16T22:55:42.613Z" }, - { url = "https://files.pythonhosted.org/packages/e9/45/cde4f78afe2b9e29caecf38319eedc1deb76aebcacbdd128e03cbb2511c3/hypothesis-6.165.10-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:637445c1593a2a9d1024fda50082f07bb56baedda78d90a25f64b8111727ef94", size = 784835, upload-time = "2026-08-16T22:54:45.429Z" }, - { url = "https://files.pythonhosted.org/packages/7f/81/847f30b81cbfd07607296b3ce43067cf4f80799bd9244167f587de9c8081/hypothesis-6.165.10-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:713f4ce4e82c26b53031f139de959bc9e8b54d3995aa824b89bbdf8229df2a45", size = 776419, upload-time = "2026-08-16T22:55:33.633Z" }, - { url = "https://files.pythonhosted.org/packages/04/66/4c71c5be7a49d84b8c3a9278c1807c4c81181ab5474beb27df9d4c40dc0e/hypothesis-6.165.10-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f9ff356e97e3ab09db07c8b675efa67340103874a0bae7465acb83dad7a35f7f", size = 1106830, upload-time = "2026-08-16T22:55:10.389Z" }, - { url = "https://files.pythonhosted.org/packages/e3/c4/e2cbd2810e79f7a452a8ea9f6c6438ee718ce938d8cc12252cf0b36a81d3/hypothesis-6.165.10-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1a380bc99aa3b035e6a95a2201bf792d4082a04ca75babcc21849c2d0914bb28", size = 1156952, upload-time = "2026-08-16T22:55:53.35Z" }, - { url = "https://files.pythonhosted.org/packages/a8/8b/794ced36864825492ac3712d5acab5a257b4601e6a9dc2ccdd3937198f87/hypothesis-6.165.10-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e9acb2c4d9cb532c3fedea74159f7b923c8c036328c9239b4049e7aa073bdd81", size = 1280780, upload-time = "2026-08-16T22:54:34.983Z" }, - { url = "https://files.pythonhosted.org/packages/5d/2d/550525442cdbcc2daf1f9bdd8ba35bcbde63db7c7a22f2ef137fbb49df2f/hypothesis-6.165.10-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8660572b2d424bf5369ea8990985225f70bd1615b76ecd9c25588a3b9307009f", size = 1324130, upload-time = "2026-08-16T22:55:48.659Z" }, - { url = "https://files.pythonhosted.org/packages/74/59/6caf69dd5fe03499ada94c9cec016bffcc164511c6b93fe680f01209b9ff/hypothesis-6.165.10-cp312-cp312-win_amd64.whl", hash = "sha256:3376f2594763aef14faa519b0fb27cae7ce9eeaab4c69efa07777499110306c9", size = 672337, upload-time = "2026-08-16T22:54:49.11Z" }, - { url = "https://files.pythonhosted.org/packages/b1/fb/c82c5bd92864ffcf319772fedc8c9bf2dbe4ca14baa0fee6e49e67b5ba1c/hypothesis-6.165.10-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:9d77c3be7b429875036ad0f0597c6e5cc6bb17894a4da005e3807de64d2673ad", size = 784726, upload-time = "2026-08-16T22:54:32.371Z" }, - { url = "https://files.pythonhosted.org/packages/0e/b9/3d7acd08506da85557e65147b7f3fca8c47684e33be90bee0acb523920db/hypothesis-6.165.10-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:490c56b830772b0eca3b4b2cecb3741a1ed26b1d7206a279e1525dbf0aa95ee4", size = 776375, upload-time = "2026-08-16T22:55:13.303Z" }, - { url = "https://files.pythonhosted.org/packages/38/6b/922e8b3f9a706dd89d440b9545d2c6231c65e74da1c1fee3ff36c251b9c4/hypothesis-6.165.10-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ed68e27b8a61e57a3ccdc7c5a14499e00b54dfe223087204d5d40b3b5ef58b6d", size = 1106763, upload-time = "2026-08-16T22:55:06.129Z" }, - { url = "https://files.pythonhosted.org/packages/01/39/f5b9a5d390d4edd1ad472334493ac442963ebeb4daaa74ff4bdac6ef292f/hypothesis-6.165.10-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6caadcd1afb62630ff5c5ff353626eaa616553a5971295ad6dc2b19ca8a39620", size = 1156778, upload-time = "2026-08-16T22:54:33.824Z" }, - { url = "https://files.pythonhosted.org/packages/b5/5f/5fbe1be4326337fd6acefe2d18ed44007ee1dc1f98fe5b3c0eb22942364d/hypothesis-6.165.10-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9145fe43ebb22e66672967c3fab411793b226ed776e4fe282271bca6ad3c0bb", size = 1280756, upload-time = "2026-08-16T22:55:54.834Z" }, - { url = "https://files.pythonhosted.org/packages/25/c0/cf6f9e1ef632a1a75694eed0db3a02e6fc75c367a363e94acee52f043c64/hypothesis-6.165.10-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:79900a9920a0b1d3a626c03a90ac6bf7042e78d46906a565b86a0dbe926f1d96", size = 1323889, upload-time = "2026-08-16T22:55:56.567Z" }, - { url = "https://files.pythonhosted.org/packages/cc/cc/662b94880f260b0a88de1fdcf60fc9984f6e2a796da549542adc10a7bc83/hypothesis-6.165.10-cp313-cp313-win_amd64.whl", hash = "sha256:c01dd04044c472e47193b54f68e84e08d6ebf4f29551885aa959b015f7cd9747", size = 672346, upload-time = "2026-08-16T22:56:03.792Z" }, - { url = "https://files.pythonhosted.org/packages/3f/77/55e020c9c576532ff7d20bf8b1dfa052ecbd5ada1949b02f76c44c966f7e/hypothesis-6.165.10-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:9ccac776b2ca93b324806facd526ccb45da0fd035001c899a35b02c44431e209", size = 784833, upload-time = "2026-08-16T22:55:21.255Z" }, - { url = "https://files.pythonhosted.org/packages/4f/f2/01da2adf829cf549eaddcabb8e8072077fb3d26da4275f4c1e89b2c0af74/hypothesis-6.165.10-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e5f95f7b622e4171096d92175dda0a560f0955ade9b8a3a07bdcf151f7359611", size = 776545, upload-time = "2026-08-16T22:56:10.159Z" }, - { url = "https://files.pythonhosted.org/packages/cf/8e/58d4f842895220b793c53fc94a6489705b3665bb4d0ae4d338ce03fdf9fb/hypothesis-6.165.10-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f76d1562643693b8a40066f1f96af795b93fd9bcfc9690a1af2ff4c5867ee29e", size = 1107271, upload-time = "2026-08-16T22:54:50.266Z" }, - { url = "https://files.pythonhosted.org/packages/8f/b8/206468912d2153306bb8a41afdfc59e45b7a73a0495bbe4b9cb4f0e79c1d/hypothesis-6.165.10-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:60cab3ab4ea468d31a33739ffd7e94ec3e37dea891d65a6582ecc8a477175191", size = 1156915, upload-time = "2026-08-16T22:54:25.89Z" }, - { url = "https://files.pythonhosted.org/packages/fb/d3/bf5a22929b70a4cfd3edf69c5642b029b27ddb5cfda48fa295d384b01abb/hypothesis-6.165.10-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:22cf19388f0ff6ced8eb3e49c903d14938e4ed909d93bf28383eef451511e424", size = 1281205, upload-time = "2026-08-16T22:54:44.083Z" }, - { url = "https://files.pythonhosted.org/packages/07/a2/d7b2ba444d36fc84d4779f4431e74dd9b023dc63bcf282199f6e48ad39f4/hypothesis-6.165.10-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:057d0232f1224dcd0b7698902551a4341a7399f90670b036db6c4376715fe889", size = 1324243, upload-time = "2026-08-16T22:55:41.123Z" }, - { url = "https://files.pythonhosted.org/packages/d1/95/afe6b531fd01928c6f63d394ee413fa2338d088b2b44efcc23596b54477e/hypothesis-6.165.10-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:ab0f2e9d7d7d4db257f7cf53de3706c2baf124269571f20ffc2bcd6781f03063", size = 616382, upload-time = "2026-08-16T22:55:18.449Z" }, - { url = "https://files.pythonhosted.org/packages/48/86/9b4fb75f520a028edec50ffc904a94d724180395d71feb6d7a0ce7bb6f00/hypothesis-6.165.10-cp314-cp314-win_amd64.whl", hash = "sha256:d1ea02fa8ab3d33eb1125eade81f7136341eb429152c6dbe2ae6f8bc33b3fbdd", size = 672145, upload-time = "2026-08-16T22:54:24.831Z" }, - { url = "https://files.pythonhosted.org/packages/f9/ba/f7bbaae0c789bab7ddb764d2056ee1a463cc95a8acbccc90d4184e48b242/hypothesis-6.165.10-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:ed1a5891e59472884a03cb9875483e8fc131c80a275c60967f8afc5458a0c8ff", size = 783287, upload-time = "2026-08-16T22:54:23.751Z" }, - { url = "https://files.pythonhosted.org/packages/3a/83/01ef80772b4abd335c49405576dc503cede94fb5da30ba2643a119013aea/hypothesis-6.165.10-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:09772e328a26e50486ac572be34f9887f9aa185efe7ebb16bde4e8f6038db1f4", size = 774991, upload-time = "2026-08-16T22:55:25.987Z" }, - { url = "https://files.pythonhosted.org/packages/a3/0b/f47506241f9d5a5a2efe4c65b6bf4830e9d9576e5d3779007a260699e608/hypothesis-6.165.10-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5cf3b612542ba174c9da4000b59a4f4c81e8d66f87509be85d3a1b71b5c36413", size = 1105499, upload-time = "2026-08-16T22:54:51.864Z" }, - { url = "https://files.pythonhosted.org/packages/84/fe/abb3909b7089835112fbe75bf00d817d733b3a8032759783db0a24ff1e56/hypothesis-6.165.10-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f69ec5be85ef508e206153bed8eafd03f7995dc464356c8bbb279a1e2b7d56f3", size = 1155685, upload-time = "2026-08-16T22:54:30.94Z" }, - { url = "https://files.pythonhosted.org/packages/73/2f/1964738921640184067121ae77414522fc3f0463fc26c6e25a4f3b8e42ca/hypothesis-6.165.10-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:dd207497bb985918409a1bb5db85d1875f74e1269487332113b73d1ee7c77647", size = 1279177, upload-time = "2026-08-16T22:54:40.179Z" }, - { url = "https://files.pythonhosted.org/packages/34/c5/312af8ae038d3af9cf3f7f1021c1abfe31c0d9035e4cf63519e0a7dc983e/hypothesis-6.165.10-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:00de0abdcf8c05c9d0eab735a3c49a276376b55151e6fcb903c2b39a90e5e5c3", size = 1322921, upload-time = "2026-08-16T22:54:42.7Z" }, - { url = "https://files.pythonhosted.org/packages/9e/e7/b0a2fde7570c090a1b914026266a421c751ef10138fffe37fe0ef9e675c0/hypothesis-6.165.10-cp314-cp314t-win_amd64.whl", hash = "sha256:cc2da5aa4edf14743fa9257e5ba3513963999f01211635702479d8e92b8207c8", size = 672147, upload-time = "2026-08-16T22:55:27.527Z" }, - { url = "https://files.pythonhosted.org/packages/47/fd/985aa564d6ffd06483d45a62b40d319df0a703cd8bc1d041de17d102fbaa/hypothesis-6.165.10-cp315-abi3.abi3t-macosx_10_12_x86_64.whl", hash = "sha256:eeab73050ea58c13dd56e329f594c1dfe32ebd7bb169bbdf4f8ceefbc31ec6b5", size = 782882, upload-time = "2026-08-16T22:55:37.93Z" }, - { url = "https://files.pythonhosted.org/packages/f8/2c/6cc11151e450f72353a490940cd0db704680d07b78dc75dcc9f480e0d0e1/hypothesis-6.165.10-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:4c68e983d0007d014bb01ad4bcbba78bc432c73a1755ff36d5102ceefa18299a", size = 774584, upload-time = "2026-08-16T22:55:51.822Z" }, - { url = "https://files.pythonhosted.org/packages/10/39/ef26fa79c1738dfe9cdb1a3584fb6717d26429ca6c9d011cc4fdf08130c2/hypothesis-6.165.10-cp315-abi3.abi3t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7730d8197086f65d8969a991d6728a1d420a51b19fea06535c896cb43a1e05d0", size = 1104876, upload-time = "2026-08-16T22:54:58.937Z" }, - { url = "https://files.pythonhosted.org/packages/4e/f4/3fcc84e7637f42bf00d987093b9418083ac8db81b87392608a60f4b7c5fd/hypothesis-6.165.10-cp315-abi3.abi3t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7a7980a898a3e6ebe4de1896a0507e3d519edb53fb9b4bda478c9fbeb6514558", size = 1133353, upload-time = "2026-08-16T22:54:28.635Z" }, - { url = "https://files.pythonhosted.org/packages/35/59/21c5c14179c38f8d0de3560e7f1825c083311b3013b63f817d7dc78dfcbd/hypothesis-6.165.10-cp315-abi3.abi3t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b5820d009aedb7ae9cfd32f98b1ab0c0bbd6268379c4fab042218b6b655c63f8", size = 1132300, upload-time = "2026-08-16T22:56:08.539Z" }, - { url = "https://files.pythonhosted.org/packages/14/af/fbb56059961e416b2de7b9dc5352db2e8572bd5ea46892957e4c1e5548ab/hypothesis-6.165.10-cp315-abi3.abi3t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:37a7ac3d34220800e1107871cc391bca1b00439875925d7d821878b8b791f245", size = 1155175, upload-time = "2026-08-16T22:55:19.824Z" }, - { url = "https://files.pythonhosted.org/packages/0f/53/77fb0c2dad445858555429c4e06cf94a59ae8d2407dd6426b5af97c84828/hypothesis-6.165.10-cp315-abi3.abi3t-manylinux_2_31_riscv64.whl", hash = "sha256:dafa7c9dbe3d802f9bcdf261b29c8a70700fb22839947f06e471f62c46b6257f", size = 1109881, upload-time = "2026-08-16T22:55:32.029Z" }, - { url = "https://files.pythonhosted.org/packages/a8/7b/d187f673ff30e6ada640953636f978ffe64a6332f756b64163c2277f8d0c/hypothesis-6.165.10-cp315-abi3.abi3t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:90915635b9648071129b0f72c0673cf8eac9eb84cfd445c5bedef30c714b1ec2", size = 1144963, upload-time = "2026-08-16T22:56:13.428Z" }, - { url = "https://files.pythonhosted.org/packages/e0/60/31d504e364134d60af23e5f6365db0da3cf4a51b3ed3d4836e5a2cff12cf/hypothesis-6.165.10-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:e1bbeb7c506b07ee0422cf9b2f7212fefa4240957f03526d38d27bc6743a0a48", size = 1278684, upload-time = "2026-08-16T22:55:22.971Z" }, - { url = "https://files.pythonhosted.org/packages/ef/e6/89d26834a08c02f8da149e541dd40d7a96f68d9722f43146e69a77436ed7/hypothesis-6.165.10-cp315-abi3.abi3t-musllinux_1_2_armv7l.whl", hash = "sha256:2b36aaffc88625a44f91074c5bbedfdefb9b376c38d1b3c342edcd2e4c8ed16c", size = 1407202, upload-time = "2026-08-16T22:55:14.949Z" }, - { url = "https://files.pythonhosted.org/packages/dc/61/20d1e72246867ea195440092e8bb422c7ddc2f271b87b5b65679d5532719/hypothesis-6.165.10-cp315-abi3.abi3t-musllinux_1_2_ppc64le.whl", hash = "sha256:18a3ea838ddea183388f8788750afa8494d79abb5358823be9782585f34445d3", size = 1261395, upload-time = "2026-08-16T22:56:05.448Z" }, - { url = "https://files.pythonhosted.org/packages/2a/9b/ebab6c3c2b90a16abb4119198178652d12aff83cc8ec2cfde5276c69fb1e/hypothesis-6.165.10-cp315-abi3.abi3t-musllinux_1_2_riscv64.whl", hash = "sha256:2a2567b3a03a4a5a7c575c191cfcce321a967df3727803817e75bffbbeaecabe", size = 1279213, upload-time = "2026-08-16T22:55:35.066Z" }, - { url = "https://files.pythonhosted.org/packages/23/78/69b219b524231d36eb20c792e1f01e7cb037e02bd0af1c29f77ed9a969c0/hypothesis-6.165.10-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:8001925fa3dde51cb574e4c9de4c7efe77c4e4d64bd2fd2ef61d5651f9d04f3d", size = 1322367, upload-time = "2026-08-16T22:54:21.279Z" }, - { url = "https://files.pythonhosted.org/packages/55/63/ad5cc153dcc72ae5e7905fb9b3585f3e48ce892a2d6366f90163e867a69d/hypothesis-6.165.10-cp315-abi3.abi3t-win32.whl", hash = "sha256:c6559380469295c4009215fe1cab561301591a3bee2e2fb3f4f96d2273a3affc", size = 666038, upload-time = "2026-08-16T22:56:11.797Z" }, - { url = "https://files.pythonhosted.org/packages/80/32/b62307b73fbc99f0a4381d6f9456df76fbcbb7a27ef7256e26f0376f48ea/hypothesis-6.165.10-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:30797f20ca45e57f526d2df872f63ba453cb4e1091ad542184a7a951af8da79d", size = 671941, upload-time = "2026-08-16T22:55:00.235Z" }, - { url = "https://files.pythonhosted.org/packages/c2/dd/e0f98add0548ef73ea7afac45da1fb8efc854d7f9931db568754d0f963f3/hypothesis-6.165.10-cp315-abi3.abi3t-win_arm64.whl", hash = "sha256:c53e9b1c36350df9965ec44d6c0d4e0bbbb38f720dd2b0e1256dc6524d411015", size = 669931, upload-time = "2026-08-16T22:55:50.205Z" }, - { url = "https://files.pythonhosted.org/packages/0b/6a/880d6eeed5c451fb40a66733dadec4a5d498628a4a7f6a8a5f633f4c6dcb/hypothesis-6.165.10-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:34ee6402df6f31274d89119f1561b5f7489c97866afc5b7a3ed3a13d7e762802", size = 784644, upload-time = "2026-08-16T22:54:20.127Z" }, - { url = "https://files.pythonhosted.org/packages/27/e0/9e942bd3c3cf5ea0d5c0fd0905893bbfb6cefb7284c70fcc8033f8fdec38/hypothesis-6.165.10-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:277f41801e88dad2eba082f91a75632b7584ff64044ba2cf9dadf511b0d19cd0", size = 780515, upload-time = "2026-08-16T22:55:04.676Z" }, - { url = "https://files.pythonhosted.org/packages/19/32/f11a618415dc5fa9cdde41fea56c489f0814759527ae1ecd11a75a4558b9/hypothesis-6.165.10-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:72df95fb1db41755b155c5f02106e0036a339250555c8d351d488704fd112cf9", size = 1109374, upload-time = "2026-08-16T22:56:00.241Z" }, - { url = "https://files.pythonhosted.org/packages/5e/6f/db49b719842297c2b71e0d81e5b8967d31215fb7389421abcb465ce7ed3f/hypothesis-6.165.10-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e20a02775eb3cf0ffb4f0219b6d7c1f240336663d4e5d7028675ec247c790c4", size = 1159092, upload-time = "2026-08-16T22:55:58.57Z" }, - { url = "https://files.pythonhosted.org/packages/b2/2a/bf0bae84ba1cb3923d295973f1fe38ee867eaf90119e0d559116083be300/hypothesis-6.165.10-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:1ec53f08732e3cfd0342cbbd75dbd1b193c8f19390660466e536a748bb81f757", size = 676045, upload-time = "2026-08-16T22:55:45.514Z" }, + { url = "https://files.pythonhosted.org/packages/53/4d/3592ca336deafbd3e9b0f47dc4c727aa32d30e765ef6370da8ecd590d388/hypothesis-6.167.1-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:d28118fd70e4e15ff9c308a98b312b544b6145ae45aaa3b566328c1fdee8058f", size = 785476, upload-time = "2026-08-30T19:51:02.154Z" }, + { url = "https://files.pythonhosted.org/packages/18/bf/e33c431148994cbcb3332c6df94b833ecfb4aa6a8e51ea4b83da55ddd581/hypothesis-6.167.1-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:e517be7f82a0a917758cc489a88b826b5371f56381fd94b4a8a09ce82d8de406", size = 781033, upload-time = "2026-08-30T19:51:27.314Z" }, + { url = "https://files.pythonhosted.org/packages/94/a3/e0de9a82c7e790a1def0801076e0ef43110f98e95ed54a3554877d0cb66d/hypothesis-6.167.1-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26f8cec74c4fad7aeb0852cb34c2134b16db05d878ad3946a53337dace7016f4", size = 1117814, upload-time = "2026-08-30T19:53:04.109Z" }, + { url = "https://files.pythonhosted.org/packages/71/a4/8dd6bdc909324d1c39da1c86d65f75512ae049c159952af4cfe8feb5f8d4/hypothesis-6.167.1-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fd202d02d129197a5e771f8a11c7d30559927284c23ec3a8bd4f37a7955964d1", size = 1141639, upload-time = "2026-08-30T19:51:50.399Z" }, + { url = "https://files.pythonhosted.org/packages/fa/bd/c13ed6145c360d0770415efd7d5a7e63c29905aeef52ab88004fe7e7f924/hypothesis-6.167.1-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8b1e393ab01b71f683ba2a783785871cc6b81a6e41017780c64a5bc0b99759ae", size = 1143334, upload-time = "2026-08-30T19:50:38.045Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a8/060d79ed8504b54ced9ad16f33d674b1b98a9debe9733c02709d7dd5c71c/hypothesis-6.167.1-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c385c7741893404306f9e5559ab3835432e85e7c153e25f854c502c410bbcbb", size = 1163345, upload-time = "2026-08-30T19:53:06.583Z" }, + { url = "https://files.pythonhosted.org/packages/cb/f7/6e68e2b705f729a6f7b4f41030022b1a5264c5434d3bcd917233d6801c6a/hypothesis-6.167.1-cp310-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:94920ca1fae70c26b0bd3fabbeef9437ffc17a39fe85696fb9a86187d92f6dba", size = 1123029, upload-time = "2026-08-30T19:52:03.134Z" }, + { url = "https://files.pythonhosted.org/packages/a5/c0/d274fe37ed5ecd5ad8ed555edc1f5e2abc8e1c3be3d5404b7edd5cc353a8/hypothesis-6.167.1-cp310-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b8d90ded2ffdc7e56b5e571993f384b52fade0a7b424e614f999cc2491789970", size = 1154003, upload-time = "2026-08-30T19:50:55.053Z" }, + { url = "https://files.pythonhosted.org/packages/b4/2f/2b5bb386f43fc965eb86fd69fcb2bd62c08cb6d7c6708a40dc39b3b97440/hypothesis-6.167.1-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:40cd5de7dd252942a08480639f5850594b1aca4a463e8a7f15e1fb6c2c3760c1", size = 1293729, upload-time = "2026-08-30T19:52:59.481Z" }, + { url = "https://files.pythonhosted.org/packages/ac/32/22436b072d79011fe81abb933edcd2476057c7b971588c5f3caf07519a88/hypothesis-6.167.1-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:b9c33f921ddc7fea93660eca408b25fe755516e22ec7ab21cb9951031f1cd608", size = 1419248, upload-time = "2026-08-30T19:50:52.903Z" }, + { url = "https://files.pythonhosted.org/packages/9e/3e/abf39faaff0f78112112a82316a5c9fe472574480c1ecee526734775b812/hypothesis-6.167.1-cp310-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:495989cf0a5ee03f7f9598ee9efeaabf15fd861ec52b5a9d6435849453e17e5d", size = 1274903, upload-time = "2026-08-30T19:52:27.25Z" }, + { url = "https://files.pythonhosted.org/packages/29/83/69b89ed5692ba3aad117facfb9ce099633a22c35acc3d64829b72253ec8c/hypothesis-6.167.1-cp310-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:bc73c46ce8ff93b0eb220f2b75adcbd9fcc9112078a74d3522d2532ad8069bad", size = 1294185, upload-time = "2026-08-30T19:50:35.038Z" }, + { url = "https://files.pythonhosted.org/packages/2b/1e/55dfcbe45c72df0a5c5b86a6b7c9365121acab69c2cc060bd55336a48c8f/hypothesis-6.167.1-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:36e83e1d7e97aaacbf6cd778e14a841344f848a674b20dfe4fe997546a6a2151", size = 1330013, upload-time = "2026-08-30T19:52:54.841Z" }, + { url = "https://files.pythonhosted.org/packages/3b/a6/0a36cead4ccff58bedb5d1aa2f894f7a580c317424b4fa788c6b22232b2d/hypothesis-6.167.1-cp310-abi3-win32.whl", hash = "sha256:fb4d87454d2459c2ccb541a4c61c92ce13058b91305ed3304695a409a1d886e4", size = 671942, upload-time = "2026-08-30T19:51:32.732Z" }, + { url = "https://files.pythonhosted.org/packages/ec/5b/360285ed42109f5ef48d98ca9ffcf71d1477130c0ff1f1a8d535c8507259/hypothesis-6.167.1-cp310-abi3-win_amd64.whl", hash = "sha256:5e35f98b427bf438a946203426b485dd5b62485f3d5a69a0e0862870a545e518", size = 678637, upload-time = "2026-08-30T19:50:33.573Z" }, + { url = "https://files.pythonhosted.org/packages/79/2d/cc084c1a8bfa296048ec0461f0fa11731abe0097f5c2310c8d39d94c8dc4/hypothesis-6.167.1-cp310-abi3-win_arm64.whl", hash = "sha256:dd6a0808a2eb8b5b1ac06bca4244eee18ed2c0e7b105599e1662203d164317b5", size = 676657, upload-time = "2026-08-30T19:51:34.494Z" }, + { url = "https://files.pythonhosted.org/packages/ff/14/2445b7b1a0c8db61c6812b74606ed7a4f41e3ea0e0c103313e25995b82d5/hypothesis-6.167.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:613bf10e6e490daaa88eb4bf06fb3aacf6572b887e2f9fa5d0bac1be96a18c00", size = 785945, upload-time = "2026-08-30T19:51:19.221Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f6/67926d308a9ab19bb7dfb5118832fa74b508f94871fadbb3370629decbfc/hypothesis-6.167.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f715d912560dd4df3daab4c1fd98c01132eea7ab292f5b9e1d28fc419fe63348", size = 781726, upload-time = "2026-08-30T19:52:57.179Z" }, + { url = "https://files.pythonhosted.org/packages/33/de/22ac0e272530b36ad840170bf661ff89df8648052bb6a37dba520b312f1f/hypothesis-6.167.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8cdd2fc0232b47910e9621f8c1b5732381438055b03fcc69180e1ef3659b7e70", size = 1117929, upload-time = "2026-08-30T19:51:58.828Z" }, + { url = "https://files.pythonhosted.org/packages/4d/1f/b72b51bac9a7d330bcdda01dc0ab1abe76e1c29ff522effd2d1be4e23702/hypothesis-6.167.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:163e4cebd4b2380ff92f5d36bd04697e82b13973794440694da768521e1e2eab", size = 1163855, upload-time = "2026-08-30T19:52:33.689Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b4/02424328951f0244f7dd3f620775ed22d39dc234e92759f9d2980150252b/hypothesis-6.167.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2de4e67289f86e358732eb16b345f9d1f987c33e44b10d4ffa3ccd715dea9316", size = 1294177, upload-time = "2026-08-30T19:50:44.418Z" }, + { url = "https://files.pythonhosted.org/packages/d8/09/2262d6b362c81066ad451fda48633b2d3ea6cf2d0d673ee6554ad8f86c58/hypothesis-6.167.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:fcfd20792f62d65729f850ea50328c1f8b09874d5960b122715b9e6784a7a547", size = 1330267, upload-time = "2026-08-30T19:51:23.5Z" }, + { url = "https://files.pythonhosted.org/packages/70/5d/19e8c02eebfe89592dfd12372032e8868ad40e6d40b38e5ecc4935ce194f/hypothesis-6.167.1-cp311-cp311-win_amd64.whl", hash = "sha256:ebb841d21156039d7da0a41fa9de4ccf468510a4e4d8144f4fe2b3f31239ef3b", size = 678401, upload-time = "2026-08-30T19:51:44.77Z" }, + { url = "https://files.pythonhosted.org/packages/72/82/07987292cfb59c73ce6574e2912c015f678d5aa4d8c0712b78ae4415a535/hypothesis-6.167.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:1937ae4e23f7dde6d4202d3d08c2633bcd535a091bdf866b8799abaabcb1e6f0", size = 787050, upload-time = "2026-08-30T19:51:10.782Z" }, + { url = "https://files.pythonhosted.org/packages/c7/e9/0d37051bec44ec433d87da03c9a7fe389b1b58210790c1f166af249bf941/hypothesis-6.167.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1434bbd25d05aaf75c4e6829b4cad8e9931b690f840d92b747b2e6e5af575922", size = 778617, upload-time = "2026-08-30T19:50:31.939Z" }, + { url = "https://files.pythonhosted.org/packages/13/2a/60c18a493215c22c9cfcb4574b381497bd1971eed9fb5f9831b26e73cffb/hypothesis-6.167.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:af3c09428e553b1dd2f9abbc4738377c58bf6d74cb0b8b528cc1dde3a9cdfbe8", size = 1116743, upload-time = "2026-08-30T19:50:49.293Z" }, + { url = "https://files.pythonhosted.org/packages/12/1f/b6796f11d6502e0b1764aec99f2792ca60382b6a45e26bbe163dc5757980/hypothesis-6.167.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:04f807b85d425a7005e8a24498ca832bf5590f0d306737471d94c842569cecef", size = 1162718, upload-time = "2026-08-30T19:52:47.544Z" }, + { url = "https://files.pythonhosted.org/packages/a8/6c/e3cf35474b799e299fa08980b6756f500d730781686916ab65f87cbc0613/hypothesis-6.167.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:803c6a98ff66cee4caf03245bfd00e442a907264031b994a3a650dc6e4786f51", size = 1292417, upload-time = "2026-08-30T19:50:56.822Z" }, + { url = "https://files.pythonhosted.org/packages/9a/4c/875803c80c373a1628f8eb62f215ad23ce7b50ed61f884d6be0838ebea4a/hypothesis-6.167.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:21a7122ddf072906083e3704fe3961ecbc49d7d30a9b51bcd525d977b3afe65e", size = 1329035, upload-time = "2026-08-30T19:51:46.659Z" }, + { url = "https://files.pythonhosted.org/packages/90/a8/a8daed3796623884471dc0ee8ed63917b1e2b979b4074bcea19a964fcd71/hypothesis-6.167.1-cp312-cp312-win_amd64.whl", hash = "sha256:a2837c60d782eb0b8a910c541264675b9d11486e186af8c82a5e2920b5fe4fe8", size = 675966, upload-time = "2026-08-30T19:51:56.58Z" }, + { url = "https://files.pythonhosted.org/packages/4b/44/dca7b211804f60c789aced2792b1e7803ccd8b70b79041cbb92788df5d19/hypothesis-6.167.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:6478d19a7887731cc2afaa1ec15f62811c9ceb6fd18e5b7563e0a18399a9528f", size = 786947, upload-time = "2026-08-30T19:51:29.165Z" }, + { url = "https://files.pythonhosted.org/packages/b9/6a/3cffa138492c9e3d5f98f4ff8b467273dc87af6ca3c18084272d106bde10/hypothesis-6.167.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8f13167a4b81c93e7e051d1f02790814a6495fb79cacf3fb89560a796a2f7d00", size = 778584, upload-time = "2026-08-30T19:52:25.091Z" }, + { url = "https://files.pythonhosted.org/packages/7a/7d/e8039791aaca3b21557bc520a71cdb88751892f66fd1a0a459b59872e463/hypothesis-6.167.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5ef7dd225f7df7d74d1c5a905592cd8b4cd348e6be639b189a43def8b0b5dd79", size = 1116749, upload-time = "2026-08-30T19:52:38.178Z" }, + { url = "https://files.pythonhosted.org/packages/ee/b8/f9b8d93bd6178870f0daa868ca99915f6d9df1f99dc7291e9ce2743a6dc5/hypothesis-6.167.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7d7585429f2263d3ceeb3474bae3871024630a7a598e71eeb4b0dcf03e291623", size = 1162599, upload-time = "2026-08-30T19:52:11.72Z" }, + { url = "https://files.pythonhosted.org/packages/a1/0d/53d419094e6f8a7e7377c09de15ac23f842ab698ff07241f7b73e19bd559/hypothesis-6.167.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:fb9194f450417cf35f66b6c72737cc6b8f21f20567ba4d39822c64f0d1075784", size = 1292230, upload-time = "2026-08-30T19:52:49.732Z" }, + { url = "https://files.pythonhosted.org/packages/5c/df/cf4c482323ae4f06b5326b5bdd89cf17d8232fdb3186c9913e0b19a5fa58/hypothesis-6.167.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c63a0d292a5dde3c0fe999892e76d8375a003ca40c0c00d763f3360f91be5b96", size = 1328899, upload-time = "2026-08-30T19:51:05.366Z" }, + { url = "https://files.pythonhosted.org/packages/49/05/780c4b0396491d294fda69a541cb1dedb37fb9eb2e3a696e85fe19064c40/hypothesis-6.167.1-cp313-cp313-win_amd64.whl", hash = "sha256:ff07f98a0b230632bb2836b5dad3e94d85c114ae155a316afd251c58760958ae", size = 675927, upload-time = "2026-08-30T19:50:58.472Z" }, + { url = "https://files.pythonhosted.org/packages/6a/f1/1e602f090dcb7e38655f1f7909482742891332275fc01f241e255cdfa514/hypothesis-6.167.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:fcfc2a78fc1025644f889a74684b3201f4652ce8e6694c2a01af0f100d0348cf", size = 787054, upload-time = "2026-08-30T19:50:40.88Z" }, + { url = "https://files.pythonhosted.org/packages/52/57/cfa930719c7af5a33627abba826a3fa2efa61a5f23e38d4111eace5dfe53/hypothesis-6.167.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:769bdd9aa0af08c063327730ab6dc18b7a23837a2912f2aeaab3912f11a7e3ad", size = 778721, upload-time = "2026-08-30T19:50:36.503Z" }, + { url = "https://files.pythonhosted.org/packages/01/37/4c4bc3d319eac85bfe17515c9786bf49e57181ca8757886110d2cfb13d10/hypothesis-6.167.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d75d44bdead6679b6ee9a7c90d10207db865ca0c77c5212103b5ff421379f99e", size = 1116972, upload-time = "2026-08-30T19:51:52.472Z" }, + { url = "https://files.pythonhosted.org/packages/33/45/0d61ceef2739e7b96ea1faa0f3d5aa5917c8156797993bf3acbadfcd7f0a/hypothesis-6.167.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:742be00d7bb53d10634e6435e5b98f51fcdbe7ed377d473ab7387d9499c87169", size = 1162776, upload-time = "2026-08-30T19:51:09.084Z" }, + { url = "https://files.pythonhosted.org/packages/4a/cf/756666ce2262e90fd61fec41a95548cceab94b0669381d8f0387cd89af93/hypothesis-6.167.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b6fcdc8d03b37a902262be13112d113bb4ac87edf3b08afb47f3d1210deb038a", size = 1292748, upload-time = "2026-08-30T19:51:17.22Z" }, + { url = "https://files.pythonhosted.org/packages/e8/b4/87eb3c695d6c37fb44f4d49f9faa2033af496e24965658942a1706e22620/hypothesis-6.167.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e56e7841514276c308c2bb4d033cf01860d0fc8c76e2b79ce748a9f123eaf83b", size = 1329101, upload-time = "2026-08-30T19:51:36.313Z" }, + { url = "https://files.pythonhosted.org/packages/a7/3b/87faa4a86533eaaa19037741fb9cdde8647f7ffdf8fd4279828ac9d81f8b/hypothesis-6.167.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:bbf4f0cad201d0b8e821e82ad828b2aec99ce6d9967779eecb2cad4d4a93debd", size = 618079, upload-time = "2026-08-30T19:52:43.226Z" }, + { url = "https://files.pythonhosted.org/packages/e0/46/96b7ac9605887447d267b4b3a9ecf61c6caaabf39eef667173b0cc9222b3/hypothesis-6.167.1-cp314-cp314-win_amd64.whl", hash = "sha256:3e04f6001299708b6fd4512267b189c0b029ef1e34500deb4e4c9639023598d7", size = 675812, upload-time = "2026-08-30T19:52:52.298Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f6/0337a91c50ce4be323c3d6aa852fcf08199ffbb1072da09fbe6d602f4dfe/hypothesis-6.167.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:47c99256df28555ecc2aed0e22ca17cd61c63c8c44207a07b4e402cc49661fae", size = 785525, upload-time = "2026-08-30T19:51:03.637Z" }, + { url = "https://files.pythonhosted.org/packages/5a/08/9bb52de855169d31888c7033ee2f94b94138fde021c1af9dbc7ba5e83cd5/hypothesis-6.167.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5d6614e88fd267bbd870e3ec02f8a5387897d2d573626a02f5ac06d81533afa6", size = 777142, upload-time = "2026-08-30T19:52:18.472Z" }, + { url = "https://files.pythonhosted.org/packages/85/79/f1a7e088e13a641357abb9b43d75c116c2a0902711b1a25a203864b96c9b/hypothesis-6.167.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:27829aa89fe2e47c5c8d13b3ce31e0f53a01a98f76a4f99cfa3369ab35362f33", size = 1115311, upload-time = "2026-08-30T19:52:40.975Z" }, + { url = "https://files.pythonhosted.org/packages/e0/38/e28b1fc20bd3d67d43cf1aab7a15daa2a24ec01d17a153e82fcf38c882f3/hypothesis-6.167.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e936777d92ae27393b4a941839bbb43c1f339b5a0e394c7f3730454cdf091b3a", size = 1161238, upload-time = "2026-08-30T19:52:20.501Z" }, + { url = "https://files.pythonhosted.org/packages/5a/de/9b4fc7992166299e0fc5c13c8766919ae57d0fb9eed5319b7a3bad4f2f17/hypothesis-6.167.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:971ce0d8a367a37c4690b83a2e7f6ef832fa3543357eb6da0da27ba078e5088a", size = 1290974, upload-time = "2026-08-30T19:51:15.673Z" }, + { url = "https://files.pythonhosted.org/packages/cc/79/ca086eea02588212ab796ee4bd7fe6ed514e10d1a99967e478691608e8d9/hypothesis-6.167.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:af84ce2416be2a65bc0ea18e64d2dbb9796b7692593b5b2064d60ea1d52ec1e2", size = 1327969, upload-time = "2026-08-30T19:52:35.846Z" }, + { url = "https://files.pythonhosted.org/packages/26/79/1875380fa30e8411553e76b3e9695aca0845f9f265523f20f879bdea2b82/hypothesis-6.167.1-cp314-cp314t-win_amd64.whl", hash = "sha256:3b596efec5bd714588e3bb269544d993c5258c979f3a26f51fadf62c215d0e68", size = 675735, upload-time = "2026-08-30T19:52:22.821Z" }, + { url = "https://files.pythonhosted.org/packages/b0/45/59abecd75e52b9dfb5b3eb991276f54954c44917a1c83d148cfb3580bd39/hypothesis-6.167.1-cp315-abi3.abi3t-macosx_10_12_x86_64.whl", hash = "sha256:c25c556d51d55d94988dc0a2c716d471ff19cf9632cd33f5e6db2914d802428a", size = 785097, upload-time = "2026-08-30T19:51:12.528Z" }, + { url = "https://files.pythonhosted.org/packages/01/7c/e6d978dc9564ba70352da60c00f55f6ad7d66d99ecbf336a228978206cb4/hypothesis-6.167.1-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:b57e950f9d5c93ca335bc612e8fa8fb49abb187c3fc9d5e7d9966d52eb27d747", size = 776798, upload-time = "2026-08-30T19:50:47.247Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a4/f8ecedcf96790aab69d750afe3fcbf503229d0bb4e0c32be655385a4fc8c/hypothesis-6.167.1-cp315-abi3.abi3t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0807ae8d399162827fc1c396ab4c697a41921c2a2baacfada439771e9dc2b867", size = 1115116, upload-time = "2026-08-30T19:52:16.029Z" }, + { url = "https://files.pythonhosted.org/packages/7e/97/8bca7c262ac4fcb1ee684c04e4ba75f26541d3a30417e3743d19912d257e/hypothesis-6.167.1-cp315-abi3.abi3t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:546fef39c7aadba74bf3e592585694a71340d1775e9b3274bb3f94106dbde4b7", size = 1137812, upload-time = "2026-08-30T19:51:25.507Z" }, + { url = "https://files.pythonhosted.org/packages/d6/07/9cb4a7fc2aa2b2ad063b446c378f0d7acfa5303e84afd1b1374ba23fd6f3/hypothesis-6.167.1-cp315-abi3.abi3t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a17a5618b6a5b84f17c8acb3bce37122647cf7a3e48b660a39d68a773bd627dd", size = 1140384, upload-time = "2026-08-30T19:52:05.264Z" }, + { url = "https://files.pythonhosted.org/packages/1c/18/813bf7efa18f11ce938a0da22a7518a54db46d4a181ebf4cb0a8061c263f/hypothesis-6.167.1-cp315-abi3.abi3t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0ff5ad833480c1e34ae902cb52fc00802b08ac6c87bd22d7e5f04fd925869608", size = 1160569, upload-time = "2026-08-30T19:51:40.099Z" }, + { url = "https://files.pythonhosted.org/packages/52/1d/6658d9294ed33bb17da4acf06fe63b010b205ea1861f6921c38060793255/hypothesis-6.167.1-cp315-abi3.abi3t-manylinux_2_31_riscv64.whl", hash = "sha256:630eb37df80b5bc4ec6f391b13caaecc06942ff5da3aadebacf85f53bbc55757", size = 1120605, upload-time = "2026-08-30T19:53:01.848Z" }, + { url = "https://files.pythonhosted.org/packages/a6/81/a75821c0e879223a2635b8eded84ae874cb6c711b23e9930668008d0b13f/hypothesis-6.167.1-cp315-abi3.abi3t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bc4e65f7c43b187f7a40964706b5ded1073e0c1839e9fb5e041d7ed973bb65fe", size = 1149479, upload-time = "2026-08-30T19:51:30.972Z" }, + { url = "https://files.pythonhosted.org/packages/4e/f2/c8c3faf4ec796d6dbf36b84662806696434aef38616e5a65b46188c04262/hypothesis-6.167.1-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:e849f518cbc4e76ab15f2f1473c60dd3103da8d32399187325ceb84309105976", size = 1290423, upload-time = "2026-08-30T19:50:51.114Z" }, + { url = "https://files.pythonhosted.org/packages/a4/e3/e0903abe7e8634cedb5414931452e82daacdd3b8b46d6348bbefcaa45f2a/hypothesis-6.167.1-cp315-abi3.abi3t-musllinux_1_2_armv7l.whl", hash = "sha256:5c5a26d4d3dca0c84e01bde41df4cabaa5a373c7393f9eef372d19fe93b07ccd", size = 1415749, upload-time = "2026-08-30T19:51:48.456Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/03f09c1ecac1dfb0f4cd7fcc6dc50d9c6ea8067b295a728e242650bafe32/hypothesis-6.167.1-cp315-abi3.abi3t-musllinux_1_2_ppc64le.whl", hash = "sha256:4819adbc5911648f6bfaeb574f276add184b4b49f54731dbde46fd71256bb157", size = 1272086, upload-time = "2026-08-30T19:52:29.368Z" }, + { url = "https://files.pythonhosted.org/packages/7c/7c/7a63bfc0bfbaf000f71352c4faac72ff611376330a2ce2e9a1bf4668848a/hypothesis-6.167.1-cp315-abi3.abi3t-musllinux_1_2_riscv64.whl", hash = "sha256:3ad7206de9c398c8da5745b69b5ba2ef45100082eeb174656490bc4f262b112c", size = 1291553, upload-time = "2026-08-30T19:52:07.545Z" }, + { url = "https://files.pythonhosted.org/packages/e8/5c/8065bdab53bc81743ca68fc76ca53fc7531a5b3f01c0de4ba40467955d6a/hypothesis-6.167.1-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:96d5e8017a9508f06c8a61a6130cb0d0b4810847ed5c76923cb5cfb9952b31af", size = 1327734, upload-time = "2026-08-30T19:51:42.306Z" }, + { url = "https://files.pythonhosted.org/packages/0d/a0/5c15d480aea3a8e6e5c17c7cb1170171707ac643ffd319473bc194743ad8/hypothesis-6.167.1-cp315-abi3.abi3t-win32.whl", hash = "sha256:a4e4de36a397cba49d949d89cbc26135977c15f9d797caa95317962ceb5b5674", size = 669115, upload-time = "2026-08-30T19:51:14.192Z" }, + { url = "https://files.pythonhosted.org/packages/1b/36/4cf494bc96384189fedb7d3f272580315f2284a9f8a7f6a59796612eb76d/hypothesis-6.167.1-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:f6fe9c40ab14def363d9e7ab22863fa31652bd5e08f8495b34ff7bd0062b3f8d", size = 675438, upload-time = "2026-08-30T19:51:06.881Z" }, + { url = "https://files.pythonhosted.org/packages/b0/7f/db1a37e5f45be32c0e64f9ed1268eba56aeedcb2ef20d195fa60c6610347/hypothesis-6.167.1-cp315-abi3.abi3t-win_arm64.whl", hash = "sha256:627ce3bd166799a6c0ddcf1351049be5b9a772d5bce436216d42b41a935f42c0", size = 673123, upload-time = "2026-08-30T19:52:13.991Z" }, + { url = "https://files.pythonhosted.org/packages/53/bc/a77ee57eb8fb13f2b5bdfb4a1ea3f32713c50420f0208e84fbe590fad1ad/hypothesis-6.167.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:436027c9a00eb11a2ca3d608147ca2d0d623b4f02878c56a50fc3f3f58c2b41b", size = 786862, upload-time = "2026-08-30T19:51:38.287Z" }, + { url = "https://files.pythonhosted.org/packages/f8/3f/cc9c9120fad719e683914b9204b38f1a30721bc06344f465fc36427cc45e/hypothesis-6.167.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:35e90c121b1518d7428a45e6b0d5c6d06e0ed9eaa567f1106e1f09dae006d6da", size = 782711, upload-time = "2026-08-30T19:50:28.733Z" }, + { url = "https://files.pythonhosted.org/packages/98/a6/a48824ba4ad1257904bde4654099851febf0b4c3f018c8174b33d4ba0308/hypothesis-6.167.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9c903b4f1c8531736fc7e8e537f47ef509756b731a32a5e5e7014e5291343acb", size = 1118685, upload-time = "2026-08-30T19:52:01.045Z" }, + { url = "https://files.pythonhosted.org/packages/b4/f3/c6bcfb38c4b4cd22494902f5368b5815425f03eeb5159741c7d910a69af5/hypothesis-6.167.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:27ca252991fdbe2ff5c611a1cc4d972d4e009eb45292c7802faa2190f995dc50", size = 1165389, upload-time = "2026-08-30T19:50:39.44Z" }, + { url = "https://files.pythonhosted.org/packages/3b/f6/d1d19d115a4c0aa35c87b9e5d570b0a7c9f42f816087849cf29c58664426/hypothesis-6.167.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6c91f6f2f15b8bc6e474b824f931247c39711231e7b1b2f68277726e0ae1c728", size = 679392, upload-time = "2026-08-30T19:51:00.264Z" }, ] [[package]] @@ -453,108 +466,132 @@ wheels = [ [[package]] name = "kiwisolver" -version = "1.5.0" +version = "1.5.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d0/67/9c61eccb13f0bdca9307614e782fec49ffdde0f7a2314935d489fa93cd9c/kiwisolver-1.5.0.tar.gz", hash = "sha256:d4193f3d9dc3f6f79aaed0e5637f45d98850ebf01f7ca20e69457f3e8946b66a", size = 103482, upload-time = "2026-03-09T13:15:53.382Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/07/bd78e6a8fae171ea041ef5bba3ed21a003522fa088834b069b1909981f30/kiwisolver-1.5.1.tar.gz", hash = "sha256:f1303ef2eec81262a4b708c3e858afe58d7c75ad91c1c05266eda7673369859a", size = 104395, upload-time = "2026-08-28T10:28:27.153Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/12/dd/a495a9c104be1c476f0386e714252caf2b7eca883915422a64c50b88c6f5/kiwisolver-1.5.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9eed0f7edbb274413b6ee781cca50541c8c0facd3d6fd289779e494340a2b85c", size = 122798, upload-time = "2026-03-09T13:12:58.963Z" }, - { url = "https://files.pythonhosted.org/packages/11/60/37b4047a2af0cf5ef6d8b4b26e91829ae6fc6a2d1f74524bcb0e7cd28a32/kiwisolver-1.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3c4923e404d6bcd91b6779c009542e5647fef32e4a5d75e115e3bbac6f2335eb", size = 66216, upload-time = "2026-03-09T13:13:00.155Z" }, - { url = "https://files.pythonhosted.org/packages/0a/aa/510dc933d87767584abfe03efa445889996c70c2990f6f87c3ebaa0a18c5/kiwisolver-1.5.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0df54df7e686afa55e6f21fb86195224a6d9beb71d637e8d7920c95cf0f89aac", size = 63911, upload-time = "2026-03-09T13:13:01.671Z" }, - { url = "https://files.pythonhosted.org/packages/80/46/bddc13df6c2a40741e0cc7865bb1c9ed4796b6760bd04ce5fae3928ef917/kiwisolver-1.5.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2517e24d7315eb51c10664cdb865195df38ab74456c677df67bb47f12d088a27", size = 1438209, upload-time = "2026-03-09T13:13:03.385Z" }, - { url = "https://files.pythonhosted.org/packages/fd/d6/76621246f5165e5372f02f5e6f3f48ea336a8f9e96e43997d45b240ed8cd/kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff710414307fefa903e0d9bdf300972f892c23477829f49504e59834f4195398", size = 1248888, upload-time = "2026-03-09T13:13:05.231Z" }, - { url = "https://files.pythonhosted.org/packages/b2/c1/31559ec6fb39a5b48035ce29bb63ade628f321785f38c384dee3e2c08bc1/kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6176c1811d9d5a04fa391c490cc44f451e240697a16977f11c6f722efb9041db", size = 1266304, upload-time = "2026-03-09T13:13:06.743Z" }, - { url = "https://files.pythonhosted.org/packages/5e/ef/1cb8276f2d29cc6a41e0a042f27946ca347d3a4a75acf85d0a16aa6dcc82/kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50847dca5d197fcbd389c805aa1a1cf32f25d2e7273dc47ab181a517666b68cc", size = 1319650, upload-time = "2026-03-09T13:13:08.607Z" }, - { url = "https://files.pythonhosted.org/packages/4c/e4/5ba3cecd7ce6236ae4a80f67e5d5531287337d0e1f076ca87a5abe4cd5d0/kiwisolver-1.5.0-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:01808c6d15f4c3e8559595d6d1fe6411c68e4a3822b4b9972b44473b24f4e679", size = 970949, upload-time = "2026-03-09T13:13:10.299Z" }, - { url = "https://files.pythonhosted.org/packages/5a/69/dc61f7ae9a2f071f26004ced87f078235b5507ab6e5acd78f40365655034/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f1f9f4121ec58628c96baa3de1a55a4e3a333c5102c8e94b64e23bf7b2083309", size = 2199125, upload-time = "2026-03-09T13:13:11.841Z" }, - { url = "https://files.pythonhosted.org/packages/e5/7b/abbe0f1b5afa85f8d084b73e90e5f801c0939eba16ac2e49af7c61a6c28d/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b7d335370ae48a780c6e6a6bbfa97342f563744c39c35562f3f367665f5c1de2", size = 2293783, upload-time = "2026-03-09T13:13:14.399Z" }, - { url = "https://files.pythonhosted.org/packages/8a/80/5908ae149d96d81580d604c7f8aefd0e98f4fd728cf172f477e9f2a81744/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:800ee55980c18545af444d93fdd60c56b580db5cc54867d8cbf8a1dc0829938c", size = 1960726, upload-time = "2026-03-09T13:13:16.047Z" }, - { url = "https://files.pythonhosted.org/packages/84/08/a78cb776f8c085b7143142ce479859cfec086bd09ee638a317040b6ef420/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:c438f6ca858697c9ab67eb28246c92508af972e114cac34e57a6d4ba17a3ac08", size = 2464738, upload-time = "2026-03-09T13:13:17.897Z" }, - { url = "https://files.pythonhosted.org/packages/b1/e1/65584da5356ed6cb12c63791a10b208860ac40a83de165cb6a6751a686e3/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8c63c91f95173f9c2a67c7c526b2cea976828a0e7fced9cdcead2802dc10f8a4", size = 2270718, upload-time = "2026-03-09T13:13:19.421Z" }, - { url = "https://files.pythonhosted.org/packages/be/6c/28f17390b62b8f2f520e2915095b3c94d88681ecf0041e75389d9667f202/kiwisolver-1.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:beb7f344487cdcb9e1efe4b7a29681b74d34c08f0043a327a74da852a6749e7b", size = 73480, upload-time = "2026-03-09T13:13:20.818Z" }, - { url = "https://files.pythonhosted.org/packages/d8/0e/2ee5debc4f77a625778fec5501ff3e8036fe361b7ee28ae402a485bb9694/kiwisolver-1.5.0-cp311-cp311-win_arm64.whl", hash = "sha256:ad4ae4ffd1ee9cd11357b4c66b612da9888f4f4daf2f36995eda64bd45370cac", size = 64930, upload-time = "2026-03-09T13:13:21.997Z" }, - { url = "https://files.pythonhosted.org/packages/4d/b2/818b74ebea34dabe6d0c51cb1c572e046730e64844da6ed646d5298c40ce/kiwisolver-1.5.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:4e9750bc21b886308024f8a54ccb9a2cc38ac9fa813bf4348434e3d54f337ff9", size = 123158, upload-time = "2026-03-09T13:13:23.127Z" }, - { url = "https://files.pythonhosted.org/packages/bf/d9/405320f8077e8e1c5c4bd6adc45e1e6edf6d727b6da7f2e2533cf58bff71/kiwisolver-1.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:72ec46b7eba5b395e0a7b63025490d3214c11013f4aacb4f5e8d6c3041829588", size = 66388, upload-time = "2026-03-09T13:13:24.765Z" }, - { url = "https://files.pythonhosted.org/packages/99/9f/795fedf35634f746151ca8839d05681ceb6287fbed6cc1c9bf235f7887c2/kiwisolver-1.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ed3a984b31da7481b103f68776f7128a89ef26ed40f4dc41a2223cda7fb24819", size = 64068, upload-time = "2026-03-09T13:13:25.878Z" }, - { url = "https://files.pythonhosted.org/packages/c4/13/680c54afe3e65767bed7ec1a15571e1a2f1257128733851ade24abcefbcc/kiwisolver-1.5.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb5136fb5352d3f422df33f0c879a1b0c204004324150cc3b5e3c4f310c9049f", size = 1477934, upload-time = "2026-03-09T13:13:27.166Z" }, - { url = "https://files.pythonhosted.org/packages/c8/2f/cebfcdb60fd6a9b0f6b47a9337198bcbad6fbe15e68189b7011fd914911f/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b2af221f268f5af85e776a73d62b0845fc8baf8ef0abfae79d29c77d0e776aaf", size = 1278537, upload-time = "2026-03-09T13:13:28.707Z" }, - { url = "https://files.pythonhosted.org/packages/f2/0d/9b782923aada3fafb1d6b84e13121954515c669b18af0c26e7d21f579855/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b0f172dc8ffaccb8522d7c5d899de00133f2f1ca7b0a49b7da98e901de87bf2d", size = 1296685, upload-time = "2026-03-09T13:13:30.528Z" }, - { url = "https://files.pythonhosted.org/packages/27/70/83241b6634b04fe44e892688d5208332bde130f38e610c0418f9ede47ded/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6ab8ba9152203feec73758dad83af9a0bbe05001eb4639e547207c40cfb52083", size = 1346024, upload-time = "2026-03-09T13:13:32.818Z" }, - { url = "https://files.pythonhosted.org/packages/e4/db/30ed226fb271ae1a6431fc0fe0edffb2efe23cadb01e798caeb9f2ceae8f/kiwisolver-1.5.0-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:cdee07c4d7f6d72008d3f73b9bf027f4e11550224c7c50d8df1ae4a37c1402a6", size = 987241, upload-time = "2026-03-09T13:13:34.435Z" }, - { url = "https://files.pythonhosted.org/packages/ec/bd/c314595208e4c9587652d50959ead9e461995389664e490f4dce7ff0f782/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7c60d3c9b06fb23bd9c6139281ccbdc384297579ae037f08ae90c69f6845c0b1", size = 2227742, upload-time = "2026-03-09T13:13:36.4Z" }, - { url = "https://files.pythonhosted.org/packages/c1/43/0499cec932d935229b5543d073c2b87c9c22846aab48881e9d8d6e742a2d/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e315e5ec90d88e140f57696ff85b484ff68bb311e36f2c414aa4286293e6dee0", size = 2323966, upload-time = "2026-03-09T13:13:38.204Z" }, - { url = "https://files.pythonhosted.org/packages/3d/6f/79b0d760907965acfd9d61826a3d41f8f093c538f55cd2633d3f0db269f6/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:1465387ac63576c3e125e5337a6892b9e99e0627d52317f3ca79e6930d889d15", size = 1977417, upload-time = "2026-03-09T13:13:39.966Z" }, - { url = "https://files.pythonhosted.org/packages/ab/31/01d0537c41cb75a551a438c3c7a80d0c60d60b81f694dac83dd436aec0d0/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:530a3fd64c87cffa844d4b6b9768774763d9caa299e9b75d8eca6a4423b31314", size = 2491238, upload-time = "2026-03-09T13:13:41.698Z" }, - { url = "https://files.pythonhosted.org/packages/e4/34/8aefdd0be9cfd00a44509251ba864f5caf2991e36772e61c408007e7f417/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1d9daea4ea6b9be74fe2f01f7fbade8d6ffab263e781274cffca0dba9be9eec9", size = 2294947, upload-time = "2026-03-09T13:13:43.343Z" }, - { url = "https://files.pythonhosted.org/packages/ad/cf/0348374369ca588f8fe9c338fae49fa4e16eeb10ffb3d012f23a54578a9e/kiwisolver-1.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:f18c2d9782259a6dc132fdc7a63c168cbc74b35284b6d75c673958982a378384", size = 73569, upload-time = "2026-03-09T13:13:45.792Z" }, - { url = "https://files.pythonhosted.org/packages/28/26/192b26196e2316e2bd29deef67e37cdf9870d9af8e085e521afff0fed526/kiwisolver-1.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:f7c7553b13f69c1b29a5bde08ddc6d9d0c8bfb84f9ed01c30db25944aeb852a7", size = 64997, upload-time = "2026-03-09T13:13:46.878Z" }, - { url = "https://files.pythonhosted.org/packages/9d/69/024d6711d5ba575aa65d5538042e99964104e97fa153a9f10bc369182bc2/kiwisolver-1.5.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:fd40bb9cd0891c4c3cb1ddf83f8bbfa15731a248fdc8162669405451e2724b09", size = 123166, upload-time = "2026-03-09T13:13:48.032Z" }, - { url = "https://files.pythonhosted.org/packages/ce/48/adbb40df306f587054a348831220812b9b1d787aff714cfbc8556e38fccd/kiwisolver-1.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c0e1403fd7c26d77c1f03e096dc58a5c726503fa0db0456678b8668f76f521e3", size = 66395, upload-time = "2026-03-09T13:13:49.365Z" }, - { url = "https://files.pythonhosted.org/packages/a8/3a/d0a972b34e1c63e2409413104216cd1caa02c5a37cb668d1687d466c1c45/kiwisolver-1.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:dda366d548e89a90d88a86c692377d18d8bd64b39c1fb2b92cb31370e2896bbd", size = 64065, upload-time = "2026-03-09T13:13:50.562Z" }, - { url = "https://files.pythonhosted.org/packages/2b/0a/7b98e1e119878a27ba8618ca1e18b14f992ff1eda40f47bccccf4de44121/kiwisolver-1.5.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:332b4f0145c30b5f5ad9374881133e5aa64320428a57c2c2b61e9d891a51c2f3", size = 1477903, upload-time = "2026-03-09T13:13:52.084Z" }, - { url = "https://files.pythonhosted.org/packages/18/d8/55638d89ffd27799d5cc3d8aa28e12f4ce7a64d67b285114dbedc8ea4136/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c50b89ffd3e1a911c69a1dd3de7173c0cd10b130f56222e57898683841e4f96", size = 1278751, upload-time = "2026-03-09T13:13:54.673Z" }, - { url = "https://files.pythonhosted.org/packages/b8/97/b4c8d0d18421ecceba20ad8701358453b88e32414e6f6950b5a4bad54e65/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4db576bb8c3ef9365f8b40fe0f671644de6736ae2c27a2c62d7d8a1b4329f099", size = 1296793, upload-time = "2026-03-09T13:13:56.287Z" }, - { url = "https://files.pythonhosted.org/packages/c4/10/f862f94b6389d8957448ec9df59450b81bec4abb318805375c401a1e6892/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0b85aad90cea8ac6797a53b5d5f2e967334fa4d1149f031c4537569972596cb8", size = 1346041, upload-time = "2026-03-09T13:13:58.269Z" }, - { url = "https://files.pythonhosted.org/packages/a3/6a/f1650af35821eaf09de398ec0bc2aefc8f211f0cda50204c9f1673741ba9/kiwisolver-1.5.0-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:d36ca54cb4c6c4686f7cbb7b817f66f5911c12ddb519450bbe86707155028f87", size = 987292, upload-time = "2026-03-09T13:13:59.871Z" }, - { url = "https://files.pythonhosted.org/packages/de/19/d7fb82984b9238115fe629c915007be608ebd23dc8629703d917dbfaffd4/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:38f4a703656f493b0ad185211ccfca7f0386120f022066b018eb5296d8613e23", size = 2227865, upload-time = "2026-03-09T13:14:01.401Z" }, - { url = "https://files.pythonhosted.org/packages/7f/b9/46b7f386589fd222dac9e9de9c956ce5bcefe2ee73b4e79891381dda8654/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3ac2360e93cb41be81121755c6462cff3beaa9967188c866e5fce5cf13170859", size = 2324369, upload-time = "2026-03-09T13:14:02.972Z" }, - { url = "https://files.pythonhosted.org/packages/92/8b/95e237cf3d9c642960153c769ddcbe278f182c8affb20cecc1cc983e7cc5/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c95cab08d1965db3d84a121f1c7ce7479bdd4072c9b3dafd8fecce48a2e6b902", size = 1977989, upload-time = "2026-03-09T13:14:04.503Z" }, - { url = "https://files.pythonhosted.org/packages/1b/95/980c9df53501892784997820136c01f62bc1865e31b82b9560f980c0e649/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fc20894c3d21194d8041a28b65622d5b86db786da6e3cfe73f0c762951a61167", size = 2491645, upload-time = "2026-03-09T13:14:06.106Z" }, - { url = "https://files.pythonhosted.org/packages/cb/32/900647fd0840abebe1561792c6b31e6a7c0e278fc3973d30572a965ca14c/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7a32f72973f0f950c1920475d5c5ea3d971b81b6f0ec53b8d0a956cc965f22e0", size = 2295237, upload-time = "2026-03-09T13:14:08.891Z" }, - { url = "https://files.pythonhosted.org/packages/be/8a/be60e3bbcf513cc5a50f4a3e88e1dcecebb79c1ad607a7222877becaa101/kiwisolver-1.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:0bf3acf1419fa93064a4c2189ac0b58e3be7872bf6ee6177b0d4c63dc4cea276", size = 73573, upload-time = "2026-03-09T13:14:12.327Z" }, - { url = "https://files.pythonhosted.org/packages/4d/d2/64be2e429eb4fca7f7e1c52a91b12663aeaf25de3895e5cca0f47ef2a8d0/kiwisolver-1.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:fa8eb9ecdb7efb0b226acec134e0d709e87a909fa4971a54c0c4f6e88635484c", size = 64998, upload-time = "2026-03-09T13:14:13.469Z" }, - { url = "https://files.pythonhosted.org/packages/b0/69/ce68dd0c85755ae2de490bf015b62f2cea5f6b14ff00a463f9d0774449ff/kiwisolver-1.5.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:db485b3847d182b908b483b2ed133c66d88d49cacf98fd278fadafe11b4478d1", size = 125700, upload-time = "2026-03-09T13:14:14.636Z" }, - { url = "https://files.pythonhosted.org/packages/74/aa/937aac021cf9d4349990d47eb319309a51355ed1dbdc9c077cdc9224cb11/kiwisolver-1.5.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:be12f931839a3bdfe28b584db0e640a65a8bcbc24560ae3fdb025a449b3d754e", size = 67537, upload-time = "2026-03-09T13:14:15.808Z" }, - { url = "https://files.pythonhosted.org/packages/ee/20/3a87fbece2c40ad0f6f0aefa93542559159c5f99831d596050e8afae7a9f/kiwisolver-1.5.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:16b85d37c2cbb3253226d26e64663f755d88a03439a9c47df6246b35defbdfb7", size = 65514, upload-time = "2026-03-09T13:14:18.035Z" }, - { url = "https://files.pythonhosted.org/packages/f0/7f/f943879cda9007c45e1f7dba216d705c3a18d6b35830e488b6c6a4e7cdf0/kiwisolver-1.5.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4432b835675f0ea7414aab3d37d119f7226d24869b7a829caeab49ebda407b0c", size = 1584848, upload-time = "2026-03-09T13:14:19.745Z" }, - { url = "https://files.pythonhosted.org/packages/37/f8/4d4f85cc1870c127c88d950913370dd76138482161cd07eabbc450deff01/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b0feb50971481a2cc44d94e88bdb02cdd497618252ae226b8eb1201b957e368", size = 1391542, upload-time = "2026-03-09T13:14:21.54Z" }, - { url = "https://files.pythonhosted.org/packages/04/0b/65dd2916c84d252b244bd405303220f729e7c17c9d7d33dca6feeff9ffc4/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56fa888f10d0f367155e76ce849fa1166fc9730d13bd2d65a2aa13b6f5424489", size = 1404447, upload-time = "2026-03-09T13:14:23.205Z" }, - { url = "https://files.pythonhosted.org/packages/39/5c/2606a373247babce9b1d056c03a04b65f3cf5290a8eac5d7bdead0a17e21/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:940dda65d5e764406b9fb92761cbf462e4e63f712ab60ed98f70552e496f3bf1", size = 1455918, upload-time = "2026-03-09T13:14:24.74Z" }, - { url = "https://files.pythonhosted.org/packages/d5/d1/c6078b5756670658e9192a2ef11e939c92918833d2745f85cd14a6004bdf/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_39_riscv64.whl", hash = "sha256:89fc958c702ee9a745e4700378f5d23fddbc46ff89e8fdbf5395c24d5c1452a3", size = 1072856, upload-time = "2026-03-09T13:14:26.597Z" }, - { url = "https://files.pythonhosted.org/packages/cb/c8/7def6ddf16eb2b3741d8b172bdaa9af882b03c78e9b0772975408801fa63/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9027d773c4ff81487181a925945743413f6069634d0b122d0b37684ccf4f1e18", size = 2333580, upload-time = "2026-03-09T13:14:28.237Z" }, - { url = "https://files.pythonhosted.org/packages/9e/87/2ac1fce0eb1e616fcd3c35caa23e665e9b1948bb984f4764790924594128/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:5b233ea3e165e43e35dba1d2b8ecc21cf070b45b65ae17dd2747d2713d942021", size = 2423018, upload-time = "2026-03-09T13:14:30.018Z" }, - { url = "https://files.pythonhosted.org/packages/67/13/c6700ccc6cc218716bfcda4935e4b2997039869b4ad8a94f364c5a3b8e63/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ce9bf03dad3b46408c08649c6fbd6ca28a9fce0eb32fdfffa6775a13103b5310", size = 2062804, upload-time = "2026-03-09T13:14:32.888Z" }, - { url = "https://files.pythonhosted.org/packages/1b/bd/877056304626943ff0f1f44c08f584300c199b887cb3176cd7e34f1515f1/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:fc4d3f1fb9ca0ae9f97b095963bc6326f1dbfd3779d6679a1e016b9baaa153d3", size = 2597482, upload-time = "2026-03-09T13:14:34.971Z" }, - { url = "https://files.pythonhosted.org/packages/75/19/c60626c47bf0f8ac5dcf72c6c98e266d714f2fbbfd50cf6dab5ede3aaa50/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f443b4825c50a51ee68585522ab4a1d1257fac65896f282b4c6763337ac9f5d2", size = 2394328, upload-time = "2026-03-09T13:14:36.816Z" }, - { url = "https://files.pythonhosted.org/packages/47/84/6a6d5e5bb8273756c27b7d810d47f7ef2f1f9b9fd23c9ee9a3f8c75c9cef/kiwisolver-1.5.0-cp313-cp313t-win_arm64.whl", hash = "sha256:893ff3a711d1b515ba9da14ee090519bad4610ed1962fbe298a434e8c5f8db53", size = 68410, upload-time = "2026-03-09T13:14:38.695Z" }, - { url = "https://files.pythonhosted.org/packages/e4/d7/060f45052f2a01ad5762c8fdecd6d7a752b43400dc29ff75cd47225a40fd/kiwisolver-1.5.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8df31fe574b8b3993cc61764f40941111b25c2d9fea13d3ce24a49907cd2d615", size = 123231, upload-time = "2026-03-09T13:14:41.323Z" }, - { url = "https://files.pythonhosted.org/packages/c2/a7/78da680eadd06ff35edef6ef68a1ad273bad3e2a0936c9a885103230aece/kiwisolver-1.5.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1d49a49ac4cbfb7c1375301cd1ec90169dfeae55ff84710d782260ce77a75a02", size = 66489, upload-time = "2026-03-09T13:14:42.534Z" }, - { url = "https://files.pythonhosted.org/packages/49/b2/97980f3ad4fae37dd7fe31626e2bf75fbf8bdf5d303950ec1fab39a12da8/kiwisolver-1.5.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0cbe94b69b819209a62cb27bdfa5dc2a8977d8de2f89dfd97ba4f53ed3af754e", size = 64063, upload-time = "2026-03-09T13:14:44.759Z" }, - { url = "https://files.pythonhosted.org/packages/e7/f9/b06c934a6aa8bc91f566bd2a214fd04c30506c2d9e2b6b171953216a65b6/kiwisolver-1.5.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80aa065ffd378ff784822a6d7c3212f2d5f5e9c3589614b5c228b311fd3063ac", size = 1475913, upload-time = "2026-03-09T13:14:46.247Z" }, - { url = "https://files.pythonhosted.org/packages/6b/f0/f768ae564a710135630672981231320bc403cf9152b5596ec5289de0f106/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e7f886f47ab881692f278ae901039a234e4025a68e6dfab514263a0b1c4ae05", size = 1282782, upload-time = "2026-03-09T13:14:48.458Z" }, - { url = "https://files.pythonhosted.org/packages/e2/9f/1de7aad00697325f05238a5f2eafbd487fb637cc27a558b5367a5f37fb7f/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5060731cc3ed12ca3a8b57acd4aeca5bbc2f49216dd0bec1650a1acd89486bcd", size = 1300815, upload-time = "2026-03-09T13:14:50.721Z" }, - { url = "https://files.pythonhosted.org/packages/5a/c2/297f25141d2e468e0ce7f7a7b92e0cf8918143a0cbd3422c1ad627e85a06/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7a4aa69609f40fce3cbc3f87b2061f042eee32f94b8f11db707b66a26461591a", size = 1347925, upload-time = "2026-03-09T13:14:52.304Z" }, - { url = "https://files.pythonhosted.org/packages/b9/d3/f4c73a02eb41520c47610207b21afa8cdd18fdbf64ffd94674ae21c4812d/kiwisolver-1.5.0-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:d168fda2dbff7b9b5f38e693182d792a938c31db4dac3a80a4888de603c99554", size = 991322, upload-time = "2026-03-09T13:14:54.637Z" }, - { url = "https://files.pythonhosted.org/packages/7b/46/d3f2efef7732fcda98d22bf4ad5d3d71d545167a852ca710a494f4c15343/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:413b820229730d358efd838ecbab79902fe97094565fdc80ddb6b0a18c18a581", size = 2232857, upload-time = "2026-03-09T13:14:56.471Z" }, - { url = "https://files.pythonhosted.org/packages/3f/ec/2d9756bf2b6d26ae4349b8d3662fb3993f16d80c1f971c179ce862b9dbae/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5124d1ea754509b09e53738ec185584cc609aae4a3b510aaf4ed6aa047ef9303", size = 2329376, upload-time = "2026-03-09T13:14:58.072Z" }, - { url = "https://files.pythonhosted.org/packages/8f/9f/876a0a0f2260f1bde92e002b3019a5fabc35e0939c7d945e0fa66185eb20/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e4415a8db000bf49a6dd1c478bf70062eaacff0f462b92b0ba68791a905861f9", size = 1982549, upload-time = "2026-03-09T13:14:59.668Z" }, - { url = "https://files.pythonhosted.org/packages/6c/4f/ba3624dfac23a64d54ac4179832860cb537c1b0af06024936e82ca4154a0/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d618fd27420381a4f6044faa71f46d8bfd911bd077c555f7138ed88729bfbe79", size = 2494680, upload-time = "2026-03-09T13:15:01.364Z" }, - { url = "https://files.pythonhosted.org/packages/39/b7/97716b190ab98911b20d10bf92eca469121ec483b8ce0edd314f51bc85af/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5092eb5b1172947f57d6ea7d89b2f29650414e4293c47707eb499ec07a0ac796", size = 2297905, upload-time = "2026-03-09T13:15:03.925Z" }, - { url = "https://files.pythonhosted.org/packages/a3/36/4e551e8aa55c9188bca9abb5096805edbf7431072b76e2298e34fd3a3008/kiwisolver-1.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:d76e2d8c75051d58177e762164d2e9ab92886534e3a12e795f103524f221dd8e", size = 75086, upload-time = "2026-03-09T13:15:07.775Z" }, - { url = "https://files.pythonhosted.org/packages/70/15/9b90f7df0e31a003c71649cf66ef61c3c1b862f48c81007fa2383c8bd8d7/kiwisolver-1.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:fa6248cd194edff41d7ea9425ced8ca3a6f838bfb295f6f1d6e6bb694a8518df", size = 66577, upload-time = "2026-03-09T13:15:09.139Z" }, - { url = "https://files.pythonhosted.org/packages/17/01/7dc8c5443ff42b38e72731643ed7cf1ed9bf01691ae5cdca98501999ed83/kiwisolver-1.5.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:d1ffeb80b5676463d7a7d56acbe8e37a20ce725570e09549fe738e02ca6b7e1e", size = 125794, upload-time = "2026-03-09T13:15:10.525Z" }, - { url = "https://files.pythonhosted.org/packages/46/8a/b4ebe46ebaac6a303417fab10c2e165c557ddaff558f9699d302b256bc53/kiwisolver-1.5.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bc4d8e252f532ab46a1de9349e2d27b91fce46736a9eedaa37beaca66f574ed4", size = 67646, upload-time = "2026-03-09T13:15:12.016Z" }, - { url = "https://files.pythonhosted.org/packages/60/35/10a844afc5f19d6f567359bf4789e26661755a2f36200d5d1ed8ad0126e5/kiwisolver-1.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6783e069732715ad0c3ce96dbf21dbc2235ab0593f2baf6338101f70371f4028", size = 65511, upload-time = "2026-03-09T13:15:13.311Z" }, - { url = "https://files.pythonhosted.org/packages/f8/8a/685b297052dd041dcebce8e8787b58923b6e78acc6115a0dc9189011c44b/kiwisolver-1.5.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e7c4c09a490dc4d4a7f8cbee56c606a320f9dc28cf92a7157a39d1ce7676a657", size = 1584858, upload-time = "2026-03-09T13:15:15.103Z" }, - { url = "https://files.pythonhosted.org/packages/9e/80/04865e3d4638ac5bddec28908916df4a3075b8c6cc101786a96803188b96/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a075bd7bd19c70cf67c8badfa36cf7c5d8de3c9ddb8420c51e10d9c50e94920", size = 1392539, upload-time = "2026-03-09T13:15:16.661Z" }, - { url = "https://files.pythonhosted.org/packages/ba/01/77a19cacc0893fa13fafa46d1bba06fb4dc2360b3292baf4b56d8e067b24/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bdd3e53429ff02aa319ba59dfe4ceeec345bf46cf180ec2cf6fd5b942e7975e9", size = 1405310, upload-time = "2026-03-09T13:15:18.229Z" }, - { url = "https://files.pythonhosted.org/packages/53/39/bcaf5d0cca50e604cfa9b4e3ae1d64b50ca1ae5b754122396084599ef903/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cdcb35dc9d807259c981a85531048ede628eabcffb3239adf3d17463518992d", size = 1456244, upload-time = "2026-03-09T13:15:20.444Z" }, - { url = "https://files.pythonhosted.org/packages/d0/7a/72c187abc6975f6978c3e39b7cf67aeb8b3c0a8f9790aa7fd412855e9e1f/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:70d593af6a6ca332d1df73d519fddb5148edb15cd90d5f0155e3746a6d4fcc65", size = 1073154, upload-time = "2026-03-09T13:15:22.039Z" }, - { url = "https://files.pythonhosted.org/packages/c7/ca/cf5b25783ebbd59143b4371ed0c8428a278abe68d6d0104b01865b1bbd0f/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:377815a8616074cabbf3f53354e1d040c35815a134e01d7614b7692e4bf8acfa", size = 2334377, upload-time = "2026-03-09T13:15:23.741Z" }, - { url = "https://files.pythonhosted.org/packages/4a/e5/b1f492adc516796e88751282276745340e2a72dcd0d36cf7173e0daf3210/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0255a027391d52944eae1dbb5d4cc5903f57092f3674e8e544cdd2622826b3f0", size = 2425288, upload-time = "2026-03-09T13:15:25.789Z" }, - { url = "https://files.pythonhosted.org/packages/e6/e5/9b21fbe91a61b8f409d74a26498706e97a48008bfcd1864373d32a6ba31c/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:012b1eb16e28718fa782b5e61dc6f2da1f0792ca73bd05d54de6cb9561665fc9", size = 2063158, upload-time = "2026-03-09T13:15:27.63Z" }, - { url = "https://files.pythonhosted.org/packages/b1/02/83f47986138310f95ea95531f851b2a62227c11cbc3e690ae1374fe49f0f/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0e3aafb33aed7479377e5e9a82e9d4bf87063741fc99fc7ae48b0f16e32bdd6f", size = 2597260, upload-time = "2026-03-09T13:15:29.421Z" }, - { url = "https://files.pythonhosted.org/packages/07/18/43a5f24608d8c313dd189cf838c8e68d75b115567c6279de7796197cfb6a/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e7a116ae737f0000343218c4edf5bd45893bfeaff0993c0b215d7124c9f77646", size = 2394403, upload-time = "2026-03-09T13:15:31.517Z" }, - { url = "https://files.pythonhosted.org/packages/3b/b5/98222136d839b8afabcaa943b09bd05888c2d36355b7e448550211d1fca4/kiwisolver-1.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:1dd9b0b119a350976a6d781e7278ec7aca0b201e1a9e2d23d9804afecb6ca681", size = 79687, upload-time = "2026-03-09T13:15:33.204Z" }, - { url = "https://files.pythonhosted.org/packages/99/a2/ca7dc962848040befed12732dff6acae7fb3c4f6fc4272b3f6c9a30b8713/kiwisolver-1.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:58f812017cd2985c21fbffb4864d59174d4903dd66fa23815e74bbc7a0e2dd57", size = 70032, upload-time = "2026-03-09T13:15:34.411Z" }, - { url = "https://files.pythonhosted.org/packages/1c/fa/2910df836372d8761bb6eff7d8bdcb1613b5c2e03f260efe7abe34d388a7/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-macosx_10_13_x86_64.whl", hash = "sha256:5ae8e62c147495b01a0f4765c878e9bfdf843412446a247e28df59936e99e797", size = 130262, upload-time = "2026-03-09T13:15:35.629Z" }, - { url = "https://files.pythonhosted.org/packages/0f/41/c5f71f9f00aabcc71fee8b7475e3f64747282580c2fe748961ba29b18385/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:f6764a4ccab3078db14a632420930f6186058750df066b8ea2a7106df91d3203", size = 138036, upload-time = "2026-03-09T13:15:36.894Z" }, - { url = "https://files.pythonhosted.org/packages/fa/06/7399a607f434119c6e1fdc8ec89a8d51ccccadf3341dee4ead6bd14caaf5/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c31c13da98624f957b0fb1b5bae5383b2333c2c3f6793d9825dd5ce79b525cb7", size = 194295, upload-time = "2026-03-09T13:15:38.22Z" }, - { url = "https://files.pythonhosted.org/packages/b5/91/53255615acd2a1eaca307ede3c90eb550bae9c94581f8c00081b6b1c8f44/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-win_amd64.whl", hash = "sha256:1f1489f769582498610e015a8ef2d36f28f505ab3096d0e16b4858a9ec214f57", size = 75987, upload-time = "2026-03-09T13:15:39.65Z" }, - { url = "https://files.pythonhosted.org/packages/e9/eb/5fcbbbf9a0e2c3a35effb88831a483345326bbc3a030a3b5b69aee647f84/kiwisolver-1.5.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ec4c85dc4b687c7f7f15f553ff26a98bfe8c58f5f7f0ac8905f0ba4c7be60232", size = 59532, upload-time = "2026-03-09T13:15:47.047Z" }, - { url = "https://files.pythonhosted.org/packages/c3/9b/e17104555bb4db148fd52327feea1e96be4b88e8e008b029002c281a21ab/kiwisolver-1.5.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:12e91c215a96e39f57989c8912ae761286ac5a9584d04030ceb3368a357f017a", size = 57420, upload-time = "2026-03-09T13:15:48.199Z" }, - { url = "https://files.pythonhosted.org/packages/48/44/2b5b95b7aa39fb2d8d9d956e0f3d5d45aef2ae1d942d4c3ffac2f9cfed1a/kiwisolver-1.5.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:be4a51a55833dc29ab5d7503e7bcb3b3af3402d266018137127450005cdfe737", size = 79892, upload-time = "2026-03-09T13:15:49.694Z" }, - { url = "https://files.pythonhosted.org/packages/52/7d/7157f9bba6b455cfb4632ed411e199fc8b8977642c2b12082e1bd9e6d173/kiwisolver-1.5.0-pp311-pypy311_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:daae526907e262de627d8f70058a0f64acc9e2641c164c99c8f594b34a799a16", size = 77603, upload-time = "2026-03-09T13:15:50.945Z" }, - { url = "https://files.pythonhosted.org/packages/0a/dd/8050c947d435c8d4bc94e3252f4d8bb8a76cfb424f043a8680be637a57f1/kiwisolver-1.5.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:59cd8683f575d96df5bb48f6add94afc055012c29e28124fcae2b63661b9efb1", size = 73558, upload-time = "2026-03-09T13:15:52.112Z" }, + { url = "https://files.pythonhosted.org/packages/94/7b/2de6908edc668427c149af5f93112e931f87e1fa4cab80bac32c5844dccc/kiwisolver-1.5.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:b3d78f7bb2b9d9a30345be1474b9aaa8685430b54afb51ba3639b5c6c11e9ed6", size = 123364, upload-time = "2026-08-28T10:25:04.359Z" }, + { url = "https://files.pythonhosted.org/packages/8a/24/e70914415c77c97be7e22c80a0740869cb7428768cc380fdcdf6703e7084/kiwisolver-1.5.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5025e36fb4fb275cef0a4e30dbb11cb4ae61d1c83deb90189cb5d7e4cafd6b55", size = 66558, upload-time = "2026-08-28T10:25:05.506Z" }, + { url = "https://files.pythonhosted.org/packages/e8/2b/8b08b11833db4d475b8ef1f36174f8d8a7abd31bedd7e794be78e8814b48/kiwisolver-1.5.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc1a26b8e53395a01c2c611e58602fa47461f136fba7cd5542e6db6d64be1839", size = 64071, upload-time = "2026-08-28T10:25:06.7Z" }, + { url = "https://files.pythonhosted.org/packages/89/00/05c2d0369ac322d22d5c05f84b5c4a6856fa6207fbae42869108a28f0383/kiwisolver-1.5.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:95a02752aa032eef4aed01cda6d9b687c669bd0396bf4519eef8bba22a286720", size = 1438206, upload-time = "2026-08-28T10:25:08.254Z" }, + { url = "https://files.pythonhosted.org/packages/c0/05/c941a139f27438c1910d630fdc3ccfdab7c8407c72052299ead12ece086e/kiwisolver-1.5.1-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:719a35fa1156db3640555f95ebb94f60a444e64d1c69626b0edef5df78eba225", size = 1248975, upload-time = "2026-08-28T10:25:10.053Z" }, + { url = "https://files.pythonhosted.org/packages/58/a1/2669ee5512e39b9d4de25faacaedf788c957f93730c5f7c63993ec4f5933/kiwisolver-1.5.1-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:febcce10f2bcdbb80b4ea919238a6a4ac13dbc4c7cadbe8d5d75c3682f8b5404", size = 1266301, upload-time = "2026-08-28T10:25:11.754Z" }, + { url = "https://files.pythonhosted.org/packages/28/b8/353f52f2c7f861a9e90cd2e8f90f85b3ad03060835f823e08298d094c463/kiwisolver-1.5.1-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1d852545c4d0e35a72728d072cbaa59e2fa7dd84bdf01e068d670dd0ceb58eb6", size = 1319708, upload-time = "2026-08-28T10:25:13.559Z" }, + { url = "https://files.pythonhosted.org/packages/21/0e/14b83200eadc2c1d63b76bac01c1813bf072aecf567429f303e00b70258e/kiwisolver-1.5.1-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:2e10ae1bba1899188b33557c10d73affcc12033edd18adddb57d209039976a4c", size = 971720, upload-time = "2026-08-28T10:25:14.934Z" }, + { url = "https://files.pythonhosted.org/packages/86/91/9d43d84d23b1cbff72a142d387ead1ea03db0cba8ff86ed5335addad3cc9/kiwisolver-1.5.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b69602970994a2ed8bbfa78c2f0394a7435226c6040489702d9f0a0ad0c07052", size = 2200119, upload-time = "2026-08-28T10:25:16.636Z" }, + { url = "https://files.pythonhosted.org/packages/ff/7c/f2bd9616f27ffb5e17cecc0baa5d0bbcee7e55aeddc0ccc871d69e2fc3ee/kiwisolver-1.5.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d50de98e8d807dc31822fff96f50293163a62418eb65487a21b42713d72ed0b7", size = 2295005, upload-time = "2026-08-28T10:25:18.374Z" }, + { url = "https://files.pythonhosted.org/packages/ba/17/ee671b72bf8f46a08379d4392c65582541759a542428197562f2898294ad/kiwisolver-1.5.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3221f78211074f561c44ca42eac0619828171bec15a2c4cf6f7747d07df76e8e", size = 1960982, upload-time = "2026-08-28T10:25:19.893Z" }, + { url = "https://files.pythonhosted.org/packages/2e/f7/0e26b4c05bee3bdb0f048dfa305e4fe701999ea17b51e9c616ef91035bbe/kiwisolver-1.5.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:0ba9527afc80ae3d7814ed98b6572d02bf85eaf48065678342c5f0c6dab7a8c7", size = 2464918, upload-time = "2026-08-28T10:25:21.65Z" }, + { url = "https://files.pythonhosted.org/packages/ae/62/6eb431133d30ce656ac1e5ff72fac70dd34d54c3984f4011b9ac8bf77d54/kiwisolver-1.5.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e12dfea7f5fc2a34a9080efbf79c4c44eb380ec5b9c6fea09407e08f0d1e941d", size = 2270967, upload-time = "2026-08-28T10:25:23.643Z" }, + { url = "https://files.pythonhosted.org/packages/bf/9f/6f9e489c188200e6fb3193935501894811e8c97577c8ffe9033589bf3521/kiwisolver-1.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:1a7587dc335f2c0f5bd577fd0540bd16c66006bdb60f759a1059f025e6c4f071", size = 70744, upload-time = "2026-08-28T10:25:25.061Z" }, + { url = "https://files.pythonhosted.org/packages/c6/6d/dfc430d1d43957061599adea3f08ea982bb6f4ab601a8c974bedcf2ba850/kiwisolver-1.5.1-cp311-cp311-win_arm64.whl", hash = "sha256:e4e4523d6f336708d732516e6cfca7796cf3d96c9474eb5aecf6165f2f1fefc3", size = 68404, upload-time = "2026-08-28T10:25:26.186Z" }, + { url = "https://files.pythonhosted.org/packages/6b/9b/65b302742389c6f96f2956bef5decf26011309feb2fc5d79613af18adea4/kiwisolver-1.5.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:63fb7294b768f444eb4b068965f2662f28c2fd4161e23bd60fcf3ff27b74c046", size = 123876, upload-time = "2026-08-28T10:25:27.44Z" }, + { url = "https://files.pythonhosted.org/packages/71/74/c21f339956f6f691b2ed7e31d5f3ae767304df6c460192739fc830853051/kiwisolver-1.5.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0ebdef3eae5336568147c39a55be6a2036ffde53faa9ca2d978989ae7c2da12c", size = 66487, upload-time = "2026-08-28T10:25:28.728Z" }, + { url = "https://files.pythonhosted.org/packages/84/e5/bdb34e21523e01dceda064d63713f3bdec91388af24fba1eca7ea5e85864/kiwisolver-1.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1798e83840c3f627246104c4d8a9639c60fa068adf9ce92b61791781fa8a68c1", size = 64660, upload-time = "2026-08-28T10:25:30.071Z" }, + { url = "https://files.pythonhosted.org/packages/fc/f4/dadfec469313c7f428efa7e84b4aba9732f813c13ea7131a24b7b008ef57/kiwisolver-1.5.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:34633ecf50d16187ab8e5528b7a2530f2feb4e23f300db4672538b51cfc5cd38", size = 1477929, upload-time = "2026-08-28T10:25:31.495Z" }, + { url = "https://files.pythonhosted.org/packages/6f/35/09c58daac34e6f6ea5c6dee0094b422118e5a7c265586008a95fd135ac5f/kiwisolver-1.5.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d27c2123977cb9269c30a49ba45f03a4323017ef693e19db4ec9dbe1299a3002", size = 1278499, upload-time = "2026-08-28T10:25:33.375Z" }, + { url = "https://files.pythonhosted.org/packages/19/32/739765e24fbad29d13f83e546ea4abc215a78cea9d677ca09025b027724d/kiwisolver-1.5.1-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6a797a1cefc8b9c93170db580337e1fe3d011ad18b1299943231279406342048", size = 1296677, upload-time = "2026-08-28T10:25:35.059Z" }, + { url = "https://files.pythonhosted.org/packages/df/32/03304d1010e2cc45e5b3b52cef7e43fed3a2a5cd6c87a89b4a88e1d85b5d/kiwisolver-1.5.1-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2551cf9917af48ee7c4b29cc82320489508cf96fd26a51f6fc124de661cd44c7", size = 1346037, upload-time = "2026-08-28T10:25:36.705Z" }, + { url = "https://files.pythonhosted.org/packages/3e/57/4c49377bfd274450dd72ecaa13eaac32ea804a03363e4d1db0c5aa999ceb/kiwisolver-1.5.1-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:38f6e0deb4d0a4615efe0c4efc5990b06ae450ab50a0b321c0b078b6d238c083", size = 988248, upload-time = "2026-08-28T10:25:38.299Z" }, + { url = "https://files.pythonhosted.org/packages/cb/c3/38df144a08b6c5d75ca4504e5cc3141bb3bfef64c04f4ef48204f42711b6/kiwisolver-1.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bfd1de989b3330420e29de39352f5c049905c9e3ee67233a50d550e3d652c148", size = 2228722, upload-time = "2026-08-28T10:25:40.038Z" }, + { url = "https://files.pythonhosted.org/packages/e7/11/3221838a89cd64d9b386353e000cd8a296069a20fbe3584507fdfd5bebae/kiwisolver-1.5.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1209042a623ddfda5497e4066c7b77651dde8e1d3a9dd97599dc7e97f3b9b78c", size = 2325216, upload-time = "2026-08-28T10:25:41.699Z" }, + { url = "https://files.pythonhosted.org/packages/83/d4/075c219230697bb5db910d37262b9bacf880f92b4811a02ab81ed073a253/kiwisolver-1.5.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:26e8268480be5061d509e29669d59103c067a26377a56491630ece11762e3858", size = 1977689, upload-time = "2026-08-28T10:25:43.559Z" }, + { url = "https://files.pythonhosted.org/packages/bb/08/1d219c3c2dd960983d0d4da623d916e9de6385df2b0bab3d1af0e9b8fccc/kiwisolver-1.5.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:d79308fa689fac89cbcfbd4dbfc80b5f95c54c5a7fd4d194be221f9d33d026e6", size = 2491443, upload-time = "2026-08-28T10:25:45.242Z" }, + { url = "https://files.pythonhosted.org/packages/ba/d3/024208ec1079d273f1047468d1bdffbf38bb75b7b268090fd3a0301b9d9a/kiwisolver-1.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b03af77d77e50edba2030fd5f7c352ff209314b09030a3cba7c14edf9a09a444", size = 2295200, upload-time = "2026-08-28T10:25:46.984Z" }, + { url = "https://files.pythonhosted.org/packages/6e/7c/7b210498f9f92e1cd7855f260fa69ef056881087b199ee20c208f0e4189a/kiwisolver-1.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:06a6917674de9e0fe3f66f5430787f59a9f2ddb64af9b714eaec547e29ef5c19", size = 70748, upload-time = "2026-08-28T10:25:48.444Z" }, + { url = "https://files.pythonhosted.org/packages/94/61/ef0daa157c8bb23672f7423e0d14c39db1dc6ef8ed47e6bc54c9c1bef3bf/kiwisolver-1.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:ad8b9671348d7c8716715652ae11f85ed0eb99e265a2df2ca490577d69860b2c", size = 68324, upload-time = "2026-08-28T10:25:49.81Z" }, + { url = "https://files.pythonhosted.org/packages/08/c1/88018321d976f53c421e379c43bc6993e70ce0c8a3ec5edc4bfe102257f6/kiwisolver-1.5.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b6ae6a0328f0bc035741820fdeecdcd67bf4694eee03972e843663107122f450", size = 62272, upload-time = "2026-08-28T10:25:51.02Z" }, + { url = "https://files.pythonhosted.org/packages/85/d2/712bc17ea4f1d216034928069d612defbc6c95a471c55a7203a39faecb1a/kiwisolver-1.5.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:886fc26012f0e8b5f69d1cfe6d711f6b11f194621539bf8e6bb1c25c5dc82724", size = 64481, upload-time = "2026-08-28T10:25:52.22Z" }, + { url = "https://files.pythonhosted.org/packages/1b/e6/6c5380d676f43b6d918033962ea5e72360ca69e5a404154bc496b598ffdb/kiwisolver-1.5.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:aefe930d113798330e9462f7874542977869c0613cba3262e2de3a8d5dee8f3a", size = 66260, upload-time = "2026-08-28T10:25:53.387Z" }, + { url = "https://files.pythonhosted.org/packages/92/6a/7087f5822cc8bb272679641404b1966a42504dac9ee74e2b33840475a0aa/kiwisolver-1.5.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a5ca5aebae78a0bc13c1943af4af615d4966c5b650b05d5aa83b50e427196fee", size = 123876, upload-time = "2026-08-28T10:25:54.644Z" }, + { url = "https://files.pythonhosted.org/packages/5c/4b/9f385087ca09ee5ab9c09c6832561a7d2f7c78d3e5661d511e669f70e439/kiwisolver-1.5.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1ed0f5e49d0ceff8b72190824d9e59c062fbbc02c231b853112c78474b3f5ec2", size = 66487, upload-time = "2026-08-28T10:25:55.899Z" }, + { url = "https://files.pythonhosted.org/packages/47/8b/40d33ffd2f378094ed462e9a9a0907e59d4de9845e65a59561272da350d4/kiwisolver-1.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:77a4c8187a5948d7f8795adb765a3c7b553d07d86d88e43038fc32fc1fb9a3f3", size = 64673, upload-time = "2026-08-28T10:25:57.048Z" }, + { url = "https://files.pythonhosted.org/packages/ab/43/86aacc027959108b4c66eeae8b73cedb057dfa6eb3a335d05ad65197081c/kiwisolver-1.5.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:74ad5c3dad54a4641b4c28cd15ded70899d04459c6c7aeacafea716be97cce6d", size = 1477992, upload-time = "2026-08-28T10:25:58.479Z" }, + { url = "https://files.pythonhosted.org/packages/ed/40/b1d0369048c79733a32c8abb0f2718532e6630641368e33a81384246e844/kiwisolver-1.5.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21e46b23a2da695c364124817bc01d970effd5483147f8d66a6a7167e3f6b851", size = 1278821, upload-time = "2026-08-28T10:26:00.121Z" }, + { url = "https://files.pythonhosted.org/packages/61/a1/fa71c1792272ff9461432715ae60ffb7e11a4d7ac3bf68961b9cab6c60cf/kiwisolver-1.5.1-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:75d9b1cf8258462dbdc1eeda718c96ea7f079324c09067f6daabfcf37712b7fe", size = 1296805, upload-time = "2026-08-28T10:26:01.868Z" }, + { url = "https://files.pythonhosted.org/packages/a3/49/3f0bd94af8e06ecc47eb834b195a06f05d48711ceb2352c56d6835160f0e/kiwisolver-1.5.1-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8fca690b00c4c48f6c2a547b0160ed511357093a4e4c9b47e0fadf3128066d89", size = 1346109, upload-time = "2026-08-28T10:26:03.59Z" }, + { url = "https://files.pythonhosted.org/packages/a4/44/3afe6ef9cf06d61220953a8963e94eca978491be1d9547cb01d82a1efa08/kiwisolver-1.5.1-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:876bbfd276473d3daffe30e8c975df4ed9429967b41a6cb362dbb5155b6f13ad", size = 988252, upload-time = "2026-08-28T10:26:05.306Z" }, + { url = "https://files.pythonhosted.org/packages/e4/30/a12bd7a7285a211e1747c3eec77b8c614dbfcc1dad942f7611a1a6921ae5/kiwisolver-1.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f942903fde7363d1d879057ec5de01310efda2597161784d752fa9953a01a71a", size = 2228846, upload-time = "2026-08-28T10:26:07.312Z" }, + { url = "https://files.pythonhosted.org/packages/a1/6b/233e2958abf0dab7b18d07e52f286e02e519d7651bfbbe97af9347564109/kiwisolver-1.5.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c90d3022d8a94778939cda8638c6c8da8fa757b8958dad7ec868ce29c87681b8", size = 2325583, upload-time = "2026-08-28T10:26:09.093Z" }, + { url = "https://files.pythonhosted.org/packages/42/8e/7673060a27b01405b580058510adef34687069d229800239f5e44682d4d0/kiwisolver-1.5.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:8a34616dc2521cc8dc1d7d081734da63539f021ac0450ce950908340c6e7aa2f", size = 1978221, upload-time = "2026-08-28T10:26:11.127Z" }, + { url = "https://files.pythonhosted.org/packages/3f/7b/1b882fc1a8b4a0bb8084e7d1d85004116d08c92c0705d31f2928dec607f2/kiwisolver-1.5.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:8bf4df63592c2a66b4f8edc5df2544998c288aa02f96ce0acd880cd1de8c8127", size = 2491819, upload-time = "2026-08-28T10:26:13.348Z" }, + { url = "https://files.pythonhosted.org/packages/39/9c/426deb49e62c5f69464b64bbeca064d3b758a7506b8913d986ef34f4619c/kiwisolver-1.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d09037ca068d784ebc4aec290ef952ca27ac15dd9c0b5801a88c6e1096b83e6b", size = 2295520, upload-time = "2026-08-28T10:26:15.042Z" }, + { url = "https://files.pythonhosted.org/packages/f5/22/deabbb3ad6d918d74b7831b2d8ae7151b09d21c87974e5ee8a456f58c94c/kiwisolver-1.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:dc23390afe9f4ef9ac3bcc72a03a56eebbde03f4c571a32cb38f859cff9a6524", size = 70758, upload-time = "2026-08-28T10:26:16.504Z" }, + { url = "https://files.pythonhosted.org/packages/b9/71/02fa5c2fd92068bb8952847e70ee6c5cb280e7febe11653d17812acc53dd/kiwisolver-1.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:186884a58486651e3c217b6acea0a53eaa9498fdd472057c46f2f0fb5c25aad5", size = 68329, upload-time = "2026-08-28T10:26:17.658Z" }, + { url = "https://files.pythonhosted.org/packages/dd/87/2d5dfad0daf17dcc18d98c48ed2332fc3f051cf599e60be6182a30dd4cf1/kiwisolver-1.5.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0324cd2567259b7a095f6cf18a52b0ffc6f3de9e69528ff1bc0e7a37bd43ff1a", size = 62337, upload-time = "2026-08-28T10:26:18.778Z" }, + { url = "https://files.pythonhosted.org/packages/08/c8/83e1624f15d6262b470dbcc80b09979fd4d5b2ea3ddfc6b6e3327e235726/kiwisolver-1.5.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:74ea337e0ec3f6f342a36a4f1b5cd94dd9affddcd28ba9aae2905af932ee8c6b", size = 64513, upload-time = "2026-08-28T10:26:19.909Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2d/827ec30eb07f528c08d8459ffb318ae91a56d793ee8acbea8b491f0ff906/kiwisolver-1.5.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ee9df1f0d77b9c6e94f4ac0fec533fbddd5ea3a327807f18d7b069ae019ded80", size = 66287, upload-time = "2026-08-28T10:26:21.078Z" }, + { url = "https://files.pythonhosted.org/packages/53/11/5c43a562529dad8def4b81e5e1877c612a7e0298105a5939b3b409d2079c/kiwisolver-1.5.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fc271a6f0a2126958f4090e5507b9da5848927dae331f8f763bd4aa642b3d2cd", size = 123940, upload-time = "2026-08-28T10:26:22.475Z" }, + { url = "https://files.pythonhosted.org/packages/64/db/9bd6c505c95128c258a55236bfbb3a7a3fb6023f863316b6d7d9f3c69052/kiwisolver-1.5.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9b3092d8992a1d69b7a59c3e39f35e1b9be327a17f68a7c35fc17329e337d6f2", size = 66493, upload-time = "2026-08-28T10:26:23.743Z" }, + { url = "https://files.pythonhosted.org/packages/e1/f4/b3007a3ed5c9be73f81161140684cf7d9bdb9c4b632f5f484d2a1c713fb9/kiwisolver-1.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c2306e8bb53601979fcb3fa09cc65e031876d9ae01eff2fcbcd7a84ef94d5bc1", size = 64720, upload-time = "2026-08-28T10:26:24.95Z" }, + { url = "https://files.pythonhosted.org/packages/a1/13/08188f0cafa3a800403e4ff62b9aad4e7a17f9c4c7e080dc8f18c64794cf/kiwisolver-1.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:18a0cfb124546a4c2e6087c5f3029c7f44b37c85b142e0ced71f73a7599ac208", size = 1475867, upload-time = "2026-08-28T10:26:26.393Z" }, + { url = "https://files.pythonhosted.org/packages/8a/3e/053bdc3c9abdb8f2606225eda398adca25c0c91ab90add8222a69db65ee0/kiwisolver-1.5.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:34ec467940442c9943016fb2d4c81d1ba84351eeca2f1a78f8bc87f1ba0d414c", size = 1282865, upload-time = "2026-08-28T10:26:28.118Z" }, + { url = "https://files.pythonhosted.org/packages/5e/64/a44c341b36b610588cc2f1e89b3cae072a3119aa8be578e90987cd640751/kiwisolver-1.5.1-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a83ee7107df13abe42a54a6654670eef9bb39425cf2e27f65e0007465e1286ab", size = 1300865, upload-time = "2026-08-28T10:26:30.125Z" }, + { url = "https://files.pythonhosted.org/packages/60/5e/7e7d716dca38c714478b741257a5b4a321d9932b8d851551a136dcaf3984/kiwisolver-1.5.1-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bebb89489b279b2f5661bbbb2abcc87bcd4a46607bb4a5c966f04f1db6b8df9a", size = 1348071, upload-time = "2026-08-28T10:26:31.829Z" }, + { url = "https://files.pythonhosted.org/packages/10/1a/2b98fdda8bf45b7be317e48ed12393d44334394d315c74b81f4a14c0e31b/kiwisolver-1.5.1-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:509735237ae0d849e8a843551d423d2500d2e0a9ac1611a145658b29c0fb9f85", size = 992191, upload-time = "2026-08-28T10:26:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/b6/55/d893f5ede0e50f9e3fcf01f6015f42ec7d9cd221e26772701fe4a98745f9/kiwisolver-1.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:824c3d763a05ea9e9003610145186b0e9848c7584a5575c79bac5a8e7cd80bad", size = 2233854, upload-time = "2026-08-28T10:26:35.282Z" }, + { url = "https://files.pythonhosted.org/packages/b1/82/f85f6279555a6ee1639fef7bfe83adb037a03e11a6fc9eaa54b8d0380339/kiwisolver-1.5.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1fff05e239575b1481b6ed1a782f6fad616efbf1f0b1f44e6e85c4dfe426e483", size = 2330621, upload-time = "2026-08-28T10:26:36.9Z" }, + { url = "https://files.pythonhosted.org/packages/56/31/e11aea078f66fc2fffcc179d38ca90d9da97652a241b64519169742ba46a/kiwisolver-1.5.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0627b9bceb9c3cdcf12b8a18655eedfed2692b038df27423383c120d0b7dc2d6", size = 1982848, upload-time = "2026-08-28T10:26:39.01Z" }, + { url = "https://files.pythonhosted.org/packages/af/ea/2956b63bf5140ca46aa2c2818e6aa03e2d5754dd2fa41db1c6b28922940c/kiwisolver-1.5.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:8a708a47ade1fe19e8371d5da076bac0dd4b0a5a7985ad6c637f7f7e361b6baa", size = 2494850, upload-time = "2026-08-28T10:26:40.837Z" }, + { url = "https://files.pythonhosted.org/packages/11/d1/3829542258d8b3fc0898d221e7ef0e2c83eca0d348709bb8dbe54f3d4005/kiwisolver-1.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:007a5553dfc4f4e8d184f588a0200e2cd4b63a59cc8796df3c39909e679dc7a0", size = 2298067, upload-time = "2026-08-28T10:26:42.803Z" }, + { url = "https://files.pythonhosted.org/packages/4e/0e/49522e1ab5788cbaf63a26fbd3b851f9028616828c961b8a31b35cb96df8/kiwisolver-1.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:f4167e87b397f273dc2356fcf1eaf50a6bac51e6105f45103ef7129c8efb0255", size = 72282, upload-time = "2026-08-28T10:26:44.268Z" }, + { url = "https://files.pythonhosted.org/packages/f5/b6/22e7ca5315d363e6f81c9f37c9472e12e7b298731e77c0428e6a911a2c39/kiwisolver-1.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:5c490db2168a508088f59140dd392556a54b8bd1048fc6383c8baff13c359673", size = 69855, upload-time = "2026-08-28T10:26:45.725Z" }, + { url = "https://files.pythonhosted.org/packages/30/8c/03a9cfbe871964c8758a816eb03ac96c806da2795a9a7cd9bf9648bfb594/kiwisolver-1.5.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:4d4ca09bf13cff792b1884f64b98ee6c2467930d632233be25c56b442d99f10e", size = 126289, upload-time = "2026-08-28T10:26:47.023Z" }, + { url = "https://files.pythonhosted.org/packages/f2/e3/14ce3041ca79dff9c9d884ca00c7bf32374e76028a865a9ecd99b4f5a517/kiwisolver-1.5.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:44b8faef94f1857e77fa0238f3390ff1ac51d2ea20a487e2e452a59fd2b5f5ca", size = 67709, upload-time = "2026-08-28T10:26:48.268Z" }, + { url = "https://files.pythonhosted.org/packages/af/c4/45030471a66ec8ef042e9f96ffe1d522c9ab12da180186a0898966fc1385/kiwisolver-1.5.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2ae70bc59790d2af72a3f76f24b272403e135070340281108b447cb77ea70819", size = 65909, upload-time = "2026-08-28T10:26:49.523Z" }, + { url = "https://files.pythonhosted.org/packages/42/73/17dce073a6ae259bb32cf9d686c4079d2e538868bc45967462bf33df914a/kiwisolver-1.5.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:43844c1a7ad6d723d5b5b4c4fc7f5bd399c40e288120d16257c7c9e8765c6e85", size = 1584907, upload-time = "2026-08-28T10:26:50.933Z" }, + { url = "https://files.pythonhosted.org/packages/8c/84/ae3c75909f507283cbfcc7e916c7e822579ef962020b97e6882b27b4478f/kiwisolver-1.5.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22d5e5aaad6be121f2515765e3b1c444352cb8eb4c86510801db8f2e50757316", size = 1392474, upload-time = "2026-08-28T10:26:52.638Z" }, + { url = "https://files.pythonhosted.org/packages/34/31/8bcc83caad5bce8fa4577152389848bf6bc110e51e573a2b4e7c2aa34c89/kiwisolver-1.5.1-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3fa5855898f6d3d01b72ccd48a2d65cbdee301251603fefe34e2025bddba219c", size = 1405246, upload-time = "2026-08-28T10:26:54.248Z" }, + { url = "https://files.pythonhosted.org/packages/55/72/220345537d790cf4ae54f8acfff4b5cc2468e0702a384d651cf7a771c63e/kiwisolver-1.5.1-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d66a64dd5dec136040ec2ae94aa026a912ee60fdd45bc28d3db30037fd809e88", size = 1456099, upload-time = "2026-08-28T10:26:56.042Z" }, + { url = "https://files.pythonhosted.org/packages/cd/10/3725fd2398f66d18c34b4e0f81a8d03764cd4f4f089f58a527f0b4428086/kiwisolver-1.5.1-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:9e51c119992ea8820706871c30a4642ec76de20ae82f9b50b9a45517d8e9f810", size = 1073695, upload-time = "2026-08-28T10:26:57.658Z" }, + { url = "https://files.pythonhosted.org/packages/86/cb/28d6e09e66b93e4588b2e6b7d84d020ccefea09e2f4de788510a07efeab7/kiwisolver-1.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:70ed9a45c7484d2b30cdacf60d220f494a1763b9fec1ad03285c6553fa0889f2", size = 2335355, upload-time = "2026-08-28T10:26:59.202Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b3/a0f31d5e4e40af7dc97c36b8a74fdd3a36cf3c8bbd098da9a23466ff6a94/kiwisolver-1.5.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:98b208a7cc42c803445ef551d6753cc42a5ea13e9cab1ee66cd8b9cb70195330", size = 2426524, upload-time = "2026-08-28T10:27:01.181Z" }, + { url = "https://files.pythonhosted.org/packages/2f/c9/728f63bd58c72cafdc79fc306abeeac7391bec03b757a48dadeb30906521/kiwisolver-1.5.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c6834b92dd2428e2dd85ef3d85f723d3c12f20aaf43a2ddd4f944ca25d833408", size = 2063430, upload-time = "2026-08-28T10:27:03.06Z" }, + { url = "https://files.pythonhosted.org/packages/84/df/ce188b96f92f9a2c958231da140768918cba53c9713dc887b82f85462118/kiwisolver-1.5.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:5d142e352eb13facc7dd047489aebdff6ba78576c239f1ea04931979caaf0567", size = 2597513, upload-time = "2026-08-28T10:27:05.072Z" }, + { url = "https://files.pythonhosted.org/packages/69/d6/76947c8203768968382e5bd74d9cc95654746703a61ea53015f2c74a2e06/kiwisolver-1.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f9b1c4900736e489a812c529100de4b8fb617d4db075e931e213c57424b83d9b", size = 2394488, upload-time = "2026-08-28T10:27:07.423Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d4/14b21e4eb203c4d15425e8b6a2c625a320b4a1f2f7557eead63ffc30ffb7/kiwisolver-1.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:5978c3340f16a35c30f8ab2fa7bcf559973c55f1a5ef6970e1f621acf3c4db13", size = 75404, upload-time = "2026-08-28T10:27:08.892Z" }, + { url = "https://files.pythonhosted.org/packages/cb/f5/53157899fc7f45f76421b77b99eb1639dd0f83f26ff9d76300c96bb4a3b0/kiwisolver-1.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ca307d6c259e5c98d3cb9ade55342b47a6839762caf2536f3d7b46ee660cc82e", size = 72946, upload-time = "2026-08-28T10:27:10.944Z" }, + { url = "https://files.pythonhosted.org/packages/75/62/f786c3a27f181fa339d851a77e266d208e776b9883cabc40a5b041a31b5a/kiwisolver-1.5.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:bb7c99f0673c03017a3ee01e54a5c2617a05468b11eabe513b0080e063ed95b1", size = 62420, upload-time = "2026-08-28T10:27:12.308Z" }, + { url = "https://files.pythonhosted.org/packages/02/cd/58a91ed25fbad0facdf503297b03768efab04bdf3141e5e3b49a34be7443/kiwisolver-1.5.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:0d8924877ce22e17326a99a418c3c82037da078df3c6a260b13eca677444e6e7", size = 64577, upload-time = "2026-08-28T10:27:13.502Z" }, + { url = "https://files.pythonhosted.org/packages/f6/5c/d501ef5a0958b226eac28306d24d5e5f114be0ace50e19cabae7b6b3b197/kiwisolver-1.5.1-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:534f02c1abb31ed6dbd3515545285c330b2f12d00fdb1fdb71658b9ca5a13a6a", size = 66284, upload-time = "2026-08-28T10:27:14.813Z" }, + { url = "https://files.pythonhosted.org/packages/a3/a6/8fbecaf4fc18c02f31f05e47a84c010a80e3ec391ed2f0bdade1d62b5954/kiwisolver-1.5.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:cea20da04494e662b83c872683bf4ff2345206043d036315ed0e924b652e7294", size = 124031, upload-time = "2026-08-28T10:27:16.193Z" }, + { url = "https://files.pythonhosted.org/packages/7c/c6/bf3090d2983b4204347cbdbe952116e7c3b2abf62b4e33e50167a13e75ee/kiwisolver-1.5.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:7fd82debf43c6acd0a94359d232f6bb516ee13f269a7993736a9ac9f988bb5d9", size = 66489, upload-time = "2026-08-28T10:27:17.506Z" }, + { url = "https://files.pythonhosted.org/packages/85/de/562dddef55fdd7c291da8626d6619e72b5fc0870e6ccca0e149a5731e7f3/kiwisolver-1.5.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:18170a77ddfecf40ec60d0928268dc95880c881864e015a8f34094ed18b9b9ad", size = 64806, upload-time = "2026-08-28T10:27:18.673Z" }, + { url = "https://files.pythonhosted.org/packages/89/b1/ba7b9c0164ce1cf62bf2872db63b8483289cf0f3110d6f9390eb09e409ed/kiwisolver-1.5.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ca7f6fe0f37ca978a1e5eb7a3a68e6413f417e78e838324947ffd420202b198b", size = 1482211, upload-time = "2026-08-28T10:27:20.039Z" }, + { url = "https://files.pythonhosted.org/packages/13/dc/34da54efb4976616d45c20aae32d70e89d6e7395ed908029154d1609ef22/kiwisolver-1.5.1-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5b973887ff782cfd6b67c9904ad8ca542e0bc5e4961503408b423b5a688b4d38", size = 1283739, upload-time = "2026-08-28T10:27:21.82Z" }, + { url = "https://files.pythonhosted.org/packages/5a/3a/30ffb62bee646e266e98a1b5cd276d9c75b6116fbfcb87c1190838c1b6df/kiwisolver-1.5.1-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f76fc85bd054c806960f917ec0f329e24e436f1712267d90588e4c39890caa63", size = 1301681, upload-time = "2026-08-28T10:27:23.876Z" }, + { url = "https://files.pythonhosted.org/packages/a9/8d/13c70be22a8506880b35fdc38dca36629613bc493405c79f4037f2cd2bb9/kiwisolver-1.5.1-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:828f75af2b0080c8a972e75f649ab46af008e92c6104a57a759157200b835b75", size = 1349159, upload-time = "2026-08-28T10:27:25.899Z" }, + { url = "https://files.pythonhosted.org/packages/82/0e/993972b8ec6767f47cd69818fb3a5ff14510557d29f7d1a839be7574fa1b/kiwisolver-1.5.1-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:431dc224a1a92a5c8f582d96e505196a3b5997a7271076678da2dfde67b77e9a", size = 997613, upload-time = "2026-08-28T10:27:27.507Z" }, + { url = "https://files.pythonhosted.org/packages/36/82/ca26eddd2eda2420dfc56693449c1f821f78b485da9cbde9904c03af3f93/kiwisolver-1.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:61e9a64c7635095a6bfe483e2ff055d437c59bd45f3617a228b37277f0185d62", size = 2235109, upload-time = "2026-08-28T10:27:30.113Z" }, + { url = "https://files.pythonhosted.org/packages/c7/84/97d920881e10840b8d7c7185620298e3e4c88820b05514e3a15a258b08a6/kiwisolver-1.5.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:3c24cd69455e1b00ddf770c13b6e2c33e07d6dc3f2d34add0bf9277c5c6bbd46", size = 2331207, upload-time = "2026-08-28T10:27:32.795Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ce/34d74b8f25acc58800f4c09268371e8d6159cf0f1206f1e4dc7835629b48/kiwisolver-1.5.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:27add358abe374ebaa3b8763ef380bc99051b5a4b18d94878366a9e4f59efef0", size = 1986696, upload-time = "2026-08-28T10:27:34.628Z" }, + { url = "https://files.pythonhosted.org/packages/0e/01/f892644014612527aef7031d3306a2ffc60b3cb044f802c1561f8e5e14f3/kiwisolver-1.5.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:255605693a483db7bd5c79f60437f7bf658f7f520d61aa42722e32257c941951", size = 2496400, upload-time = "2026-08-28T10:27:36.818Z" }, + { url = "https://files.pythonhosted.org/packages/32/6d/d8284e66e697026536e5f418b9cfe56567bffd3c775e3ecfbae373605854/kiwisolver-1.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:7d38b0c279c3032e8c9cc013b405c6df8e1668dbf15465779aa7f15f61201812", size = 2302967, upload-time = "2026-08-28T10:27:38.803Z" }, + { url = "https://files.pythonhosted.org/packages/46/0a/69a355e27f32ba50d5b6369949b6a1702e122f5277c89bc76d452b81c1c4/kiwisolver-1.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:958254518717542d02d0688d0d20cbf771da5e415e6f49543f92481c850a4540", size = 72283, upload-time = "2026-08-28T10:27:40.491Z" }, + { url = "https://files.pythonhosted.org/packages/ef/d8/7a95be90c33dcdd52204d4aa6384d731443225b887283bbd8b61e7931f6c/kiwisolver-1.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:da3275833be0edbaf4830fae08bae3dc7219f40ce0c37eaa6c25825957e06612", size = 69860, upload-time = "2026-08-28T10:27:41.835Z" }, + { url = "https://files.pythonhosted.org/packages/31/f8/9bc493e7f5707788ba7f621902c68f82dc3a7ba03c78fbd337b026cef1ed/kiwisolver-1.5.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:470d420f98d368d6f010633a20659b544c5fdfa5329e6b70219f2ef08fd4a7ef", size = 126336, upload-time = "2026-08-28T10:27:43.379Z" }, + { url = "https://files.pythonhosted.org/packages/d1/82/3aea86b3f99712db825e9ac5631bf99571e818a8b8961ff98cebd798413e/kiwisolver-1.5.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:83f78128fa28705fa85d01c59771c72fe81c11bd0e6155edbb9f818983a7d761", size = 67698, upload-time = "2026-08-28T10:27:44.613Z" }, + { url = "https://files.pythonhosted.org/packages/84/7d/8daafc5d2e7f9c47a4f78f8865d86d2a9cf399c2a85f86c44a993594410c/kiwisolver-1.5.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:9506e892bcc3b409831d363c6f53e5985e1c8d1f6f6b0256d00358684ff85378", size = 65945, upload-time = "2026-08-28T10:27:45.932Z" }, + { url = "https://files.pythonhosted.org/packages/3d/c9/51dc974d9130da70a8c47a96160123443d387ffe1b6b833d6f91d9429339/kiwisolver-1.5.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cea90547bfd93807e0013a004dc76552be44fad3bc1cc2b38610a9e889ed098f", size = 1588030, upload-time = "2026-08-28T10:27:47.678Z" }, + { url = "https://files.pythonhosted.org/packages/e6/86/f3e1a730e7a995149d8d3ff9e313b6d8a17b2cf1d98a8eff139dc30463fb/kiwisolver-1.5.1-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e8e4d953faaded9ec7ede36824e9814082d22d4c7b1eafbfa079ecba8cd0d076", size = 1390760, upload-time = "2026-08-28T10:27:49.676Z" }, + { url = "https://files.pythonhosted.org/packages/4f/3b/8ba25a2b5a0d2375e046f1b72de5179513f0be95aba6e7b094c89303929f/kiwisolver-1.5.1-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7e9c01d3dd7ceba4d1d436cc021d40d592466e40b9bc7f5d83dc4e98a5c9cd8c", size = 1403279, upload-time = "2026-08-28T10:27:51.242Z" }, + { url = "https://files.pythonhosted.org/packages/18/d0/278d5cb8be812740027d5ca0a7eda0c375488a88d6dce0fa60fcc2591ad2/kiwisolver-1.5.1-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:37f801b5d7cc0e5a548921308e059fd2b057bb42972b591cfa3049f95423c4ed", size = 1454429, upload-time = "2026-08-28T10:27:53.213Z" }, + { url = "https://files.pythonhosted.org/packages/94/37/bcbab41063ec284c1d200efe5087cf087798c2f8916960aa8a20dd303290/kiwisolver-1.5.1-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:e68e151428b5384f766cd25739bf77c7e4a3dc93b5ded7a12118d9fbfdf78ab6", size = 1073225, upload-time = "2026-08-28T10:27:55.089Z" }, + { url = "https://files.pythonhosted.org/packages/cb/c5/ab79dcdf5ae28909a51210ae0a1c579e97ff997b3466414f0d04c0994583/kiwisolver-1.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:8f8fddb8e323bd6eee4e54e69a39243beab22689070f4c66b472c4cc88bb89d8", size = 2334335, upload-time = "2026-08-28T10:27:56.78Z" }, + { url = "https://files.pythonhosted.org/packages/8d/8e/71d047468a189041d9c93f3b76844b924f9793b188c44bd149fa258912da/kiwisolver-1.5.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:3cc210010fd2f438a3ed430b45f1b501fd13a8618bf984dc2c5ce5b69b78752e", size = 2424982, upload-time = "2026-08-28T10:27:58.639Z" }, + { url = "https://files.pythonhosted.org/packages/7d/37/1347461bbea6d0e1f0580b94ef603b18e72c2be5f667fa1653867361a00b/kiwisolver-1.5.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:b5664603a253efd3a75716d793d1d3a6a82723b61dc6db767b2460bbbeec4c0f", size = 2062857, upload-time = "2026-08-28T10:28:00.407Z" }, + { url = "https://files.pythonhosted.org/packages/1f/87/c7f0976c9cd0d127643351bc0c9929e0b8899d7f49d4ec238cd909e39c42/kiwisolver-1.5.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:a7b85b2cc6ea45e5f7e8c9a30bc9fabd47cda09106cbb4b967335c3e6c43b69d", size = 2596022, upload-time = "2026-08-28T10:28:02.183Z" }, + { url = "https://files.pythonhosted.org/packages/e3/9a/59a6f6ae6f938c15076be2c21b6cedea973d71bb1349ec84fa485fab82cf/kiwisolver-1.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:ab620eb663952455271ac37f9aaad86b73c969c02f11f53cea405b38e96a4300", size = 2395634, upload-time = "2026-08-28T10:28:03.977Z" }, + { url = "https://files.pythonhosted.org/packages/e9/2d/8982c1fb7926da5bb7ed60318c3665b5c3f941447271ac982960a11b8637/kiwisolver-1.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:cb6fae641357ed2f6e533c0d3c6504a4a5703621a50c89459e46051d56b61140", size = 75379, upload-time = "2026-08-28T10:28:06.307Z" }, + { url = "https://files.pythonhosted.org/packages/07/78/ba7b6dfa1708b82b373ac056928a30c545d5c1a627df9839dcec3c6c1881/kiwisolver-1.5.1-cp315-cp315t-win_arm64.whl", hash = "sha256:b390aec180a7c054919c04898835e1c77bced23ea8383eb2c570213bf25d1a86", size = 73011, upload-time = "2026-08-28T10:28:07.578Z" }, + { url = "https://files.pythonhosted.org/packages/a9/c4/1407df7512a5b36cc79840e01710dc575733c461b13ab866cae77eaf87f3/kiwisolver-1.5.1-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:482676e5bd48d70ac99d9fc78863469845421e01184fa83f1f9366dc49f7e974", size = 134002, upload-time = "2026-08-28T10:28:08.881Z" }, + { url = "https://files.pythonhosted.org/packages/16/45/c37a21ad5c0ab581a93c55ad544721aaa1f0ae94edb29c6a678a23d013e6/kiwisolver-1.5.1-graalpy312-graalpy250_312_native-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:072bdb15a3c19a5b5dbc8f8fb1f4e1884bf4f3507eeb4cc6334401274d37a5c0", size = 194292, upload-time = "2026-08-28T10:28:11.06Z" }, + { url = "https://files.pythonhosted.org/packages/a6/c1/69f00d627949580e43d57af0aa465df46868d7c29801c137a55374101294/kiwisolver-1.5.1-graalpy312-graalpy250_312_native-win_amd64.whl", hash = "sha256:a5a00665d1a0e26763a7338d7e911d4598fbc1d50dd0d6b7919b7dc6c5d6569f", size = 73362, upload-time = "2026-08-28T10:28:12.449Z" }, + { url = "https://files.pythonhosted.org/packages/b5/1d/59ba570b1774e95e97fde3a0981b2e22118a7a495f73bf74cedc538566a0/kiwisolver-1.5.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:416ba7ff9f233b7036689bb5a3783537e838ad483f63558d2a800f75afe738b1", size = 59450, upload-time = "2026-08-28T10:28:20.383Z" }, + { url = "https://files.pythonhosted.org/packages/22/98/a6849f04dc18b5400e8b98affa2cd8fd86ed583085f036e57b32e571f4fa/kiwisolver-1.5.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:8af9b142ad719ae3a911ebf616bc4b78b32bbab84d6a40d3ad2f129670509957", size = 57400, upload-time = "2026-08-28T10:28:21.632Z" }, + { url = "https://files.pythonhosted.org/packages/f4/ce/a7dc71353dd06a4cbe02222773f52d4a28c81e5a452a75797f8ed113dc99/kiwisolver-1.5.1-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5daa1f19e097050b9c4d9a78fcc9263cb96c9dfae08037ddc1b7c4ad1889f2a2", size = 79891, upload-time = "2026-08-28T10:28:22.936Z" }, + { url = "https://files.pythonhosted.org/packages/10/b1/d61c61a84ff85d1a36a99df2c152b59ffedb1d356c598902aba44abcdb60/kiwisolver-1.5.1-pp311-pypy311_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cdaeeb6c350106df6bf9d873395973e5f066a9713200b72cd64f55d0a3eafab6", size = 77605, upload-time = "2026-08-28T10:28:24.322Z" }, + { url = "https://files.pythonhosted.org/packages/d3/52/5aef56f21a460a6e43ab3cdfc7697d59d7b87deb0ec97a0f7b91aa4a521b/kiwisolver-1.5.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:17851e5dad4484be0cbccbde3b15331deae036de9aebd45eed964487802b172f", size = 98465, upload-time = "2026-08-28T10:28:25.696Z" }, ] [[package]] @@ -672,7 +709,7 @@ dependencies = [ { name = "fonttools" }, { name = "kiwisolver" }, { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, - { name = "numpy", version = "2.5.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "packaging" }, { name = "pillow" }, { name = "pyparsing" }, @@ -812,16 +849,16 @@ python = [ [[package]] name = "mkdocstrings-python" -version = "2.0.7" +version = "2.0.8" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "griffelib" }, { name = "mkdocs-autorefs" }, { name = "mkdocstrings" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/90/5d/1be1c7a49d8fa13dc80f66a85f53333d52cf5206911412006ffdff8fb9a0/mkdocstrings_python-2.0.7.tar.gz", hash = "sha256:8c49faf66d243072d7590a1b5dea028d9d7425fac191f54f096123a4a9c1a783", size = 201598, upload-time = "2026-08-17T16:56:18.239Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c5/65/0db919b7c91bf033b42d04f15ff16a56f4edd6afb26e84d448359660ece3/mkdocstrings_python-2.0.8.tar.gz", hash = "sha256:34545b54681cf0b212aab95394d37b0f5326a93b102f6ad1df3d965c79384c9d", size = 201771, upload-time = "2026-08-31T20:16:06.698Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b2/6d/77546d8c26f038fce314a507106954f76270f6c182488bcf9ac9721175df/mkdocstrings_python-2.0.7-py3-none-any.whl", hash = "sha256:1fce5fbfe4ffa6e8136a35351cdc97c3bf55219c7efbd3f92a82260f93235d60", size = 105387, upload-time = "2026-08-17T16:56:16.813Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b7/a5a854d4e44853f26decc0c5e0d68df052399f3da344a313da4178f00816/mkdocstrings_python-2.0.8-py3-none-any.whl", hash = "sha256:e555d9ef53b0febdeae4dd603144d4f86ec90fef672276645ea3bb61232c3d24", size = 105443, upload-time = "2026-08-31T20:16:05.25Z" }, ] [[package]] @@ -910,7 +947,7 @@ wheels = [ [[package]] name = "numpy" -version = "2.5.2" +version = "2.5.3" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'win32'", @@ -920,73 +957,73 @@ resolution-markers = [ "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] -sdist = { url = "https://files.pythonhosted.org/packages/9a/80/db0b4559e57ec36362bedbb05530a87fafbcb6067708c946967a41d449e7/numpy-2.5.2.tar.gz", hash = "sha256:d482d171c406ae88c5b19cad3b6a1c4c5209f886ab74bc44c2c865c23f52d860", size = 20773161, upload-time = "2026-08-09T13:48:27.962Z" } +sdist = { url = "https://files.pythonhosted.org/packages/13/01/11703282db468b85f6f7b8c7f22d058de5970d5c7e60a3a8aaa313c3de36/numpy-2.5.3.tar.gz", hash = "sha256:df2d5874ff183595a4ba404edd04f6bd9b5505c1d7708573f6a6c17489a67563", size = 20791231, upload-time = "2026-09-06T16:27:47.073Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/69/72/dccb0aaf40972777283303919f613964227266d0c13adebb79ac124f1c3e/numpy-2.5.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:14e373cfc6387177e8409dac3c7159be8eb05cd77096cd7c950268b86f62831c", size = 16891693, upload-time = "2026-08-09T13:44:51.702Z" }, - { url = "https://files.pythonhosted.org/packages/60/2e/b5aee50a1f74ac815cf8331812cb8251e29024025de462e0c047641c614c/numpy-2.5.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4bbd96c833ecc8cc069ce518078fc8c60cb9cbfb0fea5b7a803ad65035596d03", size = 11903109, upload-time = "2026-08-09T13:44:55.501Z" }, - { url = "https://files.pythonhosted.org/packages/f3/f4/29e78102a80601cf034d4e9767022cffeca2c3b4c926e1754572ca95593d/numpy-2.5.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:6e8172ddfcf5cf74b811d372b570b83c60bd2de87a6fbfbebdadb4a9bd9c6cbb", size = 5350202, upload-time = "2026-08-09T13:44:58.401Z" }, - { url = "https://files.pythonhosted.org/packages/11/4b/dcd3b7eadaf4035d2c7a4289d232523a6964f602598ef7674e4bd7291f93/numpy-2.5.2-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:65f188481f1669e26f62b701e8205d19e460fa4a9b52a1414ba382330e4a3414", size = 6687736, upload-time = "2026-08-09T13:45:00.813Z" }, - { url = "https://files.pythonhosted.org/packages/e5/21/4947e0e9d6c9fc2e2ff15b8949049ee44f63adb9cacc729ab8793f97e712/numpy-2.5.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8ee9c4eeb8454b3660a8b53493563c3e121c2fc94fbd72b848ef814ed7b676a9", size = 15612696, upload-time = "2026-08-09T13:45:04.151Z" }, - { url = "https://files.pythonhosted.org/packages/3a/5f/62d28cf019460c7f1394105b4d49d9911a9c444cb77ab0bd95a204c5a6de/numpy-2.5.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3cdec01fa790a186d430433fdd4d4ffb70eed6f0eeb4bf05c8dbe2dce0a9bcb8", size = 16722264, upload-time = "2026-08-09T13:45:07.714Z" }, - { url = "https://files.pythonhosted.org/packages/14/25/3f0be4c1b9fdf5dd5e708a6806978564d7c46a055c000496309ff2a2f8af/numpy-2.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7999d4ddb0c4025018373fd787510d46e04c769467af22869707b3c1cfd459ab", size = 16974396, upload-time = "2026-08-09T13:45:11.316Z" }, - { url = "https://files.pythonhosted.org/packages/22/72/6262cbdeeb45da9d971e40715f579d791603ba8ec0b5e2db1ac55454421d/numpy-2.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c1f017dc0875c9209d219f97feceb7d54c2661bb243deb4114478e1295808af7", size = 18476044, upload-time = "2026-08-09T13:45:14.869Z" }, - { url = "https://files.pythonhosted.org/packages/36/33/29208b8b075bde62d26a81d14b358c42b0f69b6cabd98d4ff97f37f22b05/numpy-2.5.2-cp312-cp312-win32.whl", hash = "sha256:d6a48072864e3324e194a8fbb3c657bcc5b5c869dbc64c9537b1d5c862572c0a", size = 6072817, upload-time = "2026-08-09T13:45:17.867Z" }, - { url = "https://files.pythonhosted.org/packages/7f/b9/87fea2769fe1c47c1b5b01d8310772c9d1a85d485de7cf386ef7a3332b02/numpy-2.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:28ac63476ec7651484215ee7fa15a1f78b57c14621f01e392afe17b9a1390ce4", size = 12464674, upload-time = "2026-08-09T13:45:20.734Z" }, - { url = "https://files.pythonhosted.org/packages/14/52/032b97e00461ab0809bbe4c588b035620e5a14b8cdee47ecddefc7b17d33/numpy-2.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:27650bb0e7140fa3d37b9923b4803645e0b125d190f326eecfd3f4dad8e8ade1", size = 10397131, upload-time = "2026-08-09T13:45:23.73Z" }, - { url = "https://files.pythonhosted.org/packages/f5/d2/6b24738a0ef4557d189b150046cd07823c50e4273e8aebd651222e24306f/numpy-2.5.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8e4cb9a754c8a0c62eaa88273a5fba3391f4a610d1dee893c0755da31c083f15", size = 16886595, upload-time = "2026-08-09T13:45:27.323Z" }, - { url = "https://files.pythonhosted.org/packages/65/60/f2d208d366f263f39c6e69ed309290717aab41078b6d04c9be2a84fa2a07/numpy-2.5.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:52c808f96484f5571a5cc863775ce50247c17dfb3b0361f8ed6b4b0456f80080", size = 11896845, upload-time = "2026-08-09T13:45:31.638Z" }, - { url = "https://files.pythonhosted.org/packages/3c/79/81e0bf24f4d020a2b1d5cd297a9f60c3f24eeb116f9bba5870443f7b6a4a/numpy-2.5.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:29d81e97f668489cba8ebfd796b9bdd453525d35dd9e162e2daec94bf3fc7740", size = 5343880, upload-time = "2026-08-09T13:45:34.373Z" }, - { url = "https://files.pythonhosted.org/packages/ba/cc/e3141cf06d1a8a2c7e107543fe1269c1d1af760d4d683c0794a4ee1127c2/numpy-2.5.2-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:afb3f0632d6b2e3ba04dbce8d1e48d321b369138b73830b5ca371a0e8d479d56", size = 6682264, upload-time = "2026-08-09T13:45:36.7Z" }, - { url = "https://files.pythonhosted.org/packages/29/f1/2a64a307d92c5d98f5255a4014eb43bb6103ee477087b61ecae44a3aa9b9/numpy-2.5.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0aadf13b60048d501e05fa699efaf7734e2494f3498a4c2a5521d822640324f3", size = 15609566, upload-time = "2026-08-09T13:45:39.518Z" }, - { url = "https://files.pythonhosted.org/packages/7b/44/59a1eb68e773c4098d107ef34a0dbdeca501d72ffcfbff9a7707343921ce/numpy-2.5.2-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29b86ff8a6cc556b47ec6b64b194815cc80e6bf5eedcc6cddfd65318cb0b4eee", size = 16709995, upload-time = "2026-08-09T13:45:43.661Z" }, - { url = "https://files.pythonhosted.org/packages/8a/4c/3e54d4ddbc359a1295f8b633e8106bcd4d7d4a206e82df051bdfb3058755/numpy-2.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6950c4b7dd562453090548ba7f5da7e59f57f85663f15d5dcc60e249192f7e59", size = 16972511, upload-time = "2026-08-09T13:45:47.094Z" }, - { url = "https://files.pythonhosted.org/packages/f2/9f/02e371638ebf19b66d46231e4be52999e87f32d1961b113bc45656608b22/numpy-2.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b9727f472d2f3888053b8a75ab0cb94745a9de224bb5846dbadc0092101bc71d", size = 18465609, upload-time = "2026-08-09T13:45:50.808Z" }, - { url = "https://files.pythonhosted.org/packages/eb/ae/ad6645abc7a3510fe48e8ea1ab4598166f500057ef4ebf38bfad4f1577de/numpy-2.5.2-cp313-cp313-win32.whl", hash = "sha256:4f9744f9fbdcea0bc552e8f19e1f141f811a3f9bc2be2cc6e86d982cab23e3f4", size = 6070204, upload-time = "2026-08-09T13:45:54.111Z" }, - { url = "https://files.pythonhosted.org/packages/15/20/f3489f86d81ea460b2bcdceaed094142ca6579f6be0ec527b781d39afe68/numpy-2.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:85aaccb24182c25df891ad0ec333585967e115269d5f1b17f2c9ae005bc96657", size = 12460532, upload-time = "2026-08-09T13:45:57.167Z" }, - { url = "https://files.pythonhosted.org/packages/d5/21/35b31dde1b283b79de828b80f876afd8c94e28fe1e9c375f89e261cc4c0d/numpy-2.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:bd68ece1553d2023c09a4226d9e41c586ad2d20594d1a456186c33513d2cb3f2", size = 10396725, upload-time = "2026-08-09T13:46:00.478Z" }, - { url = "https://files.pythonhosted.org/packages/ac/f8/c3b222bf075b50afd8e949a07a15c4b312a4a84bd8102a332bcd953cbbb4/numpy-2.5.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d787cf769c3baeb5f6235e778edb52c08dfa923789b5958f28e6450f96107cb1", size = 16885180, upload-time = "2026-08-09T13:46:03.939Z" }, - { url = "https://files.pythonhosted.org/packages/17/e1/2c1d4b1987795a92b5bbf7c24fe249ab96aa2573ab0d7604802c189d7b86/numpy-2.5.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:24b9dc2e3d84aa58523798805194e23e736f3f6ce2d1a5b92583ae734e6dbda8", size = 11907878, upload-time = "2026-08-09T13:46:07.045Z" }, - { url = "https://files.pythonhosted.org/packages/b9/ee/d08226fc858044355983a6e5b94f08ff6f3969e0a2b160a4a89f0ddb3445/numpy-2.5.2-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:9e9413326d726c2545bfa65d2c0876871e8d8386e77f992c1d426e180bbd4323", size = 5354922, upload-time = "2026-08-09T13:46:10.04Z" }, - { url = "https://files.pythonhosted.org/packages/94/f0/6d3d933056440ebbc5e6bad92065fc6c26a48a84a36b1208580e94eea76c/numpy-2.5.2-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:60e902ac295855348a5ca2ea4c89108989a9f5fddfad3dfc0a8f36b10358567e", size = 6679168, upload-time = "2026-08-09T13:46:12.275Z" }, - { url = "https://files.pythonhosted.org/packages/c4/3b/ecd49dd90033cceb2704d88ca905d4d7d89b0e8c739608754ffd325fa820/numpy-2.5.2-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50e500dc868e9313530ce12ba470fe50ff3afe3d62993ed6eff652dacd555b65", size = 15624501, upload-time = "2026-08-09T13:46:15.322Z" }, - { url = "https://files.pythonhosted.org/packages/c7/99/461bd36dbdfac6c1c53efa370bd55a83227542d0d118f1677dbf1a3dacd5/numpy-2.5.2-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:318b9a4c845dbea06708a29c84ee429cc3065048db34cdb799047643492050ee", size = 16713701, upload-time = "2026-08-09T13:46:18.949Z" }, - { url = "https://files.pythonhosted.org/packages/f9/9c/2b251df9e8a5d647b62b0cbc1b90a91850c1cf4859ecb532fd0b4eacff6c/numpy-2.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:34c319e2963be042673fb46570501b2f06c41924e17e3563d58646b4380dfb68", size = 16986065, upload-time = "2026-08-09T13:46:23.006Z" }, - { url = "https://files.pythonhosted.org/packages/8f/25/20de43f53ff1390534a124475055a19f01fe10c920a0fd11b8e18d6d6052/numpy-2.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f06571a052127dc1b4e8b83029b4d1b20daa2b64a31cdd181fc6bc774e9000eb", size = 18470031, upload-time = "2026-08-09T13:46:27.102Z" }, - { url = "https://files.pythonhosted.org/packages/56/5e/0c577ca308d6da5eb79b546ba10bbe5b60148192194e2da060913b1de4f1/numpy-2.5.2-cp314-cp314-win32.whl", hash = "sha256:2cc779226e476d1e1f08c74068c419e60f41a9e0e069c92f6671d31d5c985e98", size = 6121028, upload-time = "2026-08-09T13:46:30.046Z" }, - { url = "https://files.pythonhosted.org/packages/15/5c/7bcbd5b11f94199073320410cddcbb80cee62415bfeb540874b265c2d922/numpy-2.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:7587f53dfbd5edc0f7b87c6217b4c6d2d1f2ef9c3da70bc1315e7db5f8d7ec9d", size = 12597627, upload-time = "2026-08-09T13:46:32.886Z" }, - { url = "https://files.pythonhosted.org/packages/87/bc/4d0b06fba0da90ccc75af62823cb9dcedb6c9ea0cffa058cb2c9ee773a77/numpy-2.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:3e4c367352d3747784248a227fbec218e193b56f7e6692e3b64fc805478ecfdf", size = 10680414, upload-time = "2026-08-09T13:46:36.036Z" }, - { url = "https://files.pythonhosted.org/packages/cd/17/f429aac9dc08833a0d0f188eba38c532a751b1a1f2ca6018a37b455cb321/numpy-2.5.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b879fb674276e331513fb136b78dbc6bd3c848309e0d841cfd63be3896c4cfc1", size = 12026967, upload-time = "2026-08-09T13:46:39.084Z" }, - { url = "https://files.pythonhosted.org/packages/ca/9f/d0849de96a2a4ceaa16662f18ee13eaa9c0aa418269fdc8c4857c56b11da/numpy-2.5.2-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:fd0d703772bba096843785bd38371e31bb4a0c1151497ad5739d182114a73f7f", size = 5473874, upload-time = "2026-08-09T13:46:42.075Z" }, - { url = "https://files.pythonhosted.org/packages/89/3c/8df216d4a4a5422a3de045301cf7df8ea47286d76f5cb7160b0128ac26b7/numpy-2.5.2-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:3a2f061cebd9e3d23bdcfaaded5e2293a4c6a5b60fa42df85d410a725ce621bf", size = 6789276, upload-time = "2026-08-09T13:46:44.387Z" }, - { url = "https://files.pythonhosted.org/packages/e6/3a/20d7e9891c4ddfadd6ff8d95bf4b29f353d8e1770553de2099880551dfb9/numpy-2.5.2-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6df895598c0edcb41030126c89e0f353b07d93238116143b7405e937359736c4", size = 15659154, upload-time = "2026-08-09T13:46:47.538Z" }, - { url = "https://files.pythonhosted.org/packages/aa/d6/f3aa3d2688bf501b858835c6bd087ae9b51a56ae6fca8e2b0990abd177af/numpy-2.5.2-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1ab3d4a901f844ea836c3e80bf463c6a27d7f3c14e8e292fcf28d348b25b9bce", size = 16748909, upload-time = "2026-08-09T13:46:51.442Z" }, - { url = "https://files.pythonhosted.org/packages/7d/8f/1c5cae8d2baf86ab802ae97a00be55bc7e21ebc11b12bbc33376c5f05342/numpy-2.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:cebc2d6dbb605a7703d59751dea4bd6b0ab127a5a4338a6f432df1936fef8b26", size = 17027685, upload-time = "2026-08-09T13:46:55.095Z" }, - { url = "https://files.pythonhosted.org/packages/5c/27/71d3467404aedc1c24ce79610f91b52b0b0f466c43a701aa56fc75c145ab/numpy-2.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:eaca7ff36f0f52e2111ec71f169d8fd3e889e7ddc0d2592e0d703fd8d3ce8fac", size = 18501181, upload-time = "2026-08-09T13:46:59.09Z" }, - { url = "https://files.pythonhosted.org/packages/14/2f/42921d27c40aea7e077f4a423ae509fd9220b028cd787bafefd8ab2b3a5f/numpy-2.5.2-cp314-cp314t-win32.whl", hash = "sha256:ddf47472af2e4280d79bac82304f5e80150211f1b9e614b760061d5fdfbb6eba", size = 6271085, upload-time = "2026-08-09T13:47:01.903Z" }, - { url = "https://files.pythonhosted.org/packages/75/e6/bad5f5d56de9b1971bac959963dda276d35c40f1854475005434bbe08692/numpy-2.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:44ef9675d908e65f9953063837c3277730f3f4437615a4cdab67b366cabaf884", size = 12787971, upload-time = "2026-08-09T13:47:04.963Z" }, - { url = "https://files.pythonhosted.org/packages/df/05/f608795cb34391acd67e38d94a3c36abd8d8576293a3a80727d7595c372c/numpy-2.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:eaa088384c46f519dacb93b7ec483a6d6b19a4a2085ae4f25ab9b1c43d387d1e", size = 10750306, upload-time = "2026-08-09T13:47:07.976Z" }, - { url = "https://files.pythonhosted.org/packages/33/c6/28de0191c5f82b7d42a0a51390ba98587048aa93a39fafb05bdbe6e8d00c/numpy-2.5.2-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:078f9b027b478c9379b9677babbf0f8b8f1ecfada27636d7b9a93990c638739f", size = 16885274, upload-time = "2026-08-09T13:47:11.439Z" }, - { url = "https://files.pythonhosted.org/packages/dd/d1/973ca116000d244897e468ea1aff30b589e5022e3c8744b71706fe33bd57/numpy-2.5.2-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:50a68f4bacd8a2b33d8da3d2269d0d78500f86ea582e4786dc10f5ef2c2c6842", size = 11907846, upload-time = "2026-08-09T13:47:15.128Z" }, - { url = "https://files.pythonhosted.org/packages/78/d9/8c4b3937ef204cb2fd88d389ccd0f265a2ffb11f35a01d2064cf46714bd6/numpy-2.5.2-cp315-cp315-macosx_14_0_arm64.whl", hash = "sha256:e79aba74ffaf5f78a050d777c184cddf8fdffabab38acf5f3ef1fecbc17895d6", size = 5354892, upload-time = "2026-08-09T13:47:18.07Z" }, - { url = "https://files.pythonhosted.org/packages/74/9b/b6ee65ea2999fdb7023935e108e6fb776ee4082aa15f159acfa857e578c8/numpy-2.5.2-cp315-cp315-macosx_14_0_x86_64.whl", hash = "sha256:9a0731745a72a184490a582fb4af2533512bd071ace67785b5fdffc0ae58dce8", size = 6679309, upload-time = "2026-08-09T13:47:20.456Z" }, - { url = "https://files.pythonhosted.org/packages/43/f3/acb18d8b137a393c8e7803a8c994c9e64bde3930692a69d826993113a159/numpy-2.5.2-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4ec954036759bcee3aa484f8603bd9c14f3e776293b85578b8734c2d72777c69", size = 15625850, upload-time = "2026-08-09T13:47:24.365Z" }, - { url = "https://files.pythonhosted.org/packages/a9/bf/a8e9bb0db815a0e265b5744ebedd3af0bd5faad8604e5b50a1cd012f3c91/numpy-2.5.2-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc649493697006bc90614a5f0bbc8cb3cb1866715c474e473694968d7e6b99ab", size = 16713664, upload-time = "2026-08-09T13:47:27.965Z" }, - { url = "https://files.pythonhosted.org/packages/0c/c3/6e913736b3dd6582344af32418b5fb9dab34282e8a8174ae1d54ceb0fc13/numpy-2.5.2-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:cf7de32f486e4ac9e2d93b810f9e9ac72a728dd46a32a0bb403222f27f653514", size = 16986749, upload-time = "2026-08-09T13:47:31.541Z" }, - { url = "https://files.pythonhosted.org/packages/80/09/7d3b23eff5c7428ef6c01e6f7052bb60d504c4d33e317b36b8959c24ad97/numpy-2.5.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2ffa7bacab3e2ee1b19ed31766bb60bb380b68c23f051e199c5cc598afd68710", size = 18470495, upload-time = "2026-08-09T13:47:35.364Z" }, - { url = "https://files.pythonhosted.org/packages/a5/a4/68a321d825374f6eb677ffe8ef8c6b9a328304e6fd2e39d9530822776607/numpy-2.5.2-cp315-cp315-win32.whl", hash = "sha256:6b588cc8f902d6bff201c19fd00c43ab8545671e3554d014e12e14139e5e8617", size = 6120696, upload-time = "2026-08-09T13:47:38.561Z" }, - { url = "https://files.pythonhosted.org/packages/c8/23/deafbb1700f79fae9cd1e91220f133d124cc267de1b584da3fbf6db2f6cd/numpy-2.5.2-cp315-cp315-win_amd64.whl", hash = "sha256:07d4e89f3a9ab0a9ba24264ccdb642b3dd951b2281e8883a5481a4aa79cc31a7", size = 12597324, upload-time = "2026-08-09T13:47:41.401Z" }, - { url = "https://files.pythonhosted.org/packages/33/cd/3272ba105e3bbbdaeb11357eda31e7a6825ffe159e8171665660299a948f/numpy-2.5.2-cp315-cp315-win_arm64.whl", hash = "sha256:a610dc7e3c52edd39c2bc2375ff9c3fd59cb3ad00e4472d36f83bc1457145788", size = 10680466, upload-time = "2026-08-09T13:47:44.873Z" }, - { url = "https://files.pythonhosted.org/packages/0e/0e/58370637b1bb70a5c9ce2b43f4b521ccb224e36ccb76a6596b17ae4b447c/numpy-2.5.2-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:40f4d451aed46a8046a1aae41c4e55fb3612273df9c502480135e1501576a34b", size = 16993947, upload-time = "2026-08-09T13:47:48.97Z" }, - { url = "https://files.pythonhosted.org/packages/10/93/2abcb807712b289d6d60fe4cf30532f98974a8396d885650f3ba5a13026e/numpy-2.5.2-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:c081cbe16ba1ab53078e5ff29013621e33c509eedab055775d956427712c236e", size = 12025331, upload-time = "2026-08-09T13:47:52.646Z" }, - { url = "https://files.pythonhosted.org/packages/8b/3a/2898e003a5fbaf87e76c039b4ee1f5eb390471b4ffe74887c1f34c4e791e/numpy-2.5.2-cp315-cp315t-macosx_14_0_arm64.whl", hash = "sha256:0090ccdd57ec2703e9b49d0bf554767370581c1dd0a6b2bb2b2d9def317d042a", size = 5472336, upload-time = "2026-08-09T13:47:55.403Z" }, - { url = "https://files.pythonhosted.org/packages/61/a5/23f69d07c544597b29758b31b55c27dc9d541012a2c1496189fef702aec2/numpy-2.5.2-cp315-cp315t-macosx_14_0_x86_64.whl", hash = "sha256:6a9bb119fb8dd21ba30b3f0e555b7e2b081bd9883af21ec9c1c633d161cda3a8", size = 6788387, upload-time = "2026-08-09T13:47:58.192Z" }, - { url = "https://files.pythonhosted.org/packages/15/ea/c0dbdbcf22f43782510a3e492dd3da73c6112b69cac8929d16d127536fc4/numpy-2.5.2-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a839318485284a6fb31be4f8f2c91c8f2cb22f4543c4a8903f12b0671ffe07cc", size = 15667096, upload-time = "2026-08-09T13:48:01.562Z" }, - { url = "https://files.pythonhosted.org/packages/fc/5e/29c73c31748cdb0f7566642125ba17fd5b56780cddf891b085dab27e4466/numpy-2.5.2-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba0a474801b8dc67b66bf465548abc90e82b44d2611b5770f33008dcabffe8ec", size = 16751730, upload-time = "2026-08-09T13:48:05.706Z" }, - { url = "https://files.pythonhosted.org/packages/47/95/02501e8454796bb58dadf7a99d3181e0b464bf264e1003039572f9779fac/numpy-2.5.2-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:0a4035ae1129ff8777f08bfbd44f1e5d8e9c049ce0c2dd78fc0d92c13e7251c0", size = 17038686, upload-time = "2026-08-09T13:48:09.627Z" }, - { url = "https://files.pythonhosted.org/packages/0e/b5/53a681d91b5c82687067d8ea5035e02d917b5509d6f334cb06484a954714/numpy-2.5.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:77843ca236b777e67f8d6b3660ea116e499612703a0ecd7093f316201eb9d8e2", size = 18507727, upload-time = "2026-08-09T13:48:13.744Z" }, - { url = "https://files.pythonhosted.org/packages/42/06/6e11443f7b64ee376c860506091103bf68f92d2cab9e8d96d4501babf07c/numpy-2.5.2-cp315-cp315t-win32.whl", hash = "sha256:7354826bc6f8f69402e9b7fe28d15fcd34feebd74f856f111585c5b0c9fb0251", size = 6269775, upload-time = "2026-08-09T13:48:17.543Z" }, - { url = "https://files.pythonhosted.org/packages/f1/18/195d6b86cd72dbbc501edfa778005fa6b87afd34c153e46028cd3a0938f4/numpy-2.5.2-cp315-cp315t-win_amd64.whl", hash = "sha256:e5651f3f87add730ee6608d915009e19c911fba0cb000c7e3ea994b7d768eb12", size = 12782559, upload-time = "2026-08-09T13:48:21.023Z" }, - { url = "https://files.pythonhosted.org/packages/b4/07/458c344f0f0c178f4481dad5cca790626ffe4c34eabf9467069d06ee4999/numpy-2.5.2-cp315-cp315t-win_arm64.whl", hash = "sha256:5f8e00be2ec6f45f4e8a41a527f68d44a7d96fee92a650e4d8b1326f77f61e6e", size = 10748103, upload-time = "2026-08-09T13:48:24.21Z" }, + { url = "https://files.pythonhosted.org/packages/d6/50/8fdbb16af64895706a45f06a4068e29db732ec180f3c1375f14123359138/numpy-2.5.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cb189f09db39283b26bfd061ec16189e14f71c6755207f72a0f7540867afe5b9", size = 16994982, upload-time = "2026-09-06T16:24:29.244Z" }, + { url = "https://files.pythonhosted.org/packages/60/39/789131c1188c078dcb3a1692e72e1e050c68b88ffe72c9ccaac9bcd7a9cd/numpy-2.5.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f59a878c33d6b88122d80d239bb3b845d58708750b0cb06a09aebb9b18ec696c", size = 12009327, upload-time = "2026-09-06T16:24:32.491Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/a312e95696e5f601914dd8b6dd844692ba61670807417e24b68e337b5c70/numpy-2.5.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:a72f874bc9e10e4b8f80426fb49716d5141f64442a0c8418065093ec8017fbb0", size = 5445405, upload-time = "2026-09-06T16:24:35.071Z" }, + { url = "https://files.pythonhosted.org/packages/30/d0/5623a1707ed4fe16e3909fe3cf5ee3da004ae677ad23d83bbf3adf1a6faf/numpy-2.5.3-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:fc36dc566135b5eceec4cf89758fcb719266a019ef07dae1754ae7c9f617ef3e", size = 6783213, upload-time = "2026-09-06T16:24:37.253Z" }, + { url = "https://files.pythonhosted.org/packages/f1/32/84146fc020ad3c25f805f70ab60da46fe3c540a21369754a7e4369754b6f/numpy-2.5.3-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76c2c1e6bfa5c84adc6434dfbf013aa92096a7985221762c8f11fedfd20fff58", size = 15687872, upload-time = "2026-09-06T16:24:39.751Z" }, + { url = "https://files.pythonhosted.org/packages/65/af/aa78d1a88805456e212b65461354cd943197fb9acecc4c90fd12295123a3/numpy-2.5.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7e18c623bb5c95acb3b3328861272816ba199fb531921c5d6d0b675f1fde9e3", size = 16717410, upload-time = "2026-09-06T16:24:42.745Z" }, + { url = "https://files.pythonhosted.org/packages/3b/24/faa79d865e69a97ba17473b23a1b74094b2259c03e820c70297293b9ea49/numpy-2.5.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4f8929ee6c96bfbd7b4ed2032e0c03af86fe1826740ab61ddabf9072d06e57ff", size = 17040975, upload-time = "2026-09-06T16:24:45.961Z" }, + { url = "https://files.pythonhosted.org/packages/62/4a/8877e629445a7176297dffcaf9c485faa96a95d81728a62521ad55bd4c0f/numpy-2.5.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b5d93cf48f687479941d12b69c873ad2cc76bbd487f0091c2200636497f34034", size = 18476479, upload-time = "2026-09-06T16:24:49.35Z" }, + { url = "https://files.pythonhosted.org/packages/c8/db/35e1c2d38b04cbd5b731f9d71495e055e813197669d22b612f11748d2ff9/numpy-2.5.3-cp312-cp312-win32.whl", hash = "sha256:bf63afbe037eb5d2fe87fbcc7778e61da53ebaf21d938a4515aa73b62532a5d4", size = 6133378, upload-time = "2026-09-06T16:24:51.915Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/accf6d4f0c80c5d9ba9735d6b1550e444180599f34dec69ca01360f717ad/numpy-2.5.3-cp312-cp312-win_amd64.whl", hash = "sha256:0a59a421a32580a009e8a1751345bf829631b990dc1794b80514ab722b435def", size = 12567828, upload-time = "2026-09-06T16:24:54.255Z" }, + { url = "https://files.pythonhosted.org/packages/22/43/1764aff32e4652526ae2f71fa8b3efd8d25c8a3d6926914454e47138ed1e/numpy-2.5.3-cp312-cp312-win_arm64.whl", hash = "sha256:ccb32e0525d29e8b0572eb84c9a57af0e7a4e615726927506f55063c62414034", size = 10485432, upload-time = "2026-09-06T16:24:57.278Z" }, + { url = "https://files.pythonhosted.org/packages/79/e5/8fb89cd46d14e35699d13bf943a5f5f441ecee8667120a1f6105ab89e349/numpy-2.5.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:66a78fe4556c60aceda5916f9eacd638b18e9e681016ec302dcb4682d6d4d034", size = 16991061, upload-time = "2026-09-06T16:25:00.411Z" }, + { url = "https://files.pythonhosted.org/packages/2f/06/9dc9e48b5e5e941c8b10350c5ff2d721da42a20517d911d15544246775ff/numpy-2.5.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92f30e89b8ee0ecf363033576c422b2f58fed6a80bed0aa48dff6d14c654663e", size = 12003676, upload-time = "2026-09-06T16:25:03.475Z" }, + { url = "https://files.pythonhosted.org/packages/ab/2a/98282aa5b8f58b1157d440bb6282eed47e3632a5de53a714fbab17e659fe/numpy-2.5.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:f9a2353b37a1a9e78fd82b27ad7e2a32a2d036604d18f02b05e3136c62ca3b09", size = 5439695, upload-time = "2026-09-06T16:25:05.978Z" }, + { url = "https://files.pythonhosted.org/packages/a1/f9/b6533d777be9d6ffd29dc1be0867e563e6e8cc9a220ff1b716adc317f060/numpy-2.5.3-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:ccbc4665079665c3cf3bab4db9f6b095370cd6437d66be549b6c2a1fd19e1958", size = 6779395, upload-time = "2026-09-06T16:25:08.599Z" }, + { url = "https://files.pythonhosted.org/packages/73/85/735720d04ec197c5dcfacdfc9922667c7f1f5f496a279b7ba4d7c74c4cc7/numpy-2.5.3-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c76d5dde9f445058f83d0c02af00557a4db91de9a9a57c0df87d1535001d654b", size = 15681750, upload-time = "2026-09-06T16:25:11.173Z" }, + { url = "https://files.pythonhosted.org/packages/3a/1b/3b16a9bc514a440a7a0883684111dcb1ef1aee960af2ca95da8fc775f124/numpy-2.5.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5fa86b80fd24bcd1aff83ad23be44ea323de3f787be8f8b15d4a65621e25321", size = 16708577, upload-time = "2026-09-06T16:25:14.171Z" }, + { url = "https://files.pythonhosted.org/packages/69/c4/386f397831b07328b639c96c5b62719346cf4baf07c68d927239752b1534/numpy-2.5.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bd4cb9ad3c7889b9b3fe0a9a9fb5d2ed26f9879bff2608d9f01aed147a20d231", size = 17042047, upload-time = "2026-09-06T16:25:17.582Z" }, + { url = "https://files.pythonhosted.org/packages/5f/3e/a700ecbf36e85ae8328fd3b0e12eeddc22ed6358a64cb2bd913e0d195d65/numpy-2.5.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1302b90c0e52281681b2975adfe8a860cb7b12216a27b4b0b4207c44bf7bccf0", size = 18465724, upload-time = "2026-09-06T16:25:20.949Z" }, + { url = "https://files.pythonhosted.org/packages/41/ee/38e785e88a4045f6ad1d1f2808dcdfafdca48c760260c0587bf171e29fc9/numpy-2.5.3-cp313-cp313-win32.whl", hash = "sha256:1c80eabb4035ecf4ca9cd49cde8a9fdd69a729e63e6474887d1523ade7aa277f", size = 6129003, upload-time = "2026-09-06T16:25:23.664Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ec/100f2b1794ede74a9b3d7ec6b9736927f56713414c1dfe19ab6c383494bf/numpy-2.5.3-cp313-cp313-win_amd64.whl", hash = "sha256:71cad2b2a7451ab79d8f5e71b453485b6775963d5cf794179144a7463fe6e8ec", size = 12560965, upload-time = "2026-09-06T16:25:26.602Z" }, + { url = "https://files.pythonhosted.org/packages/80/b1/7dc825ca94c12acebbce4c37caa5e198695eb31424bc579679f32b1bb49d/numpy-2.5.3-cp313-cp313-win_arm64.whl", hash = "sha256:8e4dd766076855b5ff7ea52fa5f07ce26286726e0f8bff446b7739d02e6ea204", size = 10482343, upload-time = "2026-09-06T16:25:29.772Z" }, + { url = "https://files.pythonhosted.org/packages/70/78/cf416f15dc29375a229d9dfebf8db6e313f291580b39fa1a568b6052bb07/numpy-2.5.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:350ba9783ce969cf9f7ce6e6a9a58e1a6e2a19ca025b7ee448c4db727706212a", size = 16998686, upload-time = "2026-09-06T16:25:33.171Z" }, + { url = "https://files.pythonhosted.org/packages/9e/59/abcc2d8def4fd60eec7d87f92d27c13448ffd9ab14339bcc63a0d7a2fdea/numpy-2.5.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:012e66aca395d795496446e52aeeb5866312a5d4d3f27da270e5a0b43f70dc5c", size = 12013862, upload-time = "2026-09-06T16:25:36.748Z" }, + { url = "https://files.pythonhosted.org/packages/94/75/4640d2d6e4b64a049e48425a82728a41ef4adb61332d2cba68055774878b/numpy-2.5.3-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:adc1ada2662f8a5f960b8a10d9986897e7499ef07e06d4cfe7197f8cce923c07", size = 5449793, upload-time = "2026-09-06T16:25:39.476Z" }, + { url = "https://files.pythonhosted.org/packages/96/cd/625b57ae33d4ca560f32cc0b47b4a5922146d9beb998ddf773900d440a73/numpy-2.5.3-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:54a115e5a73b8fc44f0cebef486365a1894b5c9760685d4558b72b7c3eb846e0", size = 6785176, upload-time = "2026-09-06T16:25:42.069Z" }, + { url = "https://files.pythonhosted.org/packages/9c/72/12918652e7912ef9751e8694c88820fcd1908e0618cb23f5f3caa6004b7b/numpy-2.5.3-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be5a8381859b6da607c84f4f7d6847725f1cf1853ef8a2c9e115b7d58bef47dc", size = 15703377, upload-time = "2026-09-06T16:25:45.135Z" }, + { url = "https://files.pythonhosted.org/packages/45/8f/9beacf79ca7c650688ad0baa80931adb988fe6e6e5d5903c23cc3dbd70eb/numpy-2.5.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b0521d0f4aebb6e06189451025fa17a913287b13c03d5fe05c017333b654ea5b", size = 16711928, upload-time = "2026-09-06T16:25:48.461Z" }, + { url = "https://files.pythonhosted.org/packages/09/8d/41d0a56e1ac4c87495c897a211b1368691b7237aadabec8b3b8f3a74d48f/numpy-2.5.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9deb49575e5b0b94ed72c8a64ec4d033381adc27e9060ae842971f697ba96104", size = 17059507, upload-time = "2026-09-06T16:25:51.873Z" }, + { url = "https://files.pythonhosted.org/packages/08/1e/0dfbc5cc251d54e2af790f254d24ec38637fa97ec7d5d11de7ffed787098/numpy-2.5.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b00eefbcf0f292945c4b4dec2ae845389ef5bcdcd596e6e4328051db5b5ba694", size = 18471002, upload-time = "2026-09-06T16:25:55.233Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2c/dfa40f6991f8185c8c30ffd023dfcbb11888e823cfab9557b920f3bb7bed/numpy-2.5.3-cp314-cp314-win32.whl", hash = "sha256:c2381f82999704f818e2c987a865050e285ec3621262c66d40f5a96c8f899f8e", size = 6180485, upload-time = "2026-09-06T16:25:58.157Z" }, + { url = "https://files.pythonhosted.org/packages/a4/73/d2c08231e4fde7e415501fd02c715d96e98599b2d8384445933944152984/numpy-2.5.3-cp314-cp314-win_amd64.whl", hash = "sha256:2c25dfa72943e4336ddb6b0ee4277b47a0c85bede0807530ec68103bf58e2c10", size = 12698179, upload-time = "2026-09-06T16:26:00.789Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e9/dcdcc9b95cf5f49815055573aee1b11cfbf5299f38a180e437ded050810f/numpy-2.5.3-cp314-cp314-win_arm64.whl", hash = "sha256:15aa985ac73a8db02db7663381aa109510449d3819d37206caed27b33a65a8a6", size = 10769383, upload-time = "2026-09-06T16:26:04.011Z" }, + { url = "https://files.pythonhosted.org/packages/49/c4/af8bc08a7ef4e1529a7c0cf24969accce316b783999802089a581ec99272/numpy-2.5.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ac7bb1c52d445bd4f8f7f97fefe6abc3a084dc4d63df50d79b17fa2b78e89297", size = 12132668, upload-time = "2026-09-06T16:26:07.138Z" }, + { url = "https://files.pythonhosted.org/packages/c5/ae/0f15eb56d4ec5e13c1f7ff04ff407f997d1acbadb45d3e1f2e2645a8f43c/numpy-2.5.3-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e6ab667ba76450084eb64013762c438ea76d9d29cc676dcd6c2e9892ba37f841", size = 5568580, upload-time = "2026-09-06T16:26:09.828Z" }, + { url = "https://files.pythonhosted.org/packages/23/fb/c72a8f25d4b6e96c354e7ab45ace3b27dc11e5d6a13b6c7d0cd6b08bf112/numpy-2.5.3-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:f7fabeb6cea87d65f3b926de33d03fb016cfdc29314c90974383b5582ae72891", size = 6882634, upload-time = "2026-09-06T16:26:12.524Z" }, + { url = "https://files.pythonhosted.org/packages/07/a9/968c90ed2ab15060c338e8137f1215b5a60756ae07328e0a60d1c6734df4/numpy-2.5.3-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1fb6f8fb9ff0b3a69f52c66ce397b0246583e9f28616231b0e32ca49259a5fa6", size = 15748923, upload-time = "2026-09-06T16:26:15.092Z" }, + { url = "https://files.pythonhosted.org/packages/59/08/9df04103947b95e3b6b1f2ed1a70521f325647a31b82da6a2aae3a485508/numpy-2.5.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93e1f5447e2b1e479d7bd74701e84746b86450cff1fc368b132d195e2b8f8211", size = 16746748, upload-time = "2026-09-06T16:26:18.43Z" }, + { url = "https://files.pythonhosted.org/packages/41/a0/14c8d5fe5b53a334aabb653deb391c0fef49558f491880ea300ed6785224/numpy-2.5.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c00abe94c1a69d75d827dcf1c025b25c8a45d230b3bcd77a9020883a1b047653", size = 17111561, upload-time = "2026-09-06T16:26:22.113Z" }, + { url = "https://files.pythonhosted.org/packages/c4/a6/d7e96e42f01522e154c32489640f16dfc4f6181d165d05fc3bec8c2c4999/numpy-2.5.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:536f963710a4e63934d80ac0dc4f478804a83e9a84b6828018f25d09953ada33", size = 18513945, upload-time = "2026-09-06T16:26:25.401Z" }, + { url = "https://files.pythonhosted.org/packages/25/39/3453afb7119d0449ef11c886874120ff180e2c337760e0e2d88f70f1a945/numpy-2.5.3-cp314-cp314t-win32.whl", hash = "sha256:4c8a6d2ebce6305fd82fbefca827775437147052a976ee7c94b36a0c1b52ac6c", size = 6335421, upload-time = "2026-09-06T16:26:28.175Z" }, + { url = "https://files.pythonhosted.org/packages/99/01/22815d2b19a1a746b1d45205cffebb3fe511a18acb75fba6c88491fc9894/numpy-2.5.3-cp314-cp314t-win_amd64.whl", hash = "sha256:9a37475425b431b4d060f23b4f52cd2f3aef6bc7c654bd760adf0040eec9d435", size = 12896420, upload-time = "2026-09-06T16:26:31.265Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ee/a7cbba67eeaff038dc29ca8b98a88396c8b0cc9c89d4924f4a27a5c9150b/numpy-2.5.3-cp314-cp314t-win_arm64.whl", hash = "sha256:2d8240cb4c16fd831074aa2b2cf9fc54664d826341d61c372245b96a74a49a9a", size = 10857177, upload-time = "2026-09-06T16:26:34.167Z" }, + { url = "https://files.pythonhosted.org/packages/45/56/78194492883ff5eec90423fe56a3a44b154da047d88a6307f629713c584f/numpy-2.5.3-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:a6391fafaba97500887132cd582abc6e19452b1ac775a47caa7b24490e152058", size = 16996531, upload-time = "2026-09-06T16:26:37.287Z" }, + { url = "https://files.pythonhosted.org/packages/11/39/dd55c0af90bbab564b09ae3b0aa60ec5c02b900fa4f1ba23440525c8b32d/numpy-2.5.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:09d5a423c71ad5feb5625844ad58050e35df43871004b52ac9c0ad44a56775be", size = 12012569, upload-time = "2026-09-06T16:26:40.707Z" }, + { url = "https://files.pythonhosted.org/packages/b6/51/04f67d32e4862b281b1cb84ceeaed3421189a84fb6fb51a391cd6d5009f7/numpy-2.5.3-cp315-cp315-macosx_14_0_arm64.whl", hash = "sha256:f9579f383d1bf9df80081e72760e84960a7fd4f88cf0c9e535a8597c9bb646f5", size = 5448498, upload-time = "2026-09-06T16:26:43.435Z" }, + { url = "https://files.pythonhosted.org/packages/a3/c9/25b4dc0dd1344ec26c7319e84fd4e9809d2b5628f4e12decd618036e5178/numpy-2.5.3-cp315-cp315-macosx_14_0_x86_64.whl", hash = "sha256:86bff898a431c0fb71f7610b75726e75a54d47b37edc9d537f48de63bb3c0b90", size = 6783026, upload-time = "2026-09-06T16:26:46.374Z" }, + { url = "https://files.pythonhosted.org/packages/fc/c7/29285be1e5232a6e7ee3268a33c85843f5a8ee93350c6465cddd66ebbf76/numpy-2.5.3-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f3ed25271581281f2fccb1adcedfcde4c07362eec69189b50baf6f90e3ae159", size = 15697322, upload-time = "2026-09-06T16:26:49.415Z" }, + { url = "https://files.pythonhosted.org/packages/55/49/bbad5335fb4996a16881f853ff3e0ba582f01720e55c89b1c06b8fc42a90/numpy-2.5.3-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ffdc76bfcae6b255dff75202c5e7feaf95b40246bc0a17944facc1fecf9f79ab", size = 16708995, upload-time = "2026-09-06T16:26:53.127Z" }, + { url = "https://files.pythonhosted.org/packages/ef/e9/1df35483760b04a65ea44669f89dc64f30e5aca098b48ceb8b1310b0e0fe/numpy-2.5.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:116f96cadd935c6122e9228d676fe7ede19e741f5c8bb1c3cddbe0c51ccebea2", size = 17052508, upload-time = "2026-09-06T16:26:56.464Z" }, + { url = "https://files.pythonhosted.org/packages/b8/99/66e54da8265cc8be8a7382bf96edce17aaa2837d6f484432025932a3caa5/numpy-2.5.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:09ffa5d903faeaa5c4dd05009cf81c8bab9f2cb37c548b8d39b65b4cfa7c97f7", size = 18468224, upload-time = "2026-09-06T16:26:59.966Z" }, + { url = "https://files.pythonhosted.org/packages/01/bc/b5e90a91c115168d793dfd2ad9c69c438c2fe7a13a437e770bc5b078e732/numpy-2.5.3-cp315-cp315-win32.whl", hash = "sha256:e01c918ac3d48e18a927cf7b14a26a3e29ff2bdf2eacb976da0aecd6a43ed034", size = 6179919, upload-time = "2026-09-06T16:27:03.166Z" }, + { url = "https://files.pythonhosted.org/packages/37/ea/780748fd3985109075514ef8fc64cd25f943e40dde13a6d59141eb268fc8/numpy-2.5.3-cp315-cp315-win_amd64.whl", hash = "sha256:e931e4f499e0dc7ef29d269a8e5b35dd722e5d14be07df6240166ea7c6532fae", size = 12697656, upload-time = "2026-09-06T16:27:06.153Z" }, + { url = "https://files.pythonhosted.org/packages/b3/16/407be69a2a87c8cab64d95975a8977a426a29e138f07e276ec258f0fe4e5/numpy-2.5.3-cp315-cp315-win_arm64.whl", hash = "sha256:26e15e4aecd8617dfbaecb37d223e365d7b39411fba20454be2670a96aa74cb5", size = 10767601, upload-time = "2026-09-06T16:27:09.297Z" }, + { url = "https://files.pythonhosted.org/packages/44/bf/a97ffb01e41d50a32a9177aef942a4d0e389a3daf451d04e5f38ef6afb87/numpy-2.5.3-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:6cef4bb1706dfec49243c05d921eefb4e190d41e2528b30d8035ea1f36b4c24a", size = 17090092, upload-time = "2026-09-06T16:27:12.907Z" }, + { url = "https://files.pythonhosted.org/packages/d1/24/136c02f2c2af9a067a84d0c3aa10c99012c0476fa5066732fa4a4202557d/numpy-2.5.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:d1c89973648c85069c5046ad460f7b8a00218b29a2e42359ac8cc63e9ab94832", size = 12129429, upload-time = "2026-09-06T16:27:16.089Z" }, + { url = "https://files.pythonhosted.org/packages/fe/6c/b47582d6597789bf946d5efbeb6b9e56fd8bcbd5efc6fbf51dbe1ea31eb3/numpy-2.5.3-cp315-cp315t-macosx_14_0_arm64.whl", hash = "sha256:214045a5bf00113a146ab9ee9730c44501af6723cdf1f6830932f7b5ef2e7af0", size = 5565452, upload-time = "2026-09-06T16:27:19.868Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/ef3cc6da73774202d4deae16bb321fd8298a4e0561e3539f8c4be237d916/numpy-2.5.3-cp315-cp315t-macosx_14_0_x86_64.whl", hash = "sha256:8617bbfae4486cf99c9f899966699428d19da931d06ca94ad3da986c76e15997", size = 6876736, upload-time = "2026-09-06T16:27:22.232Z" }, + { url = "https://files.pythonhosted.org/packages/9e/24/e3813329498596cb842703dcacac1741612ed9fb9c4e6a3e0c7e2ebbc597/numpy-2.5.3-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:595d020938c84e320bcf40ad71089e108eac0d377cd018e14a8c094f39e98d85", size = 15745777, upload-time = "2026-09-06T16:27:25.181Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9e/4e7a07fd0776dc2210cdacf2010be8665194d094defc10c419d7dea794cc/numpy-2.5.3-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f24021b9f22bc6301c37b196974a92c1c18dccedb6fef3dd252e95f2d6adbe4", size = 16746949, upload-time = "2026-09-06T16:27:28.576Z" }, + { url = "https://files.pythonhosted.org/packages/91/db/01674c0e20335057813a00c2ebd546ed25bff9ed7914f9bced00f8c55d94/numpy-2.5.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:71b39d9f935b6ec0f8753e3e2afb51e3efba6f2e05b68b32a40754d24bcd4a3c", size = 17108994, upload-time = "2026-09-06T16:27:31.946Z" }, + { url = "https://files.pythonhosted.org/packages/45/7a/584c5e71f8d378e57cac0b033891ed65c683ef90573ba4854e8c28203db0/numpy-2.5.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:6b05c171afb3aa07adbd20abc00aea86fe375beb0fdb9ef780ec5b7f63bab1c0", size = 18512266, upload-time = "2026-09-06T16:27:35.196Z" }, + { url = "https://files.pythonhosted.org/packages/a1/d2/4e1014173aa3c55e6a756e0e567290743a6ab33a288460374d7ef6bcd239/numpy-2.5.3-cp315-cp315t-win32.whl", hash = "sha256:f54660b0eb6b0b9f36e7fe1cdfdff472028dd0d14acd9b9b65098efbad059469", size = 6330292, upload-time = "2026-09-06T16:27:38.149Z" }, + { url = "https://files.pythonhosted.org/packages/6c/b0/ff5658a58199b7bcaad87bf260eef6713d9d42cca4e028f935b4fc5fbac6/numpy-2.5.3-cp315-cp315t-win_amd64.whl", hash = "sha256:1aad64d99730d013cfc6debafed22783b4fc5a7f4b8bc744d2d8cf7dcc880551", size = 12884918, upload-time = "2026-09-06T16:27:40.965Z" }, + { url = "https://files.pythonhosted.org/packages/fb/0b/b12a2df5d1b774bd9007a6fdff9381145b6223d37f11afc9c37ab0efd9a1/numpy-2.5.3-cp315-cp315t-win_arm64.whl", hash = "sha256:befa1ae5bd6030b3f512b43ff3fa5290bbed6b84411a44244b14adf835f5b89d", size = 10850807, upload-time = "2026-09-06T16:27:43.868Z" }, ] [[package]] @@ -1004,7 +1041,7 @@ version = "3.0.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, - { name = "numpy", version = "2.5.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "python-dateutil" }, { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, ] @@ -1149,11 +1186,11 @@ wheels = [ [[package]] name = "platformdirs" -version = "4.11.4" +version = "4.11.7" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/50/bb/ebc6636e1ae41314f796ebb7215fd28febb45f9aac72f2b04cb74b5071dc/platformdirs-4.11.4.tar.gz", hash = "sha256:f3373be828247211d0febabea97e238c3dfde8a60b3c90c32756fb52cb21556d", size = 34079, upload-time = "2026-08-24T14:53:49.676Z" } +sdist = { url = "https://files.pythonhosted.org/packages/69/b7/802a56eca9f2fac455b8bab5375a2647b0f0e14a2cd63ef077de3c4a7658/platformdirs-4.11.7.tar.gz", hash = "sha256:4f41487eeeeeb07f3a6625e61d9bc0ae6809f92d3386dbd74392fbb76108104d", size = 35127, upload-time = "2026-09-01T13:35:10.502Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/28/be/0ff05fcd2938fb58ad9219bd54135968342d214737e012d62d43f06a2dd6/platformdirs-4.11.4-py3-none-any.whl", hash = "sha256:e34ff91a24bcddc6d939b878bdf3f5c437c9c46fe9e212b1bf455fdf1ee57586", size = 23741, upload-time = "2026-08-24T14:53:48.406Z" }, + { url = "https://files.pythonhosted.org/packages/27/6e/80993e10a0482f630cef528635789233224f36b1ffd11592aa15d13ff9ce/platformdirs-4.11.7-py3-none-any.whl", hash = "sha256:8a02cb259042c79d1cd0450facc2fe6dc9d303ae7901afbe33bf8ea0b188cef6", size = 23938, upload-time = "2026-09-01T13:35:09.02Z" }, ] [[package]] @@ -1337,7 +1374,7 @@ version = "0.2.0" source = { editable = "." } dependencies = [ { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, - { name = "numpy", version = "2.5.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "xarray" }, ] @@ -1370,7 +1407,7 @@ docs = [ [package.metadata] requires-dist = [ { name = "matplotlib", marker = "extra == 'viz'", specifier = ">=3.10.9" }, - { name = "numpy", specifier = ">=2.1" }, + { name = "numpy", specifier = ">=2.3.2" }, { name = "pygls", marker = "extra == 'lsp'", specifier = ">=2,<3" }, { name = "xarray", specifier = ">=2026.4.0" }, ] @@ -1396,27 +1433,27 @@ docs = [ [[package]] name = "ruff" -version = "0.16.4" +version = "0.16.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/00/8f/d8074b1f25e003164087a8bfe79a0f1a3945135764dbb6aaab04103dcaf9/ruff-0.16.4.tar.gz", hash = "sha256:13171aa9d9af2240ee3504e639de73122c67e74036de5ba2e1d01422cd17e3dc", size = 4899731, upload-time = "2026-08-20T17:43:59.196Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a4/7c/6adb35d70e7c027e308274557901c7e00fb3407750faf3620c184ae058cb/ruff-0.16.6.tar.gz", hash = "sha256:dcf8a73d2ff77e99dde91244b4da16feba7f14e6beeb4015dee7c5a909e99050", size = 4921251, upload-time = "2026-09-03T16:57:29.037Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ff/80/779895ef584e089d22f2c6df0d0e99a65ec2df0805f1fffd439415b8c1f0/ruff-0.16.4-py3-none-linux_armv6l.whl", hash = "sha256:df4075f71ddac40b9934af60c3ec8a53047dd5a5fdc43224e6e4e8e9a27cb6f7", size = 10006909, upload-time = "2026-08-20T17:43:16.888Z" }, - { url = "https://files.pythonhosted.org/packages/a9/e6/f553199b5e8927a05cb5c422d921fd0656b29ab976e91c44802107c6b0da/ruff-0.16.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0c95538517af68004306b0fb3214ff2f2af67a65092aee77cd9eb86db6656604", size = 10240201, upload-time = "2026-08-20T17:43:19.337Z" }, - { url = "https://files.pythonhosted.org/packages/1c/70/4a6dc4bb34da4dee35e30f09bbd1bfbdd26f33b62fb9b8df31f08a199cd2/ruff-0.16.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:963f83df8e69e575b64d67dd447ebbc917db41a14bf38d4593a4183e7aaa8255", size = 9835122, upload-time = "2026-08-20T17:43:21.708Z" }, - { url = "https://files.pythonhosted.org/packages/24/12/c6e22d686372c15bcb7af99831f1a1be96df696491babf4f24e4f942c527/ruff-0.16.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32a5057c7ff3f6e6480a48fccfb3a412a690f48a3d03ac5cf08177d6c2da3ade", size = 9977162, upload-time = "2026-08-20T17:43:24.236Z" }, - { url = "https://files.pythonhosted.org/packages/46/49/72b10ec912f5ab5854992eaf7aa7cd36729b6937d9dc4e0fb41b3bf428ec/ruff-0.16.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b3dce8d9b0c57c265b91885a66a567d8ea1372e8eb4e250fa8e5e3f579e99cff", size = 9829789, upload-time = "2026-08-20T17:43:26.966Z" }, - { url = "https://files.pythonhosted.org/packages/fa/80/0f30e32e7f6ee26edc39075502db9d368d788a44a79b55f763eb4ab03796/ruff-0.16.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7dc651db49283c69f8e72c834eec4fe5573e4c646856aebece0ce385dceb2a80", size = 10527949, upload-time = "2026-08-20T17:43:29.384Z" }, - { url = "https://files.pythonhosted.org/packages/52/3d/86e8ad3542169e56cac3859a343afdb9df2ad54d35a59ce1e67baee83421/ruff-0.16.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3817b87dbcabc92f13b05019257c5b89b5b4d51b5fb20f56fb5235ceb723cd07", size = 11333695, upload-time = "2026-08-20T17:43:31.872Z" }, - { url = "https://files.pythonhosted.org/packages/d0/16/481c29b380c20a0054a8261066665e1b3488e23636c49d0a43e75975b9bb/ruff-0.16.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e9fce1499134b2c8c68e5166f95705a5812062bb93aacc5f9873bb1a27084bc7", size = 10727741, upload-time = "2026-08-20T17:43:34.596Z" }, - { url = "https://files.pythonhosted.org/packages/5e/b6/56bc0b8cf45b54b28b3a5e6381c8945d51b5b18adf659454c32295209a31/ruff-0.16.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f2d812e482f5a7e02eee26cd73d2a37ebbdf47d795ea63ba1b89110ae93e9fb3", size = 10286522, upload-time = "2026-08-20T17:43:37.288Z" }, - { url = "https://files.pythonhosted.org/packages/e8/8b/b345b4fb110f2fbe2bd31eabd271e5e8b3b7e4ee6c0e02f2dc6be78db000/ruff-0.16.4-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:6baaf984aa7976edf93d3b627fe2d1d22ee94bbca05fa6f90fc76d73924e3454", size = 10584182, upload-time = "2026-08-20T17:43:39.984Z" }, - { url = "https://files.pythonhosted.org/packages/29/e5/827b34041c35f58774a9681a4213994c164fc987800f4dddabcf451da0bf/ruff-0.16.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:bdfcf0b28662eb890372d50f92c283bb94e67e7635ed93c7fd533970acff7b2b", size = 10134195, upload-time = "2026-08-20T17:43:42.351Z" }, - { url = "https://files.pythonhosted.org/packages/0f/10/d0bffcdd6729b87afc82ba0ef377173356a7dc8e972f5179968cf2fdf98c/ruff-0.16.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b66b02cb9b04f537643cadf5768e5f98dc461890d530cb67113d71c8c76e605d", size = 9825821, upload-time = "2026-08-20T17:43:44.532Z" }, - { url = "https://files.pythonhosted.org/packages/f5/32/0db2a863b796ca62d83e92a07a3ccf00921b14db02059347576a2fda3d4b/ruff-0.16.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:8528bf9a4b291a60bf02ea453511e8ce6215bd2b982ee80405b66b008b6c30a0", size = 10267658, upload-time = "2026-08-20T17:43:46.989Z" }, - { url = "https://files.pythonhosted.org/packages/b2/a0/fbdeb59e48c6261f523e56c8f12e9c08fbe693786595cc7e3959207a9232/ruff-0.16.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:fbd85d2875fdd67e833213a651f613bbf25303abf6aa822a5121f4531195678d", size = 10697071, upload-time = "2026-08-20T17:43:49.891Z" }, - { url = "https://files.pythonhosted.org/packages/aa/28/0c6dd865859c6d17bc8ccc34cb72b0e02d6c7eb25e8a1e22b5bea681e2c0/ruff-0.16.4-py3-none-win32.whl", hash = "sha256:312769988007aaeb8e189b443ccdd03c0e6374489e053467be6d96518ebff76e", size = 10021687, upload-time = "2026-08-20T17:43:52.281Z" }, - { url = "https://files.pythonhosted.org/packages/a3/03/e724450f621698117f9aa6dd241c94d0274ae96781378dc86745ae29f0e7/ruff-0.16.4-py3-none-win_amd64.whl", hash = "sha256:05d9d27a18c4bcbefada602480ec9e01e0bc949d432e0ced5df77edac195919c", size = 10567657, upload-time = "2026-08-20T17:43:54.78Z" }, - { url = "https://files.pythonhosted.org/packages/0e/fe/da8b9e1347696bb22120b77280ec5ce25d500ca5cb39d5ad6e5c18de19c1/ruff-0.16.4-py3-none-win_arm64.whl", hash = "sha256:a3a61621c9b6f6a89573e938a080e648f1695baa3f58570a3a707bc51ff65a21", size = 10451579, upload-time = "2026-08-20T17:43:57.135Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/9cc1b79639e284ec103f43c88c644db4eb58cbd0ea1ca11f1193435369ac/ruff-0.16.6-py3-none-linux_armv6l.whl", hash = "sha256:61c368c26bf8e973e5ab14a2772de587bc068ea3f9a277f673380749b4898fb8", size = 10015638, upload-time = "2026-09-03T16:56:40.986Z" }, + { url = "https://files.pythonhosted.org/packages/71/11/627d342ef727ea7794edf74fe23d60a074b02c3acc2e9436684e782286ca/ruff-0.16.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ecf4f068e2e123e43a26e9db4e19524cc56563912404e83bbfca375757e45a32", size = 10220762, upload-time = "2026-09-03T16:56:44.681Z" }, + { url = "https://files.pythonhosted.org/packages/43/d9/b75668ce41e4c8d073d18d6d08672ba6906ce45d5c06ea4fdb2e84ce3853/ruff-0.16.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:99b62ea33baf130f50368798d841f0d95527b6d817bf31817b65dd058f1d314c", size = 9835082, upload-time = "2026-09-03T16:56:47.142Z" }, + { url = "https://files.pythonhosted.org/packages/99/97/123ab10b05cde889c107c20f5a9774955104b5552796a2a8584b089ae8eb/ruff-0.16.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7fbf89013f2bb3f6835a6038ff658dc8a1b38c98dc8e724b964168ad4e881876", size = 9949304, upload-time = "2026-09-03T16:56:49.813Z" }, + { url = "https://files.pythonhosted.org/packages/3e/58/a4a2c59dd2e5b85929c912d9cac3056eb9ee8c7e75e9b9fe3e109174966b/ruff-0.16.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56a67065e22efa6bc4d498299d3bb06c0c90aace8fac2068b5a12f9dc4d8d51d", size = 9840612, upload-time = "2026-09-03T16:56:52.368Z" }, + { url = "https://files.pythonhosted.org/packages/61/6a/ff8c8626a786c4f49d48ced4a752dadbca65f5263005f9c2416578194694/ruff-0.16.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e25cc89174874b176a157e4428d66761c2c0c006654419bf384f967f361ff1b1", size = 10543465, upload-time = "2026-09-03T16:56:55.089Z" }, + { url = "https://files.pythonhosted.org/packages/ad/bb/c47535923365f337b82e28192e4e9eef2176511007cfd99a62fc22df5dad/ruff-0.16.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0700580ed5303723cb3c11c2f1d2a8913ce77b7ea86646dddb887f5417a9ba70", size = 11267576, upload-time = "2026-09-03T16:56:57.791Z" }, + { url = "https://files.pythonhosted.org/packages/ba/50/e5119a5212b5cd63b51e1f4b25e7bd636a6668fc069a3160b108ad7e3c16/ruff-0.16.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:15f1d0b6e165a6e56567befb6629f8209271311d990bae0f37e6d065035ef5f3", size = 10781993, upload-time = "2026-09-03T16:57:00.666Z" }, + { url = "https://files.pythonhosted.org/packages/8b/98/083d8b4ef3c51a0d19db84367791cbe9f44e4b53343d19dfa83556e1cd9a/ruff-0.16.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d72c591a96986ee4268860e2b7235082129ca5e4cb9cbba653a4b57c11893757", size = 10317748, upload-time = "2026-09-03T16:57:03.428Z" }, + { url = "https://files.pythonhosted.org/packages/9a/29/68f7ff2c5ad95f19f00627ac2de95644e25fe47371ea60b2db1fd952315e/ruff-0.16.6-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:65a006baa18f33324325814c864daef03541d51564b98c517610ea756ab7003e", size = 10540096, upload-time = "2026-09-03T16:57:06.182Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f9/79a8f6de85968641d68a7863aeec577551924ef066a990a48ff93167beab/ruff-0.16.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:cd02a7bf1a21a8735228a3e8c95a9dc5cf86bd2a52194f4aaae2a5755b4de0f4", size = 10100494, upload-time = "2026-09-03T16:57:09.194Z" }, + { url = "https://files.pythonhosted.org/packages/d9/e8/b81a22d9b90c00b892ccf2fa2ac36fa95de4c13ab85aea3e73795cfe4651/ruff-0.16.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:31b36f1e5ad85e0737f09d2be4e512e2e283583c14015da3b9dc07359ac0fc88", size = 9843663, upload-time = "2026-09-03T16:57:12.168Z" }, + { url = "https://files.pythonhosted.org/packages/39/aa/54f516ec5e5a11c4afdceb1c454ebb054ffb96e4f4a1705580b4346abd35/ruff-0.16.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:61029b4ab4aa723fd3064fab96b1d814492596bf0c792679fffcbde1e1679953", size = 10282461, upload-time = "2026-09-03T16:57:15.077Z" }, + { url = "https://files.pythonhosted.org/packages/52/0b/38d0aa8aa32372b96dc44f97b22e576c4147808271aab7b2cb1e353d4445/ruff-0.16.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9ac8998457832c2061709d900856b7ad271dace0cb41f346588d540162bfa718", size = 10728808, upload-time = "2026-09-03T16:57:17.797Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e5/9e274e24eeb027640ffc7442f21239f16d17f47acec15ae34f32e03a5c79/ruff-0.16.6-py3-none-win32.whl", hash = "sha256:0b87d9d16fcb63e8018423ca1d50b7260f15cb2da33e30db4baad4183a948c25", size = 10049212, upload-time = "2026-09-03T16:57:20.55Z" }, + { url = "https://files.pythonhosted.org/packages/22/31/72472449414223ed1a2da236b992adbb1a2ae59e34794574810f60ce068e/ruff-0.16.6-py3-none-win_amd64.whl", hash = "sha256:10d21c51c3495d8eaea7b703a16592117ea6eb1d649e36335aa965ff1173eb39", size = 10556402, upload-time = "2026-09-03T16:57:23.501Z" }, + { url = "https://files.pythonhosted.org/packages/fc/07/d781f8f8e1ac24bef9f3269cf62ffb1407ca24c3a8f12e5e22874f90528c/ruff-0.16.6-py3-none-win_arm64.whl", hash = "sha256:7a976c79b958f94e50a022a19f0f8c87387448020935ec14fc74331bd0a7f2c5", size = 10412850, upload-time = "2026-09-03T16:57:26.416Z" }, ] [[package]] @@ -1493,40 +1530,40 @@ wheels = [ [[package]] name = "towncrier" -version = "25.8.0" +version = "26.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "jinja2" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c2/eb/5bf25a34123698d3bbab39c5bc5375f8f8bcbcc5a136964ade66935b8b9d/towncrier-25.8.0.tar.gz", hash = "sha256:eef16d29f831ad57abb3ae32a0565739866219f1ebfbdd297d32894eb9940eb1", size = 76322, upload-time = "2025-08-30T11:41:55.393Z" } +sdist = { url = "https://files.pythonhosted.org/packages/92/d4/e85ce614bf45b1a7fe13b82cc687819c2eaac5a1dd94b84834322095fd98/towncrier-26.9.0.tar.gz", hash = "sha256:ace9031631c718cc0709107b8755e6e31350eb8acdc877af43df3177addb9a55", size = 79338, upload-time = "2026-09-04T12:57:25.341Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/42/06/8ba22ec32c74ac1be3baa26116e3c28bc0e76a5387476921d20b6fdade11/towncrier-25.8.0-py3-none-any.whl", hash = "sha256:b953d133d98f9aeae9084b56a3563fd2519dfc6ec33f61c9cd2c61ff243fb513", size = 65101, upload-time = "2025-08-30T11:41:53.644Z" }, + { url = "https://files.pythonhosted.org/packages/82/88/f9340b545dafa64de053798e760515a2736fbb60092cf9aa6b146ccf3ede/towncrier-26.9.0-py3-none-any.whl", hash = "sha256:ae4d223e6aadaff98d29b7f09e58d9c9ccf4cd3b455a2d2e0486d432cd8bf660", size = 67030, upload-time = "2026-09-04T12:57:23.986Z" }, ] [[package]] name = "ty" -version = "0.0.74" +version = "0.0.79" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/88/0f/c767853e88567a2ec7e996dd95e3105b1bc62c95d103689311ef0f4a603c/ty-0.0.74.tar.gz", hash = "sha256:da14344fc8625fc9ff359bafb856ad575636ea86d9bb6a629b146bff27b380e6", size = 6786318, upload-time = "2026-08-22T15:05:54.054Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/d3/4fff47468a976c7a5ded9fe350734ca09b9e8460750327f2245ea3288d5d/ty-0.0.79.tar.gz", hash = "sha256:159a1aca70edebae32be08bfba2e5d543ffd8f9f380af160e0e713e85313b733", size = 7162150, upload-time = "2026-09-07T21:51:58.065Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/95/6ded58bc97885c6d88fa1f9cd815031489200738f961cbf0466663213f80/ty-0.0.74-py3-none-linux_armv6l.whl", hash = "sha256:8969ef4e508debf00cf58f9ea85a539f799b1732c59cdfcecd037630b9755b30", size = 12790043, upload-time = "2026-08-22T15:05:05.015Z" }, - { url = "https://files.pythonhosted.org/packages/d9/8a/5e323603b6ab8731144421877ee8a0f8ac5a5511e67857127caa09f6730e/ty-0.0.74-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:51fb6cf5b98e1e1140825b2430943f78d744876a735231656eafbb4c3f7eca3c", size = 12371748, upload-time = "2026-08-22T15:05:08.609Z" }, - { url = "https://files.pythonhosted.org/packages/d0/44/ee72e08cb705281e8d8c42917dd577aa598a8a098008495fda5176ee3f6e/ty-0.0.74-py3-none-macosx_11_0_arm64.whl", hash = "sha256:8ebe60b1f0a948c793d6c77fc9e9ddda599e4f023c04ab16e8e03bcb428c3fa0", size = 12282403, upload-time = "2026-08-22T15:05:11.448Z" }, - { url = "https://files.pythonhosted.org/packages/da/b3/fd935b694ff68bc278af50f7ad04770b36ce6306399baef7e1847b553a9d/ty-0.0.74-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa97f407a695c890a53615966a663c7d2167e2cabe88db7ca1a24d62635cdfc8", size = 12345164, upload-time = "2026-08-22T15:05:14.19Z" }, - { url = "https://files.pythonhosted.org/packages/54/5c/5b5825268e029ebb164c909780103dbbae367f069801410068bf1cef29b3/ty-0.0.74-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:673ddb733d4a0db31385ba1ed9ff1f6bd9dc5565413ce57b1ca5ac4c7803da5d", size = 12556646, upload-time = "2026-08-22T15:05:16.994Z" }, - { url = "https://files.pythonhosted.org/packages/56/e7/515914e571d62ce0101744fed3f881936eeb1b30dc37beb72b4f7ca1e289/ty-0.0.74-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1028e7c6b4f6145e9704552f43a5fffdcd51b42263ffdcd9c9677762bc395a4a", size = 13311653, upload-time = "2026-08-22T15:05:20.254Z" }, - { url = "https://files.pythonhosted.org/packages/b0/07/d1452babb6f9266c2122cabc095180b70ed306fb770b2996753814d2237d/ty-0.0.74-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:79841a8890493021fb308772474983316eb91f7b56cb227a6a05a06b262a36f0", size = 13768284, upload-time = "2026-08-22T15:05:23.197Z" }, - { url = "https://files.pythonhosted.org/packages/b1/60/8d4a2fc7842a47210a1cb0a16a187d9de39ad5d509a00fb74c1c073afcde/ty-0.0.74-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:94859d321f3c6a6c8f7bfc3f40e8319cda7e6e012e613440f3dfd145d5010e2e", size = 13422306, upload-time = "2026-08-22T15:05:26.248Z" }, - { url = "https://files.pythonhosted.org/packages/de/76/ebbc269a8c4efcc4d44624993bd188145f20d60ebda9680b15aaec42cc50/ty-0.0.74-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:970a8b2c09ff3be04c8a1c6767332d861be4fce85efe7bb205e4ade7c8655274", size = 12970637, upload-time = "2026-08-22T15:05:29.15Z" }, - { url = "https://files.pythonhosted.org/packages/9e/dd/b99f7236acbf856780ca1779a48143d2d9f2c24d7f531a0ce15a022b8a87/ty-0.0.74-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:795f763b3ded85574c2c2846a6fb8acf2aa76e9e83d761143e92b1f0c7ffa2cd", size = 13344891, upload-time = "2026-08-22T15:05:32.033Z" }, - { url = "https://files.pythonhosted.org/packages/0b/d7/9ff7449a4c7e6428f2c6f298e74cf24b70668f29d45c249507a723ff3782/ty-0.0.74-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:dc086db5367d912c31c0cc872deb7387290e779a4b9b54fcb944673a7cd52c7b", size = 12395272, upload-time = "2026-08-22T15:05:34.702Z" }, - { url = "https://files.pythonhosted.org/packages/b1/dd/b23a5b6b35d37df89dc8dc5daa09efd9245a668b50c4c81c25de21567dc1/ty-0.0.74-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:0314d7b391cf684e47c2fa093d2ce4c597cfc9b01d9a315fe204aed6359b271b", size = 12573079, upload-time = "2026-08-22T15:05:37.683Z" }, - { url = "https://files.pythonhosted.org/packages/23/c5/ccba16239d6129533c8b3603458d0f4dd2ba69478e47059073968e74261d/ty-0.0.74-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c4a45dd2e991e8bdae82ba78c8cd051b253f60bc71a6536598fa3ef580b4fc9b", size = 12832506, upload-time = "2026-08-22T15:05:40.505Z" }, - { url = "https://files.pythonhosted.org/packages/6d/1c/2390912634dff4f341f97b397f2aee341ff062be0a66cda37d59375454f2/ty-0.0.74-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:210e2eac6b018fb934e2b8dac3956a0ba076a3fb1fa6f135058c825e5b759b81", size = 13154752, upload-time = "2026-08-22T15:05:43.355Z" }, - { url = "https://files.pythonhosted.org/packages/c4/33/a8c12188227e6f74f91853a7374e01ed81d6ad21c16c8b70e92dbebfe46a/ty-0.0.74-py3-none-win32.whl", hash = "sha256:db0bb6a8f098ef9bd1be861f73b4f7c0320d40d4c05c7ae0a8677d4e7aa4f6e5", size = 12130002, upload-time = "2026-08-22T15:05:46.058Z" }, - { url = "https://files.pythonhosted.org/packages/21/5c/064f28ccb9c234cfce5a2f7aa69a256663d5ae5bb0290b3a9706cc4d1e4c/ty-0.0.74-py3-none-win_amd64.whl", hash = "sha256:bebff181515255b3c78bd2e7693ae66fab6064ad4feea2065c68bc01022aa678", size = 12771435, upload-time = "2026-08-22T15:05:48.811Z" }, - { url = "https://files.pythonhosted.org/packages/fe/06/d6becdaca0315346c26b6df97cb0eafa81de4f870945d6989e88704374ed/ty-0.0.74-py3-none-win_arm64.whl", hash = "sha256:1a3469eaaf8c85b1c0a15bede25d36daea4b09fce1d913e965b24e24b3f1d6c6", size = 12558299, upload-time = "2026-08-22T15:05:51.543Z" }, + { url = "https://files.pythonhosted.org/packages/90/a3/2fa5b5495fe37351d77571eb0985476461be02c822bb39ad669b9fdb5bb8/ty-0.0.79-py3-none-linux_armv6l.whl", hash = "sha256:f60d968bbc6d52b663d3df4cae647d3cc68df4a7135bd9098e8d570bf9a5e0da", size = 13557478, upload-time = "2026-09-07T21:51:14.089Z" }, + { url = "https://files.pythonhosted.org/packages/f5/f0/1659a926d2b19351839ca64787e1531c97fbe8bf35c3c47d20954f338428/ty-0.0.79-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:4c95ae0ca67483c4f1231c4b2fd332b76f212e8d93154c385d77511d90dcbd8e", size = 13163780, upload-time = "2026-09-07T21:51:17.066Z" }, + { url = "https://files.pythonhosted.org/packages/e2/b2/e0a8a8cf58b39f0d1f509a22550aed1e2cc12bb4a868170b43aede9fcd93/ty-0.0.79-py3-none-macosx_11_0_arm64.whl", hash = "sha256:685888adb29b6e732ee325c6b6db92a422f72f3e4031c12450728c846e5e93fe", size = 12964040, upload-time = "2026-09-07T21:51:19.818Z" }, + { url = "https://files.pythonhosted.org/packages/88/a7/7d8fb6c958d88a63ad932421eff8848267bb9229136ef65d9f372aa2be7a/ty-0.0.79-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b0535640db5dc02f9e14bf419ad2cf3acdd49681e70f233c96e96d16cfcefc00", size = 13010025, upload-time = "2026-09-07T21:51:22.468Z" }, + { url = "https://files.pythonhosted.org/packages/e3/4c/94aee26446f058e67b2c8ef0b25bdc1cf555e40eb692112d7a609f30c238/ty-0.0.79-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:48cb24e21488f9d11cffa81d5a5f71f314c87383b7a5d28dabe6feaa3f3d7a34", size = 13332191, upload-time = "2026-09-07T21:51:25.031Z" }, + { url = "https://files.pythonhosted.org/packages/6c/21/9e7c0415fa6275500f10ce13f2bad62e03211ba5f3ea61a702d819328407/ty-0.0.79-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1a35b3b116a591a68da5958c082247f0c3a50f134d0a60ff45d060eb3d2ad612", size = 14153955, upload-time = "2026-09-07T21:51:27.421Z" }, + { url = "https://files.pythonhosted.org/packages/0d/5f/b27b510f973a314200cfda4f0500de3c65f6caa45a590355f6acc5e96289/ty-0.0.79-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fcb760f059d660dfd5c9c6c33609478a267c3185c7448d72e6f7ff4572796bae", size = 14594578, upload-time = "2026-09-07T21:51:30.345Z" }, + { url = "https://files.pythonhosted.org/packages/a6/fa/0ccc2e510c1c24682ae21c836c09be6dcf0b1aacab071c51a297d301601f/ty-0.0.79-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fcdc1ae0c740f9b6d6536ffecf485616a8909bed5de88c6e870a65546a6a2d91", size = 14266409, upload-time = "2026-09-07T21:51:32.868Z" }, + { url = "https://files.pythonhosted.org/packages/e8/c3/3a404d44e9768578daf7dfda06cba714eda6feac53272eda295947a8d4c7/ty-0.0.79-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c1e77d58e192c81b958783328b303ceda8706328a307c852ceb257505765458", size = 13658441, upload-time = "2026-09-07T21:51:35.283Z" }, + { url = "https://files.pythonhosted.org/packages/f6/23/b90a512055fdf6356e95c0f745cb9b443b9a4ac9d3a79f07ae6ed53ae9ca/ty-0.0.79-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a87f52183b976d9eda5fad28405b37621a265c3de5e9e279660556c715ed1805", size = 14195669, upload-time = "2026-09-07T21:51:37.848Z" }, + { url = "https://files.pythonhosted.org/packages/b0/05/d051248dcff852e8a3165a2feb164dc5b857ef237fa82e7ea2b3b7bac375/ty-0.0.79-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:fb429a44bc9e90649e69f739d70db4a305102a36586a810caee00c984c379997", size = 13127929, upload-time = "2026-09-07T21:51:40.431Z" }, + { url = "https://files.pythonhosted.org/packages/5a/d1/60b5c980df55b1d5010a41b10d7c8cf6111b5d02ec0807f6f782bb3d88aa/ty-0.0.79-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:1d857fa34d60b0981cf12ca19180ae5de70dd46306a9277bee772bb09e82ae5c", size = 13334425, upload-time = "2026-09-07T21:51:42.956Z" }, + { url = "https://files.pythonhosted.org/packages/9b/3c/fcbfa731128f06b1c9be5074f51ce47da06842dc8f33c81e7c1c483b6e91/ty-0.0.79-py3-none-musllinux_1_2_i686.whl", hash = "sha256:a95cffa2f30289b0b949db7611ae96ede0352f0f1d7ba07ea7cabb27616fccb9", size = 13629671, upload-time = "2026-09-07T21:51:45.467Z" }, + { url = "https://files.pythonhosted.org/packages/0e/00/3004a1d375c2a835c7dbf01c480205a190f24b74c8b5c849fd2dbe078d69/ty-0.0.79-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:d33df0f1bdee62dc366551d35b9830b1fa2e9ee2936a437abd435f71b3fce73e", size = 13939769, upload-time = "2026-09-07T21:51:48.033Z" }, + { url = "https://files.pythonhosted.org/packages/10/7d/f357f5768872ffa3a026707477a880aba7582af5f6c7c7eab74f61167f3d/ty-0.0.79-py3-none-win32.whl", hash = "sha256:ca266de079a187ed6f0f5b802f6fc3b26164753c7347a04daed21d86c938d33d", size = 12833644, upload-time = "2026-09-07T21:51:50.766Z" }, + { url = "https://files.pythonhosted.org/packages/f9/f5/2a2d967be6286ee8fb626161e610fd48624ba5e6712cea932adb2d6b40b6/ty-0.0.79-py3-none-win_amd64.whl", hash = "sha256:88cb357d36ad79181015581365769fff4b1ec580cd12de83addfa98663214fd3", size = 13494258, upload-time = "2026-09-07T21:51:53.105Z" }, + { url = "https://files.pythonhosted.org/packages/8b/74/49b88f104f88d9757497758bf57ba53e3612442b49500d1092d4d6f1daa3/ty-0.0.79-py3-none-win_arm64.whl", hash = "sha256:d29da73f2840ae2631bc62ef8f8d509a94edea596e5b3072851f67e4729d744c", size = 13327517, upload-time = "2026-09-07T21:51:55.602Z" }, ] [[package]] @@ -1580,7 +1617,7 @@ version = "2026.7.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, - { name = "numpy", version = "2.5.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "packaging" }, { name = "pandas" }, ] @@ -1591,7 +1628,7 @@ wheels = [ [[package]] name = "zensical" -version = "0.0.57" +version = "0.0.59" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, @@ -1603,18 +1640,15 @@ dependencies = [ { name = "pyyaml" }, { name = "tomli" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/83/f4/fa40086c46a2e59e3d9239031f76623622e60e0d79f3df1282df2797a5c4/zensical-0.0.57.tar.gz", hash = "sha256:25fcbdf89a57153cc3ad1108a89d17c7226da5d3c551a8839c69cbd9c472a9d8", size = 4000458, upload-time = "2026-08-21T20:43:49.5Z" } +sdist = { url = "https://files.pythonhosted.org/packages/34/6b/51a78b43239986744089dfa7da7930d808797eb844405f6ce740f5070a67/zensical-0.0.59.tar.gz", hash = "sha256:2b3c3ad561bbc781144b68c7a75246f1e859d3e80319221f59d927d229df21d5", size = 4110437, upload-time = "2026-09-03T18:41:13.942Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/92/b9/49c37dc65105d1ca4a8b600a02c84ece00218d2293b2630611c620185ca3/zensical-0.0.57-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:98867d1a6ea2c57f1ebcf4902f61601f427350f2df0c04e30cfac8ba6163cd29", size = 12888507, upload-time = "2026-08-21T20:43:20.365Z" }, - { url = "https://files.pythonhosted.org/packages/05/f7/54539984418de11387bbace39a744195555d32c98c95bf4d112b432548f5/zensical-0.0.57-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:0d7935d77d73a279545052e05d89d31960f30c1f33f53933f4c101fa271aee74", size = 12778169, upload-time = "2026-08-21T20:43:22.879Z" }, - { url = "https://files.pythonhosted.org/packages/40/16/74aa60aa4cfecd5bd31ce60cb6a092cb56f1bc1aaadcc173463861ea4eb5/zensical-0.0.57-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b7046d433511d97aa603915f0f6792d15b7f839793abc2b66ab7b7ff753ecff5", size = 13230823, upload-time = "2026-08-21T20:43:25.141Z" }, - { url = "https://files.pythonhosted.org/packages/8a/d1/742d2487dd65dd18277daebcd37db56d5bd4a2408df02bde703ef8fb7b64/zensical-0.0.57-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ab85c5066b95e3a877cf8971e4ce30abb1ca1459fbfcc631f0a5a2bab56351a4", size = 13170523, upload-time = "2026-08-21T20:43:27.456Z" }, - { url = "https://files.pythonhosted.org/packages/56/6f/12b570775d344f1a3d77e26d4ae0160bcac9e41ca38f7135352ccdf9b2c8/zensical-0.0.57-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0f13d1b57ad3c8b8634933a93ea870ebac11245fe0c968d27fd2a059ee1c6311", size = 13549941, upload-time = "2026-08-21T20:43:29.964Z" }, - { url = "https://files.pythonhosted.org/packages/7b/4e/436e6fc76674244c084ef7f6f17dc5ff85c76b15aef77c48b703fd0a2dda/zensical-0.0.57-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:021dd8fb70d1816cd012684fcf45d32b8f88a0cd28b7cbe71e5f8564f6d5764d", size = 13210086, upload-time = "2026-08-21T20:43:32.098Z" }, - { url = "https://files.pythonhosted.org/packages/ef/52/20f3aeda9af1090f24241670a5cc20fff7494545fea9f5fa094c82f3dbdf/zensical-0.0.57-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7e10f3c27fdc3eac3a9ae6ddcd87f3f00edc9f332050923313c95537961bfadd", size = 13408253, upload-time = "2026-08-21T20:43:34.258Z" }, - { url = "https://files.pythonhosted.org/packages/e1/f2/2b18ba2f19674dbfcf745f3b66e005cc8efa66a1bcaba5e1b4f79467868a/zensical-0.0.57-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:78c85fee55c5aac3bdf8157e980c56397dca835167a5577c5429b5eb24ed990c", size = 13446689, upload-time = "2026-08-21T20:43:36.527Z" }, - { url = "https://files.pythonhosted.org/packages/05/ba/68cdba447a9097e5f97742eef046020c6fa42d82972849b3a46a0718e890/zensical-0.0.57-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:478d252e1924f3876e72cf7806967cb62e50d86eddb3da04bf43e882b532fa1b", size = 13598580, upload-time = "2026-08-21T20:43:38.646Z" }, - { url = "https://files.pythonhosted.org/packages/ec/89/6358a4df272328bed5bea90b04d43e73758bc45ff058c5cb2665e1147314/zensical-0.0.57-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66a9ca6b5f625b2a2b215eec2f3c72843a92d5d512042045ac6351d5dee9b339", size = 13557609, upload-time = "2026-08-21T20:43:40.866Z" }, - { url = "https://files.pythonhosted.org/packages/77/e1/8831301a24f736743e3788f09ea048918b0bdcea4aaa90f7770a433d6eec/zensical-0.0.57-cp310-abi3-win32.whl", hash = "sha256:f0fe3dc27ca7dc4e168eddd0fe5b0f4d44e311fd4e0019241e289819e445203c", size = 12446805, upload-time = "2026-08-21T20:43:43.097Z" }, - { url = "https://files.pythonhosted.org/packages/d7/3f/5d0ecd77d9ce962fdfde22dec036f4257a43ef6dbd55fb5c05fd294985ad/zensical-0.0.57-cp310-abi3-win_amd64.whl", hash = "sha256:a756834025c1c54e806e943be6d8df1048d0f8bcf6086e958568407a070a2572", size = 12716781, upload-time = "2026-08-21T20:43:45.273Z" }, + { url = "https://files.pythonhosted.org/packages/66/09/b9ed4bec7dd871474b8b9c932828e9fa058f4d48e287fb8c773ccb1f638a/zensical-0.0.59-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:2a39a413fb38f3cc1572c49e8196e30537b75b53fe9d93180d419db5c8db0e68", size = 14169206, upload-time = "2026-09-03T18:40:51.289Z" }, + { url = "https://files.pythonhosted.org/packages/06/3b/a7ab8573d40e780dfbc316c24191170f898e4d7d158d06e19465260c044a/zensical-0.0.59-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:b733edf61722a71d184c3899f035390b27915773fc77599987da934dd87fb76b", size = 13916290, upload-time = "2026-09-03T18:40:54.146Z" }, + { url = "https://files.pythonhosted.org/packages/e4/e8/92c5a80fd3701253660f5c2df8a938c6e6f1fef2218df9830b9d596ef237/zensical-0.0.59-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:973322d6cba380dae7acbefce84b74f67f8610435ba97c3557f5f25b6e49ed40", size = 14188906, upload-time = "2026-09-03T18:40:56.484Z" }, + { url = "https://files.pythonhosted.org/packages/5d/16/f898fa52c525f4241fd7709638b10e185b2bca2bc6e34f5dd5741cbe7b8e/zensical-0.0.59-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ef84401d8ad34835b5f7d31950f549758fa610d65f20a0cff8d5d71343112644", size = 14211088, upload-time = "2026-09-03T18:40:58.754Z" }, + { url = "https://files.pythonhosted.org/packages/09/50/084f74d4b98d553c566d484bb68e2bd472484027329e4f32e7334d0848ee/zensical-0.0.59-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0331651ffde09bb48904b428de3efa009290434760dbd5ec69b50c05a682a59c", size = 14466906, upload-time = "2026-09-03T18:41:01.305Z" }, + { url = "https://files.pythonhosted.org/packages/c2/bf/3472a57f8c816656fe5f8dde9fae1f5380e97ca5c2bda0b37614f61f5d62/zensical-0.0.59-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f16ec02bb8582ccdf631db2bedb0a0d19901b6f3f22d9fc8aad97ea61d547746", size = 14363960, upload-time = "2026-09-03T18:41:03.961Z" }, + { url = "https://files.pythonhosted.org/packages/83/f2/7193e2ae24636d63cc4e9e261acabacbc56ea8e48201b90cb737d9c56147/zensical-0.0.59-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6d61b8db3e198f8b735186be029842ee829be66ca29b6797084948b0eec1c2cb", size = 14695910, upload-time = "2026-09-03T18:41:06.122Z" }, + { url = "https://files.pythonhosted.org/packages/84/89/ff115a9335f12b6f3cb4401ff8be133fa12689e38d3cedb8142cf47d6101/zensical-0.0.59-cp310-abi3-win_amd64.whl", hash = "sha256:308596a2ff58130b4491c417cafd249c33e709f13b425a4482a3c5deaf4f0cff", size = 14479792, upload-time = "2026-09-03T18:41:08.465Z" }, + { url = "https://files.pythonhosted.org/packages/f7/2e/0e3eccdac45739d9bbf35ac9ab801e07de13f7d9b7cff19a3a4331fd4025/zensical-0.0.59-cp310-abi3-win_arm64.whl", hash = "sha256:6c4e4d706396e63daac3faee4b7ac4bc57b4baded163b97a0375db11ec3fae3f", size = 14190921, upload-time = "2026-09-03T18:41:11.068Z" }, ]