From 98485294dbbd3211782b0ec3903b3f39c26b37c1 Mon Sep 17 00:00:00 2001 From: Louis Choquel Date: Sat, 3 Oct 2026 17:46:29 +0200 Subject: [PATCH 1/5] =?UTF-8?q?feature/Retire-release-items=20=C2=B7=20L-2?= =?UTF-8?q?61003-7c4b4f=20(#80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The workspace ledger retired its release item type, so a release now files nothing. The /release skill's note on follow-ups now says to file work that waits on this version with `ledger new --after-release mthds-python@X.Y.Z`, which makes it ready by itself once the release tag lands, instead of filing it alongside a release item. Closes L-261003-7c4b4f Advances L-261003-0dbbf4 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- ## Summary by cubic Updates the `/release` skill docs so release follow-ups are filed with `--after-release mthds-python@X.Y.Z` instead of alongside a release item, since the ledger retired its release item type. Closes L-261003-7c4b4f · Advances L-261003-0dbbf4 Written for commit 8a2ffb373f92c035d8bfc7ba8817e50947366f13. Summary will update on new commits. Review in cubic Co-authored-by: Claude Opus 5.5 --- .claude/skills/release/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md index 5798732..03ad51b 100644 --- a/.claude/skills/release/SKILL.md +++ b/.claude/skills/release/SKILL.md @@ -79,5 +79,5 @@ Nothing in CI compares the new version against the one already on `main`: `versi - **No pre-release form reaches `main`.** `publish.yml` does detect a PEP 440 `a`, `b` or `rc` suffix and marks the GitHub Release a pre-release, which reads like permission — but nothing can carry such a version through: `guard-branches.yml` refuses a `release/v0.6.0rc1` head into `main` outright, and `version-check.yml` fails on the mismatch between a suffixed `pyproject.toml` version and the plain number in a `release/vX.Y.Z` branch name. Ship a plain `X.Y.Z`. The `pre-release/v*` base branch that `guard-branches.yml`'s `gate-release` job tolerates publishes nothing either, since `publish.yml` fires only on the push to `main`. - **The changelog heading carries the `v`** — `## [vX.Y.Z] - YYYY-MM-DD`, which is exactly what `changelog-check.yml` greps for and what `publish.yml` slices the Release notes out of. No `[Unreleased]` heading is left behind; the next change re-creates one. - **The fixture corpus is half of a pair.** A capture lands in `mthds-python` and `mthds-js` or in neither, so a release is also a statement that the sibling's `dev` carries the same bytes. That is what gate 3 and `protocol-corpus-parity.yml` are for. -- **A release arms no follow-ups on its own.** The ledger's `ledger.toml` declares no `release_followups` for this repo, so anything that must happen after this version ships is filed by hand alongside the release item in the play's step 3. +- **A release arms no follow-ups on its own.** A release files nothing in the ledger, so anything that must happen after this version ships is filed by hand in the play's step 3 with `ledger new --owner … --after-release mthds-python@X.Y.Z`, or put on an item already open with `ledger link --after-release mthds-python@X.Y.Z`, and it becomes ready by itself once the `vX.Y.Z` tag is in this repo's checkout. - **The back-merge is a merge commit.** `dev` carries a `Merge main into dev after the vX.Y.Z release` commit after each release rather than a fast-forward; `/ledger-land` makes it, and `CHANGELOG.md` is the one conflict it expects. From baa11e654b7ab10cda57dd8444a0c8c1c3d7c15c Mon Sep 17 00:00:00 2001 From: Louis Choquel Date: Sat, 3 Oct 2026 22:52:17 +0200 Subject: [PATCH 2/5] =?UTF-8?q?feature/Drop-dictconcept-tolerance=20=C2=B7?= =?UTF-8?q?=20L-260918-436871=20(#82)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `DictStuffAbstract.concept` is a `str` again and `DictConcept` is gone, now that the hosted runtime names every concept by its ref string. A stuff carrying the concept object, or no concept at all, fails validation at its `concept`, both in a `/v1/execute` body and in the working memory `PipelexRunner.execute` reads from the CLI. A capture from a real hosted run under `tests/fixtures/hosted_run/` proves the ref-string form, and the changelog records the break. Closes L-260918-436871 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01VgnpKSRkqm9WgmPgyXFB95 --- ## Summary by cubic `DictStuffAbstract.concept` is a `str` again and `DictConcept` is gone, now that the hosted runtime names every concept by its ref string. A stuff carrying the concept object, or no concept at all, now fails validation at its `concept` both in a `/v1/execute` body and in the working memory `PipelexRunner.execute` reads from the CLI, where the local runner used to reduce the object to a ref and turn a missing concept into `"None"`. A capture from a real hosted run under `tests/fixtures/hosted_run/` proves the ref-string form. **Migration** - Code that read `stuff.concept.code` or `stuff.concept.domain_code` reads the ref string from `stuff.concept`, or from `stuff.concept_ref`, which returns the same string. Written for commit 3abff791f73bb61a7bc4781f9ca0e717a493b286. Summary will update on new commits. Review in cubic --------- Co-authored-by: Claude Opus 5.5 --- CHANGELOG.md | 6 + docs/runners.md | 6 +- mthds/runners/api/models.py | 43 ++----- mthds/runners/pipelex/runner.py | 28 ++--- tests/fixtures/hosted_run/README.md | 9 ++ .../hosted_run/hosted-working-memory.json | 41 ++++++ tests/unit/test_api_runner_wire_parsing.py | 73 ++++++++--- tests/unit/test_data.py | 118 ++++++++++++++---- tests/unit/test_pipelex_runner.py | 55 +++++++- 9 files changed, 289 insertions(+), 90 deletions(-) create mode 100644 tests/fixtures/hosted_run/README.md create mode 100644 tests/fixtures/hosted_run/hosted-working-memory.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 24a4a28..e501091 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [Unreleased] + +### Removed + +- **`DictConcept`, and the concept object as a stuff's `concept` (Breaking)**: `DictStuffAbstract.concept` is a `str` again, the concept's ref string, which is what the standard puts on the wire and what the hosted runner now emits. A stuff carrying the concept object in its place, or no concept at all, now fails validation at its `concept`, in a `/v1/execute` body and in the working memory `PipelexRunner.execute` reads from the CLI alike, where the local runner used to reduce the object to a ref and turn a missing concept into the ref `"None"`. Code that read `stuff.concept.code` or `stuff.concept.domain_code` reads the ref string from `stuff.concept`, or from `stuff.concept_ref`, which returns the same string. + ## [v0.18.0] - 2026-10-03 ### Added diff --git a/docs/runners.md b/docs/runners.md index 3465f95..7a44991 100644 --- a/docs/runners.md +++ b/docs/runners.md @@ -8,7 +8,7 @@ The protocol contract and its implementations live in separate packages: - `mthds/protocol/` — the MTHDS Protocol itself: `protocol.py` (the `MTHDSProtocol` interface), `models.py` (the run/discovery wire models — `RunResultExecute`, `RunResultStart`, `ModelDeck`, `ValidationReport`, `VersionInfo`), `pipe_io_contracts.py` and `input_form.py` (the two validate artifacts the standard owns — see [The validate artifacts](#the-validate-artifacts-pipe_io_contracts-and-input_form) below), `method_files.py` (the catalog serialization of a stored method's source — see [The catalog serialization](#the-catalog-serialization-method_files) below), `options.py` (the run-source argument surface — see [Run sources: what may be combined](#run-sources-what-may-be-combined) below), `exceptions.py` (`PipelineRequestError`), and the protocol's domain shapes — `concept.py`, `stuff.py`, `working_memory.py`, `pipe_output.py`, `pipeline_inputs.py` (the abstract, non-Dict base models the protocol is defined in terms of). - `mthds/runners/` — every runner implementation, one subpackage per runner: - - `api/` — the API runner: `client.py` (`MthdsAPIClient`, one file with its helpers), `models.py` (the Dict-serialized wire models — `DictConcept`, `DictStuffAbstract`, `DictWorkingMemoryAbstract`, `DictPipeOutputAbstract`, `DictRunResultExecute` — the runners' concrete JSON materialization of the protocol's domain shapes), `exceptions.py` (`ApiResponseError` for a non-2xx answer, API auth, and the protocol's 202-degrade error, `RunStillRunningError`), `problem.py` (`ProblemDocument` and `UserAction`, the lenient read of a refused request's problem members — see [Errors: `ApiResponseError`](#errors-apiresponseerror) below), `user_agent.py` (the `User-Agent` builder and `AppInfo` — see [Client identification](#client-identification-user-agent) below). + - `api/` — the API runner: `client.py` (`MthdsAPIClient`, one file with its helpers), `models.py` (the Dict-serialized wire models — `DictStuffAbstract`, `DictWorkingMemoryAbstract`, `DictPipeOutputAbstract`, `DictRunResultExecute` — the runners' concrete JSON materialization of the protocol's domain shapes), `exceptions.py` (`ApiResponseError` for a non-2xx answer, API auth, and the protocol's 202-degrade error, `RunStillRunningError`), `problem.py` (`ProblemDocument` and `UserAction`, the lenient read of a refused request's problem members — see [Errors: `ApiResponseError`](#errors-apiresponseerror) below), `user_agent.py` (the `User-Agent` builder and `AppInfo` — see [Client identification](#client-identification-user-agent) below). - `pipelex/runner.py` — `PipelexRunner`, the local runner that shells out to the `pipelex` CLI. - `types.py` — `RunnerType`. @@ -64,9 +64,9 @@ async with MthdsAPIClient() as client: `models` answers with a `ModelDeck`, whose `models[]` entries carry a `name` and a `type`, the entry's category; a runner's own routing metadata (aliases, fallback chains) rides `model_extra`. The protocol's categories are the language's settings families — `llm`, `extract`, `img_gen`, `search` and `judgment` — and `ModelCategory` carries them. The deck is read openly, as the protocol's reader rule requires ("A client reading a model list MUST NOT fail it because an entry carries a category it does not recognize; it keeps that entry with its raw value or leaves it out."): a category this package knows reads as its `ModelCategory` member, and one it does not, which a runner of a later protocol minor may report, keeps its raw string, so a consumer handles a `str` `type` instead of the whole call failing. The `category` filter stays closed: a runner answers a category it does not define with a `422`, raised as `ApiResponseError`. -The Dict wire models are extension-open at every level, matching the protocol's OpenAPI spec (which declares `pipe_output` as an open object). Base fields are typed; anything more a runner returns rides `model_extra`. The hosted `pipelex-api` runner dumps its full `PipeOutput` — per-stuff `stuff_code` / `stuff_name`, pipe-output `graph_spec` / `tokens_usages` / `working_memory_raw` and assembly errors — and all of it is preserved. A stuff's `concept` accepts both wire forms: the reduced namespaced ref string (what this SDK's own serialization emits) or the full concept object (`DictConcept`, what the hosted runner dumps); `DictStuffAbstract.concept_ref` normalizes either form to the ref string. +The Dict wire models are extension-open at every level, matching the protocol's OpenAPI spec (which declares `pipe_output` as an open object). Base fields are typed; anything more a runner returns rides `model_extra`. The hosted `pipelex-api` runner dumps its full `PipeOutput` — per-stuff `stuff_code` / `stuff_name`, pipe-output `graph_spec` / `tokens_usages` / `working_memory_raw` and assembly errors — and all of it is preserved. The openness stops at a stuff's `concept`, which is the concept's ref string and nothing else: `DictStuffAbstract.concept` is a `str`, and a body whose stuff carries the concept object in its place fails validation at that stuff's `concept`. The local `PipelexRunner` holds the working memory the CLI writes to the same rule, passing each stuff's `concept` through rather than reducing whatever it finds, so a stuff with no concept or with the concept object is refused there too. That is the standard's wire form, and the hosted runner emits it — `tests/fixtures/hosted_run/` holds a working memory the hosted plane really delivered, and the suite parses it. `DictStuffAbstract.concept_ref` returns the same string. -On the producing side the protocol's own domain shapes say the same thing, and say it once: `ConceptAbstract` carries the reference alone (`code` + `domain_code`, with `concept_ref` joining them), and `StuffAbstract` serializes its `concept` field through that property, so every dump of a stuff emits `{"concept": ".", "content": …}` without a runner having to remember to reduce it. That is the standard's rule — a concept's definition (its description, its structure, what it refines, any runtime class name) belongs to the library the method loads and never travels beside a stuff, per the "Stuffs on the Wire" section of the [CLI I/O contract](https://mthds.ai/latest/spec/cli-io-contract/). A runtime that needs more of the definition in memory declares it on its own `ConceptAbstract` subclass, where it stays out of the wire form. Two consequences are worth naming. The crate key for a concept a dependency contributes is `::.`, and the protocol model holds no declaring package address, so producing that form is a subclass's business — it overrides `StuffAbstract.serialize_concept` under that name, since a second `field_serializer` for the field under any other name is a class-definition error and a serializer re-annotated onto the field is ignored silently. Which override point a runtime picks decides what this SDK's own output path emits, since `DictRunResultExecute.from_pipe_output` reduces a stuff by `concept_ref` rather than through the serializer: a `concept_ref` override reaches it, a `serialize_concept` override does not. And the dump is one-way: these shapes are strict, so a stuff, a working memory or a pipe output cannot be validated back from what it dumped, the definition such a parse would need being precisely what no longer travels. A consumer that re-validated a dump resolves the concept by ref through the library the method loads instead, as the input side already does; the `Dict` wire models above are the other way to read one back, since they accept either form. +On the producing side the protocol's own domain shapes say the same thing, and say it once: `ConceptAbstract` carries the reference alone (`code` + `domain_code`, with `concept_ref` joining them), and `StuffAbstract` serializes its `concept` field through that property, so every dump of a stuff emits `{"concept": ".", "content": …}` without a runner having to remember to reduce it. That is the standard's rule — a concept's definition (its description, its structure, what it refines, any runtime class name) belongs to the library the method loads and never travels beside a stuff, per the "Stuffs on the Wire" section of the [CLI I/O contract](https://mthds.ai/latest/spec/cli-io-contract/). A runtime that needs more of the definition in memory declares it on its own `ConceptAbstract` subclass, where it stays out of the wire form. Two consequences are worth naming. The crate key for a concept a dependency contributes is `::.`, and the protocol model holds no declaring package address, so producing that form is a subclass's business — it overrides `StuffAbstract.serialize_concept` under that name, since a second `field_serializer` for the field under any other name is a class-definition error and a serializer re-annotated onto the field is ignored silently. Which override point a runtime picks decides what this SDK's own output path emits, since `DictRunResultExecute.from_pipe_output` reduces a stuff by `concept_ref` rather than through the serializer: a `concept_ref` override reaches it, a `serialize_concept` override does not. And the dump is one-way: these shapes are strict, so a stuff, a working memory or a pipe output cannot be validated back from what it dumped, the definition such a parse would need being precisely what no longer travels. A consumer that re-validated a dump resolves the concept by ref through the library the method loads instead, as the input side already does; the `Dict` wire models above are the other way to read one back, since they take the concept as its ref string and never ask for its definition. ### Errors: `ApiResponseError` diff --git a/mthds/runners/api/models.py b/mthds/runners/api/models.py index 17000cb..69ff16b 100644 --- a/mthds/runners/api/models.py +++ b/mthds/runners/api/models.py @@ -1,11 +1,11 @@ """Dict-serialized wire models — the concrete JSON materialization of the protocol's domain shapes. These are the JSON forms the runners deal in: each `Stuff` carrying a `concept` -(the namespaced ref string, or the full concept object a runner may dump) and a -`content`, working memory as a flat root + aliases, the pipe-output as that -working memory + a run id, and `DictRunResultExecute` as the protocol's -`RunResult` carrying a `DictPipeOutput`. The abstract (non-dict) domain shapes -these mirror live in `mthds.protocol.*`. +— the concept's ref string, never its definition, which belongs to the library +the method loads — and a `content`, working memory as a flat root + aliases, +the pipe-output as that working memory + a run id, and `DictRunResultExecute` +as the protocol's `RunResult` carrying a `DictPipeOutput`. The abstract +(non-dict) domain shapes these mirror live in `mthds.protocol.*`. Like every response model in `mthds.protocol.models`, these are extension-open (`extra="allow"`): the protocol's OpenAPI spec declares @@ -13,9 +13,10 @@ shapes than the base fields named here — the hosted `pipelex-api` runner dumps its full `PipeOutput` (per-stuff `stuff_code` / `stuff_name`, pipe-output `graph_spec` / `tokens_usages` / `working_memory_raw`, …). Anything beyond the -base fields rides `model_extra`, never named by this SDK. This SDK's own -serialization (`from_pipe_output`) emits the reduced form: `concept` as the -ref string, base fields only. +base fields rides `model_extra`, never named by this SDK. The openness stops +at `concept` itself: a stuff that carries the concept object instead of its +ref is refused rather than tolerated. This SDK's own serialization +(`from_pipe_output`) emits base fields only. These shapes are brand-neutral (no `pipelex_` prefix) and are a shared wire contract: the in-process `PipelexRunner` and the `MthdsAPIClient` both deal in @@ -43,35 +44,15 @@ MAIN_STUFF_NAME = "main_stuff" -class DictConcept(BaseModel): - """The full-object wire form of a stuff's `concept`. - - A runner may dump the whole concept object instead of the reduced ref - string (the hosted `pipelex-api` runner does). Only the two fields needed - to reconstitute the namespaced ref are required; anything more - (`description`, `structure_class_name`, `refines`, …) rides `model_extra`. - """ - - model_config = ConfigDict(extra="allow", strict=True) - code: str - domain_code: str - - @property - def concept_ref(self) -> str: - return f"{self.domain_code}.{self.code}" - - class DictStuffAbstract(BaseModel, ABC): model_config = ConfigDict(extra="allow", strict=True) - concept: str | DictConcept + concept: str content: Any @property def concept_ref(self) -> str: - """The namespaced concept ref, whichever wire form `concept` arrived in.""" - if isinstance(self.concept, str): - return self.concept - return self.concept.concept_ref + """The concept's ref string, as the stuff carried it under `concept`.""" + return self.concept class DictWorkingMemoryAbstract(BaseModel, ABC): diff --git a/mthds/runners/pipelex/runner.py b/mthds/runners/pipelex/runner.py index 0a4f0a4..c44e81d 100644 --- a/mthds/runners/pipelex/runner.py +++ b/mthds/runners/pipelex/runner.py @@ -83,10 +83,12 @@ def _run_result_from_working_memory_dump(raw_memory: dict[str, Any]) -> DictRunR """Map the CLI's working-memory dump onto the SDK's DictRunResultExecute shape. `pipelex run ... --working-memory-path` writes the runtime's full working - memory (`{root: {name: {stuff_code, stuff_name, concept: {...}, content}}, - aliases}`). The SDK's wire shape keeps only `{concept: , - content}` per stuff — the same reduction the API runner performs - server-side. + memory (`{root: {name: {stuff_code, stuff_name, concept, content}}, + aliases}`), each stuff naming its concept by its ref string. The SDK's wire + shape keeps only `{concept, content}` per stuff. The concept is passed + through as written, so a stuff that carries anything but its ref string — + the concept object, or no concept at all — is refused here exactly as it is + on the API path. Args: raw_memory: The parsed working-memory JSON written by the CLI. @@ -94,22 +96,16 @@ def _run_result_from_working_memory_dump(raw_memory: dict[str, Any]) -> DictRunR Returns: DictRunResultExecute for the completed local run (no run id — local runs are not tracked). + + Raises: + ValidationError: If a stuff's `concept` is missing or is not a string. """ dict_root: dict[str, dict[str, Any]] = {} raw_root_obj = raw_memory.get("root", {}) raw_root: dict[str, Any] = cast("dict[str, Any]", raw_root_obj) if isinstance(raw_root_obj, dict) else {} for stuff_name, stuff_raw in raw_root.items(): stuff: dict[str, Any] = cast("dict[str, Any]", stuff_raw) if isinstance(stuff_raw, dict) else {} - concept_raw = stuff.get("concept") - concept_ref: str - if isinstance(concept_raw, dict): - concept_dict = cast("dict[str, Any]", concept_raw) - code = str(concept_dict.get("code", "")) - domain_code = concept_dict.get("domain_code") - concept_ref = f"{domain_code}.{code}" if domain_code else code - else: - concept_ref = str(concept_raw) - dict_root[stuff_name] = {"concept": concept_ref, "content": stuff.get("content")} + dict_root[stuff_name] = {"concept": stuff.get("concept"), "content": stuff.get("content")} aliases_obj = raw_memory.get("aliases", {}) aliases: dict[str, str] = cast("dict[str, str]", aliases_obj) if isinstance(aliases_obj, dict) else {} @@ -202,6 +198,10 @@ async def execute( Raises: PipelexRunnerError: If pipelex execution fails, or if extension args are passed (the CLI runner accepts none). + pydantic.ValidationError: If the working memory the CLI wrote does not + fit the wire shape — a stuff carrying anything but its concept's ref + string under `concept`, as a `pipelex` built on an mthds older than + 0.15.0 writes it. Surfaced raw, as a malformed body is on the API path. """ if extra: msg = f"The pipelex CLI runner defines no extension args; got {sorted(extra)}." diff --git a/tests/fixtures/hosted_run/README.md b/tests/fixtures/hosted_run/README.md new file mode 100644 index 0000000..4cb1c1c --- /dev/null +++ b/tests/fixtures/hosted_run/README.md @@ -0,0 +1,9 @@ +# Hosted-run capture + +`hosted-working-memory.json` is what the hosted plane really delivered for a run: the `working_memory.json` artifact of a run of the catalog method `joke_judge`, fetched from storage on `api-dev.pipelex.com` on 2026-09-19, after the deploy that carried `pipelex` 0.60.0 into the hosted plane. It is not hand-written, and that is the point. `DictStuffAbstract.concept` is a plain string because the runtime names a stuff's concept by its ref string on the wire, and a fixture written by hand would only prove that this repo agrees with itself. + +The file is a byte-for-byte copy of `conformance/tests/hosted_run/fixtures/hosted-working-memory.json` as committed in `conformance` at `5596e69`, whose `fixtures/README.md` holds the full provenance and the recipe for taking a new capture. Do not edit it: a change is a new capture. + +The method was chosen because its working memory covers the cases that matter together: a native concept (`native.Text`), two domain concepts refining `Text` (`joke_judge.Joke`, `joke_judge.JokeAnalysis`) and a domain concept with a structure of its own (`joke_judge.FunninessVerdict`). The stuffs also carry the runtime's extension fields `stuff_code` and `stuff_name`, and the working memory its `absences`, so the capture exercises the models' extension-openness at the same time. + +Only the delivered working memory is copied, not the blocking `/v1/execute` body that `conformance` captured beside it. That body's pipe output also carries the run's graph and token usage, which name the hosted plane's model routing and its per-call costs, and those do not belong in a public repository. The working memory is the part `DictStuffAbstract.concept` governs, and the run envelope around it is covered by the hand-built bodies in `tests/unit/test_data.py`. diff --git a/tests/fixtures/hosted_run/hosted-working-memory.json b/tests/fixtures/hosted_run/hosted-working-memory.json new file mode 100644 index 0000000..aaaa42d --- /dev/null +++ b/tests/fixtures/hosted_run/hosted-working-memory.json @@ -0,0 +1,41 @@ +{ + "root": { + "topic": { + "stuff_code": "85oRc", + "stuff_name": "topic", + "concept": "native.Text", + "content": { + "text": "terraform state drift" + } + }, + "joke": { + "stuff_code": "NcJMT", + "stuff_name": "joke", + "concept": "joke_judge.Joke", + "content": { + "text": "My infrastructure and my Terraform state stopped speaking to each other after someone made a change in the AWS console. Now they're in couples therapy, and the therapist keeps saying, \"Have you tried `terraform plan`?\"—but neither of them wants to admit who's really out of sync." + } + }, + "verdict": { + "stuff_code": "DDXEe", + "stuff_name": "verdict", + "concept": "joke_judge.FunninessVerdict", + "content": { + "is_funny": true, + "reasoning": "The joke works because it maps a real, relatable DevOps pain point—infrastructure drift caused by manual AWS console changes—onto a couples therapy scenario. The personification of infrastructure and Terraform state as a bickering couple is clever, and the punchline \"Have you tried `terraform plan`?\" is a genuinely apt gag since `terraform plan` is exactly what reveals the drift/out-of-sync state. The final beat about \"neither wants to admit who's really out of sync\" adds a nice thematic payoff that ties the metaphor together. It's niche and requires domain knowledge to land, but for the intended technical audience it's original and well-constructed." + } + }, + "analysis": { + "stuff_code": "kmGpN", + "stuff_name": "analysis", + "concept": "joke_judge.JokeAnalysis", + "content": { + "text": "Oh, this joke. This joke reads like it was generated by a LinkedIn thought leader who just discovered analogies and won't shut up about it.\n\nLet's start with the setup, which has the pacing of a Terraform apply on a Friday afternoon—slow, anxious, and about to break production. You've got a run-on sentence so bloated it needs its own autoscaling group. By the time we reach \"couples therapy,\" the audience has already drifted further than your actual infrastructure.\n\nAnd the personification! Oh, the personification. \"My infrastructure and my Terraform state stopped speaking to each other.\" Bold move, giving a HCL file feelings when you couldn't even give this joke a rhythm. This is the comedic equivalent of naming your servers after Greek gods and thinking that counts as a personality.\n\nThe punchline—\"Have you tried `terraform plan`?\"—isn't a joke, it's documentation. You didn't write a gag, you wrote the answer to a Stack Overflow question and slapped a therapist onto it. That's not comedy, that's a support ticket with delusions of grandeur. The therapist is literally just... correct? The whole bit collapses because there's no absurdity, no reversal, no surprise. It's like watching someone set up dominoes and then gently place the last one down by hand.\n\nThen comes the \"thematic payoff\": \"neither of them wants to admit who's really out of sync.\" My guy. We KNOW who's out of sync. It's the console. It's ALWAYS the console. YOU changed it. You are the affair. You are the manual intervention. The joke frames this as some ambiguous emotional standoff when it's actually a confession you're too cowardly to make.\n\nThe real tragedy? Infrastructure drift is *genuinely funny* territory and you squandered it. You had a whole marriage-counseling framework and the best you could do was cite a CLI command. No `terraform destroy` divorce bit. No \"state file kept a secret backup.\" No mention that the therapist bills by the hour like AWS bills by the second. You left comedic money on the table like an idle EC2 instance nobody remembered to shut down.\n\nVerdict: this joke doesn't need a therapist. It needs a `terraform destroy`." + } + } + }, + "aliases": { + "main_stuff": "analysis" + }, + "absences": {} +} diff --git a/tests/unit/test_api_runner_wire_parsing.py b/tests/unit/test_api_runner_wire_parsing.py index 14ffc79..9bd531e 100644 --- a/tests/unit/test_api_runner_wire_parsing.py +++ b/tests/unit/test_api_runner_wire_parsing.py @@ -1,27 +1,55 @@ -"""Unit tests for parsing `/v1/execute` 200 bodies into DictRunResultExecute — both wire forms.""" +"""Unit tests for parsing `/v1/execute` 200 bodies and hosted working memories into the Dict wire models.""" -from mthds.runners.api.models import DictConcept, DictRunResultExecute -from tests.unit.test_data import ExecuteWireResponses +import json +from pathlib import Path +from typing import Any + +import pytest +from pydantic import ValidationError + +from mthds.runners.api.models import DictRunResultExecute, DictWorkingMemoryAbstract +from tests.unit.test_data import ExecuteWireResponses, HostedRunCapture + +_CAPTURE_PATH = Path(__file__).resolve().parent.parent / "fixtures" / "hosted_run" / HostedRunCapture.FILE_NAME + + +def _load_hosted_capture() -> dict[str, Any]: + with _CAPTURE_PATH.open(encoding="utf-8") as capture_file: + capture: dict[str, Any] = json.load(capture_file) + return capture class TestDictRunResultExecuteWireParsing: + def test_hosted_capture_names_every_concept_by_its_ref(self) -> None: + """A working memory the hosted plane really delivered parses, and every stuff in it + carries its concept as the ref string — native, refining and structured concepts alike. + """ + working_memory = DictWorkingMemoryAbstract.model_validate(_load_hosted_capture()) + + concept_refs = {slot_name: stuff.concept for slot_name, stuff in working_memory.root.items()} + assert concept_refs == HostedRunCapture.EXPECTED_CONCEPT_REFS + for stuff in working_memory.root.values(): + assert stuff.concept_ref == stuff.concept + assert stuff.model_extra is not None + assert set(stuff.model_extra) == HostedRunCapture.EXTENSION_FIELDS + assert working_memory.aliases == {"main_stuff": HostedRunCapture.MAIN_STUFF_SLOT} + assert working_memory.model_extra == {"absences": {}} + def test_hosted_full_dump_parses(self) -> None: - """The hosted runner's enriched shape (full PipeOutput dump) validates: - per-stuff and pipe-output extras ride model_extra instead of being - rejected, and `concept` parses as the full object form. + """The hosted runner's enriched shape (full PipeOutput dump) validates, with `concept` + as the ref string. """ result = DictRunResultExecute.model_validate(ExecuteWireResponses.HOSTED_FULL_DUMP) assert result.pipeline_run_id == "run_7f3a" main = result.pipe_output.working_memory.root["extracted_entities"] - assert isinstance(main.concept, DictConcept) - assert main.concept.code == "ExtractedEntities" + assert main.concept == "extract_entities.ExtractedEntities" assert main.concept_ref == "extract_entities.ExtractedEntities" assert main.content == {"entities": [{"name": "Marie Curie", "kind": "person"}]} def test_hosted_extras_ride_model_extra(self) -> None: - """Extension fields land in model_extra at every level — top-level run - state, pipe-output artifacts, per-stuff naming, and concept details. + """Extension fields land in model_extra at every level — top-level run state, + pipe-output artifacts, the working memory's absences and per-stuff naming. """ result = DictRunResultExecute.model_validate(ExecuteWireResponses.HOSTED_FULL_DUMP) @@ -30,13 +58,9 @@ def test_hosted_extras_ride_model_extra(self) -> None: assert result.pipe_output.model_extra is not None assert result.pipe_output.model_extra["graph_spec"] == {"nodes": [], "edges": []} assert result.pipe_output.model_extra["tokens_usages"] == [] + assert result.pipe_output.working_memory.model_extra == {"absences": {}} main = result.pipe_output.working_memory.root["extracted_entities"] - assert main.model_extra is not None - assert main.model_extra["stuff_code"] == "e5f6a7b8" - assert main.model_extra["stuff_name"] == "extracted_entities" - assert isinstance(main.concept, DictConcept) - assert main.concept.model_extra is not None - assert main.concept.model_extra["structure_class_name"] == "extract_entities__ExtractedEntities" + assert main.model_extra == {"stuff_code": "e5f6a7b8", "stuff_name": "extracted_entities"} def test_hosted_dump_keeps_content_reachable(self) -> None: """`pipe_output.model_dump()` keeps the consumer path @@ -53,11 +77,22 @@ def test_hosted_dump_keeps_content_reachable(self) -> None: assert dumped["graph_spec"] == {"nodes": [], "edges": []} def test_reduced_form_parses(self) -> None: - """The SDK's own reduced form (`concept` as the ref string) still - validates, and `concept_ref` normalizes both wire forms to the same ref. - """ + """The SDK's own reduced form (base fields only) validates.""" result = DictRunResultExecute.model_validate(ExecuteWireResponses.REDUCED) main = result.pipe_output.working_memory.root["extracted_entities"] assert main.concept == "extract_entities.ExtractedEntities" assert main.concept_ref == "extract_entities.ExtractedEntities" + assert main.model_extra == {} + + def test_concept_object_is_refused(self) -> None: + """A stuff carrying the concept's definition in place of its ref is refused, and the + error names the `concept` of that stuff rather than the body as a whole. + """ + with pytest.raises(ValidationError) as exc_info: + DictRunResultExecute.model_validate(ExecuteWireResponses.CONCEPT_OBJECT) + + errors = exc_info.value.errors() + assert [(error["loc"], error["type"]) for error in errors] == [ + (("pipe_output", "working_memory", "root", "extracted_entities", "concept"), "string_type"), + ] diff --git a/tests/unit/test_data.py b/tests/unit/test_data.py index 592e018..fecb01c 100644 --- a/tests/unit/test_data.py +++ b/tests/unit/test_data.py @@ -16,12 +16,14 @@ class ExecuteWireResponses: - """Captured `/v1/execute` 200 bodies in the wire forms a compliant runner may return.""" - - # The hosted pipelex-api runner's shape (captured live against api-dev on 2026-07-03): - # the full PipeOutput dump — per-stuff `stuff_code` / `stuff_name` and `concept` as the - # full object, pipe-output extras (`graph_spec`, `tokens_usages`, `working_memory_raw`, - # assembly errors), and run-lifecycle extras at the top level. + """`/v1/execute` 200 bodies: the shapes a compliant runner returns, and one it must not.""" + + # The hosted pipelex-api runner's shape: the full PipeOutput dump, every stuff naming its + # concept by its ref string, with per-stuff `stuff_code` / `stuff_name`, the working + # memory's `absences`, pipe-output extras (`graph_spec`, `tokens_usages`, + # `working_memory_raw`, assembly errors) and run-lifecycle extras at the top level. The key + # set follows the blocking response conformance captured from the hosted plane; the values + # are hand-built, the real capture being `tests/fixtures/hosted_run/`. HOSTED_FULL_DUMP: ClassVar[dict[str, Any]] = { "pipeline_run_id": "run_7f3a", "created_at": "2026-07-03T09:15:01.000000+00:00", @@ -35,40 +37,30 @@ class ExecuteWireResponses: "text": { "stuff_code": "a1b2c3d4", "stuff_name": "text", - "concept": { - "code": "Text", - "domain_code": "native", - "description": "A text", - "structure_class_name": "TextContent", - "refines": None, - }, + "concept": "native.Text", "content": {"text": "Marie Curie joined the University of Paris in 1906."}, }, "extracted_entities": { "stuff_code": "e5f6a7b8", "stuff_name": "extracted_entities", - "concept": { - "code": "ExtractedEntities", - "domain_code": "extract_entities", - "description": "Entities extracted from a text", - "structure_class_name": "extract_entities__ExtractedEntities", - "refines": None, - }, + "concept": "extract_entities.ExtractedEntities", "content": {"entities": [{"name": "Marie Curie", "kind": "person"}]}, }, }, "aliases": {"main_stuff": "extracted_entities"}, + "absences": {}, }, "working_memory_raw": {"root": {}, "aliases": {}}, "graph_spec": {"nodes": [], "edges": []}, "graph_assembly_error": None, + "pipe_io_artifacts": None, + "pipe_io_artifacts_error": None, "tokens_usages": [], "usage_assembly_error": None, }, } - # The reduced form this SDK's own serialization (`from_pipe_output`) emits: - # `concept` as the namespaced ref string, base fields only. + # The reduced form this SDK's own serialization (`from_pipe_output`) emits: base fields only. REDUCED: ClassVar[dict[str, Any]] = { "pipeline_run_id": "run_7f3a", "pipe_output": { @@ -86,6 +78,88 @@ class ExecuteWireResponses: "main_stuff_name": "extracted_entities", } + # What the hosted runner sent before the runtime stopped dumping the concept object onto the + # wire, abridged from a body captured against api-dev on 2026-07-03: the concept's whole + # definition riding the stuff in place of its ref. The standard names a stuff's concept by its + # ref string, so this body is refused, at the `concept` of the stuff that carries the object. + CONCEPT_OBJECT: ClassVar[dict[str, Any]] = { + "pipeline_run_id": "run_7f3a", + "pipe_output": { + "pipeline_run_id": "run_7f3a", + "working_memory": { + "root": { + "extracted_entities": { + "stuff_code": "e5f6a7b8", + "stuff_name": "extracted_entities", + "concept": { + "code": "ExtractedEntities", + "domain_code": "extract_entities", + "description": "Entities extracted from a text", + "structure_class_name": "extract_entities__ExtractedEntities", + "refines": None, + }, + "content": {"entities": [{"name": "Marie Curie", "kind": "person"}]}, + }, + }, + "aliases": {"main_stuff": "extracted_entities"}, + }, + }, + } + + +class HostedRunCapture: + """What the real hosted working memory in `tests/fixtures/hosted_run/` holds.""" + + FILE_NAME: ClassVar[str] = "hosted-working-memory.json" + + # Every slot of the captured working memory, mapped to the concept ref it names. Between them + # they cover a native concept, two domain concepts refining `Text` and a structured one, so a + # narrower re-capture fails here rather than quietly testing less. + EXPECTED_CONCEPT_REFS: ClassVar[dict[str, str]] = { + "topic": "native.Text", + "joke": "joke_judge.Joke", + "verdict": "joke_judge.FunninessVerdict", + "analysis": "joke_judge.JokeAnalysis", + } + MAIN_STUFF_SLOT: ClassVar[str] = "analysis" + EXTENSION_FIELDS: ClassVar[frozenset[str]] = frozenset({"stuff_code", "stuff_name"}) + + +class CliWorkingMemoryDumps: + """Working memories as `pipelex run --working-memory-path` writes them, and two it must not.""" + + # The runtime's `smart_dump()`: each stuff names its concept by its ref string beside its + # `stuff_code` and `stuff_name`. + REF_STRING: ClassVar[dict[str, Any]] = { + "root": { + "answer": { + "stuff_code": "k3Rt9", + "stuff_name": "answer", + "concept": "answer.Answer", + "content": {"text": "Because."}, + }, + }, + "aliases": {"main_stuff": "answer"}, + } + + # Each case is a malformed `answer` stuff, which the local runner refuses at its `concept` + # rather than inventing a ref for it. + MALFORMED_CONCEPT_CASES: ClassVar[list[tuple[str, dict[str, Any]]]] = [ + ( + "concept-object", + { + "stuff_code": "k3Rt9", + "stuff_name": "answer", + "concept": {"code": "Answer", "domain_code": "answer", "description": "An answer"}, + "content": {"text": "Because."}, + }, + ), + ( + "concept-missing", + {"stuff_code": "k3Rt9", "stuff_name": "answer", "content": {"text": "Because."}}, + ), + ] + class ModelDeckWireBodies: """`GET /models` 200 bodies, among them one a runner of a later protocol minor may send.""" diff --git a/tests/unit/test_pipelex_runner.py b/tests/unit/test_pipelex_runner.py index bae5d0d..87c8704 100644 --- a/tests/unit/test_pipelex_runner.py +++ b/tests/unit/test_pipelex_runner.py @@ -6,10 +6,11 @@ from typing import TYPE_CHECKING, Any, cast import pytest -from pydantic import BaseModel +from pydantic import BaseModel, ValidationError from pytest_mock import MockerFixture from mthds.runners.pipelex.runner import PipelexRunner, PipelexRunnerError +from tests.unit.test_data import CliWorkingMemoryDumps if TYPE_CHECKING: from mthds.protocol.pipeline_inputs import PipelineInputs @@ -103,3 +104,55 @@ class _Result: assert inputs_path.exists() on_disk = json.loads(inputs_path.read_text(encoding="utf-8")) assert on_disk == {"input": {"question": "why?", "score": 0.5}} + + def test_execute_maps_the_cli_working_memory(self, mocker: MockerFixture, tmp_path: Path) -> None: + """The working memory the CLI writes maps onto the wire shape: each stuff keeps its + concept ref and its content, and its runtime fields are dropped. + """ + _patch_cli_writing_working_memory(mocker, tmp_path, CliWorkingMemoryDumps.REF_STRING) + + result = asyncio.run(PipelexRunner().execute(pipe_code="answer")) + + answer = result.pipe_output.working_memory.root["answer"] + assert answer.concept == "answer.Answer" + assert answer.content == {"text": "Because."} + assert answer.model_extra == {} + assert result.model_extra is not None + assert result.model_extra["main_stuff_name"] == "answer" + + @pytest.mark.parametrize( + ("topic", "malformed_stuff"), + CliWorkingMemoryDumps.MALFORMED_CONCEPT_CASES, + ) + def test_execute_refuses_a_stuff_without_a_concept_ref( + self, + mocker: MockerFixture, + tmp_path: Path, + topic: str, + malformed_stuff: dict[str, Any], + ) -> None: + """A stuff whose concept is not its ref string fails validation at that stuff's `concept`, + as on the API path, instead of reaching the caller under an invented ref. + """ + working_memory = {"root": {"answer": malformed_stuff}, "aliases": {"main_stuff": "answer"}} + _patch_cli_writing_working_memory(mocker, tmp_path, working_memory) + + with pytest.raises(ValidationError) as exc_info: + asyncio.run(PipelexRunner().execute(pipe_code="answer")) + + errors = exc_info.value.errors() + assert [(error["loc"], error["type"]) for error in errors] == [(("root", "answer", "concept"), "string_type")], topic + + +def _patch_cli_writing_working_memory(mocker: MockerFixture, tmp_path: Path, working_memory: dict[str, Any]) -> None: + """Stand in for the `pipelex` CLI: a run that succeeds and writes `working_memory` where it was asked to.""" + mocker.patch("mthds.runners.pipelex.runner._ensure_pipelex", return_value="pipelex") + mocker.patch("mthds.runners.pipelex.runner.tempfile.mkdtemp", return_value=str(tmp_path)) + mocker.patch("mthds.runners.pipelex.runner.shutil.rmtree") + + def write_working_memory(cmd: list[str], **_kwargs: Any) -> Any: + working_memory_path = Path(cmd[cmd.index("--working-memory-path") + 1]) + working_memory_path.write_text(json.dumps(working_memory), encoding="utf-8") + return mocker.Mock(returncode=0) + + mocker.patch("mthds.runners.pipelex.runner.run_subprocess", side_effect=write_working_memory) From 4e80b125d456d632f7922a8a2c756cfa3cc8383b Mon Sep 17 00:00:00 2001 From: Louis Choquel Date: Sun, 4 Oct 2026 00:05:31 +0200 Subject: [PATCH 3/5] =?UTF-8?q?feature/Standard-constant-200-210=20=C2=B7?= =?UTF-8?q?=20L-260919-a751c1=20=E2=80=94=20standard=20constant=202.0.0=20?= =?UTF-8?q?->=202.1.1=20(#83)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MTHDS_STANDARD_VERSION moves from 2.0.0 to 2.1.1. Since the previous change dropped the DictConcept tolerance, this package follows the standard's 2.1.0 rule that a stuff's concept on the wire is its crate key, and 2.1.1 changes nothing normative, so the constant goes straight to the standard's current cut. The version test pins the new value and covers the manifest constraints the minor move now rejects, an exact 2.0.0 and a ~2.0.0. Closes L-260919-a751c1 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01VgnpKSRkqm9WgmPgyXFB95 --- ## Summary by cubic Bumps `MTHDS_STANDARD_VERSION` from `2.0.0` to `2.1.1`. The previous change removed the `DictConcept` tolerance, so this package now follows the standard's 2.1.0 rule that a stuff's `concept` on the wire is its crate key; since 2.1.1 changes nothing normative, the constant jumps straight to the standard's current cut. Manifest `mthds_version` constraints are now evaluated against `2.1.1`: `>=2.0.0` and `^2.0.0` still pass, but an exact `2.0.0` or a `~2.0.0` no longer does. `PROTOCOL_VERSION` is unchanged, the docs example is updated, and the version tests pin the new constant and cover the newly rejected constraints. Written for commit f339f912d50b9706da20d6a68dc6ea2885fad66b. Summary will update on new commits. Review in cubic Co-authored-by: Claude Opus 5.5 --- CHANGELOG.md | 4 ++++ docs/versioning.md | 2 +- mthds/package/manifest/schema.py | 2 +- tests/unit/test_versions.py | 9 ++++++--- 4 files changed, 12 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e501091..16788b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Changed + +- **Standard version `2.1.1` (Breaking)**: `MTHDS_STANDARD_VERSION` moved from `2.0.0` to `2.1.1`, since this package now obeys the standard's `2.1.0` rule that a stuff's `concept` on the wire is the concept's crate key, and `2.1.1` changes nothing normative. A manifest's `mthds_version` is evaluated against `2.1.1`, so `>=2.0.0` and `^2.0.0` still pass while an exact `2.0.0` or a `~2.0.0` no longer does, and `pipelex` stamps the crates it normalizes with `2.1.1`. `PROTOCOL_VERSION` is unchanged. + ### Removed - **`DictConcept`, and the concept object as a stuff's `concept` (Breaking)**: `DictStuffAbstract.concept` is a `str` again, the concept's ref string, which is what the standard puts on the wire and what the hosted runner now emits. A stuff carrying the concept object in its place, or no concept at all, now fails validation at its `concept`, in a `/v1/execute` body and in the working memory `PipelexRunner.execute` reads from the CLI alike, where the local runner used to reduce the object to a ref and turn a missing concept into the ref `"None"`. Code that read `stuff.concept.code` or `stuff.concept.domain_code` reads the ref string from `stuff.concept`, or from `stuff.concept_ref`, which returns the same string. diff --git a/docs/versioning.md b/docs/versioning.md index 97b95b1..57f1fd8 100644 --- a/docs/versioning.md +++ b/docs/versioning.md @@ -13,7 +13,7 @@ The first two are **copies of a cut made by the standard**, not decisions this p Two properties of the rule are worth knowing before you read a version out of this package: -- **The two numbers are independent.** The protocol version never tracks the standard version. A release of the standard that leaves the routes alone leaves `PROTOCOL_VERSION` exactly where it is, which is why `0.7.0` and `2.0.0` are as far apart as they are. +- **The two numbers are independent.** The protocol version never tracks the standard version. A release of the standard that leaves the routes alone leaves `PROTOCOL_VERSION` exactly where it is, which is why `0.7.0` and `2.1.1` are as far apart as they are. - **The standard version moves on every release of the specification**, including one that changes nothing normative. So the number rising is not by itself evidence that anything you depend on changed; the standard's changelog is. The corollary is that the pinned native set is identified by the standard version in which it *last changed*, and an implementation of version `V` materializes the set pinned at the greatest version less than or equal to `V`. ## `mthds_version` in a manifest is a constraint diff --git a/mthds/package/manifest/schema.py b/mthds/package/manifest/schema.py index c0f7bd4..acda0cb 100644 --- a/mthds/package/manifest/schema.py +++ b/mthds/package/manifest/schema.py @@ -33,7 +33,7 @@ RESERVED_DOMAINS: frozenset[str] = frozenset({"native", "mthds", "pipelex"}) -MTHDS_STANDARD_VERSION: Final[str] = "2.0.0" +MTHDS_STANDARD_VERSION: Final[str] = "2.1.1" """The MTHDS standard version this library implements. This is a copy of a cut made by the standard, and the only place this library diff --git a/tests/unit/test_versions.py b/tests/unit/test_versions.py index 8797044..089c69e 100644 --- a/tests/unit/test_versions.py +++ b/tests/unit/test_versions.py @@ -12,7 +12,7 @@ def test_constants_match_the_standards_cut(self): # Pinned deliberately: these are copies of a cut made in the standard # (https://mthds.ai/latest/spec/versioning/), so a value moving here without a # cut moving there is exactly what this asserts against. - assert MTHDS_STANDARD_VERSION == "2.0.0" + assert MTHDS_STANDARD_VERSION == "2.1.1" assert PROTOCOL_VERSION == "0.7.0" assert is_valid_semver(MTHDS_STANDARD_VERSION) assert is_valid_semver(PROTOCOL_VERSION) @@ -21,14 +21,17 @@ def test_constants_match_the_standards_cut(self): ("topic", "constraint", "is_satisfied"), [ ("the constraint every manifest written before the cut carries", ">=1.0.0", True), - ("an exact pin on the current standard", "2.0.0", True), + ("an exact pin on the current standard", "2.1.1", True), + ("a package needing the crate-key wire form", ">=2.1.0", True), ("a caret range over the current major", "^2.0.0", True), - ("a tilde range over the current minor", "~2.0.0", True), + ("a tilde range over the current minor", "~2.1.0", True), ("a wildcard over the current major", "2.*", True), ("any standard version at all", "*", True), ("a compound range spelled with a space, as the manifest format documents it", ">=2.0.0, <3.0.0", True), ("the same compound range spelled without one", ">=2.0.0,<3.0.0", True), ("a caret range over the superseded major", "^1.0.0", False), + ("an exact pin on a superseded cut", "2.0.0", False), + ("a tilde range over the superseded minor", "~2.0.0", False), ("a package that predates the cut and says so", "<2.0.0", False), ("a package needing a standard that does not exist yet", ">=3.0.0", False), ("a compound range closing below the current standard", ">=1.0.0, <2.0.0", False), From c050fd2b737040fb2359313e481d897475196eb8 Mon Sep 17 00:00:00 2001 From: Louis Choquel Date: Sun, 4 Oct 2026 00:41:09 +0200 Subject: [PATCH 4/5] =?UTF-8?q?feature/Standard-300-mthds-python=20=C2=B7?= =?UTF-8?q?=20L-261003-c5b343=20(#84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MTHDS_STANDARD_VERSION moves to 3.0.0, the standard's cut that re-pins the native set with the verdict natives, so pipelex can label its natives pinned at 3.0.0. Nothing in this package names the native set by code: Choice and Rating reach the inputs-template projection as object nodes and keep their envelope, while YesNo stays a boolean input, and new test cases pin each one's exact compact slot since the shared corpus captures neither new native. Closes L-261003-c5b343 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01FnWnaN3BHg8B7aG3MyPbvG --- ## Summary by cubic Moves `MTHDS_STANDARD_VERSION` from `2.1.1` to `3.0.0`, the standard's cut that re-pins the native set with the verdict natives. Nothing in this package names the native set in code: `Choice` and `Rating` reach the inputs-template projection as object nodes and keep their `{concept, content}` envelope, while `YesNo` stays a bare boolean input. New test cases pin each verdict native's exact compact slot since the shared corpus captures neither new native, and manifests are now evaluated against `3.0.0`, so exact `2.1.1`, `^2.0.0`, and `~2.0.0` constraints stop passing. Closes L-261003-c5b343. Written for commit 8351c5d4bc02244f0172eafb8536fb07e401a13f. Summary will update on new commits. Review in cubic --------- Co-authored-by: Claude Opus 5.5 --- CHANGELOG.md | 2 +- docs/versioning.md | 2 +- mthds/package/manifest/schema.py | 2 +- tests/unit/test_data.py | 44 ++++++++++++++++++++ tests/unit/test_inputs_template_rendering.py | 11 +++++ tests/unit/test_versions.py | 29 ++++++------- 6 files changed, 73 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 16788b6..4fce85b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Changed -- **Standard version `2.1.1` (Breaking)**: `MTHDS_STANDARD_VERSION` moved from `2.0.0` to `2.1.1`, since this package now obeys the standard's `2.1.0` rule that a stuff's `concept` on the wire is the concept's crate key, and `2.1.1` changes nothing normative. A manifest's `mthds_version` is evaluated against `2.1.1`, so `>=2.0.0` and `^2.0.0` still pass while an exact `2.0.0` or a `~2.0.0` no longer does, and `pipelex` stamps the crates it normalizes with `2.1.1`. `PROTOCOL_VERSION` is unchanged. +- **Standard version `3.0.0` (Breaking)**: `MTHDS_STANDARD_VERSION` moved from `2.0.0` to `3.0.0`, since this package now obeys the standard's `2.1.0` rule that a stuff's `concept` on the wire is the concept's crate key, and the inputs-template projection takes the `3.0.0` verdict natives through the descriptor's own kinds: a compact template keeps the `{concept, content}` envelope of a `Choice` or `Rating` slot, and leaves a `YesNo` slot a bare boolean. A manifest's `mthds_version` is evaluated against `3.0.0`, so `>=2.0.0` and `^3.0.0` pass while `^2.0.0`, `~2.0.0` or an exact `2.0.0` no longer does, and `pipelex` stamps the crates it normalizes with `3.0.0`. `PROTOCOL_VERSION` is unchanged. ### Removed diff --git a/docs/versioning.md b/docs/versioning.md index 57f1fd8..0bad1b1 100644 --- a/docs/versioning.md +++ b/docs/versioning.md @@ -13,7 +13,7 @@ The first two are **copies of a cut made by the standard**, not decisions this p Two properties of the rule are worth knowing before you read a version out of this package: -- **The two numbers are independent.** The protocol version never tracks the standard version. A release of the standard that leaves the routes alone leaves `PROTOCOL_VERSION` exactly where it is, which is why `0.7.0` and `2.1.1` are as far apart as they are. +- **The two numbers are independent.** The protocol version never tracks the standard version. A release of the standard that leaves the routes alone leaves `PROTOCOL_VERSION` exactly where it is, which is why `0.7.0` and `3.0.0` are as far apart as they are. - **The standard version moves on every release of the specification**, including one that changes nothing normative. So the number rising is not by itself evidence that anything you depend on changed; the standard's changelog is. The corollary is that the pinned native set is identified by the standard version in which it *last changed*, and an implementation of version `V` materializes the set pinned at the greatest version less than or equal to `V`. ## `mthds_version` in a manifest is a constraint diff --git a/mthds/package/manifest/schema.py b/mthds/package/manifest/schema.py index acda0cb..29ffdee 100644 --- a/mthds/package/manifest/schema.py +++ b/mthds/package/manifest/schema.py @@ -33,7 +33,7 @@ RESERVED_DOMAINS: frozenset[str] = frozenset({"native", "mthds", "pipelex"}) -MTHDS_STANDARD_VERSION: Final[str] = "2.1.1" +MTHDS_STANDARD_VERSION: Final[str] = "3.0.0" """The MTHDS standard version this library implements. This is a copy of a cut made by the standard, and the only place this library diff --git a/tests/unit/test_data.py b/tests/unit/test_data.py index fecb01c..2dac019 100644 --- a/tests/unit/test_data.py +++ b/tests/unit/test_data.py @@ -3,14 +3,17 @@ from typing import Any, ClassVar from mthds.protocol.input_form import ( + BooleanField, DateField, ImageItem, InputFormField, ListField, + NumberField, ObjectField, ObjectItem, TextField, TextItem, + UnknownField, ) from mthds.protocol.pipe_io_contracts import PresenceMarker @@ -673,6 +676,47 @@ class CompactSlotCases: ), ] + # The verdict natives the standard pinned at 3.0.0, with their fields as the input-form descriptor + # states them: a `dict` member is `unknown`, and `integer` is a `number` with `integer: true`. + CHOICE_PAYLOAD: ClassVar[list[InputFormField]] = [ + TextField(name="choice", required=True), + NumberField(name="confidence", required=False, integer=False), + UnknownField(name="probabilities", required=False), + ] + RATING_PAYLOAD: ClassVar[list[InputFormField]] = [ + NumberField(name="level", required=True, integer=True), + NumberField(name="confidence", required=False, integer=False), + UnknownField(name="probabilities", required=False), + NumberField(name="position", required=False, integer=False), + ] + + # Each case carries the compact slot it projects to, so a `YesNo` that regressed to + # `{"yes_no": false}` fails here rather than only in the byte-parity corpus. + VERDICT_NATIVES: ClassVar[list[tuple[str, InputFormField, bool, Any]]] = [ + ( + "a Choice keeps its envelope: a bare option key would read exactly as a Text does", + ObjectField( + name="choice_in", concept_ref="native.Choice", required=True, presence=PresenceMarker.PLAIN, gating=True, fields=CHOICE_PAYLOAD + ), + True, + {"concept": "native.Choice", "content": {"choice": "choice_value", "confidence": 0.0, "probabilities": {}}}, + ), + ( + "a Rating keeps it too: a bare level would read exactly as a Number does", + ObjectField( + name="rating_in", concept_ref="native.Rating", required=True, presence=PresenceMarker.PLAIN, gating=True, fields=RATING_PAYLOAD + ), + True, + {"concept": "native.Rating", "content": {"level": 0, "confidence": 0.0, "probabilities": {}, "position": 0.0}}, + ), + ( + "a YesNo does not: its input is a boolean, its probability being what a judging model reports", + BooleanField(name="yes_no_in", concept_ref="native.YesNo", required=True, presence=PresenceMarker.PLAIN, gating=True), + False, + False, + ), + ] + FIXED_COUNT_SLOT: ClassVar[ListField] = ListField( name="two", concept_ref="probe.Gadget", diff --git a/tests/unit/test_inputs_template_rendering.py b/tests/unit/test_inputs_template_rendering.py index acdf7f6..1264ab1 100644 --- a/tests/unit/test_inputs_template_rendering.py +++ b/tests/unit/test_inputs_template_rendering.py @@ -64,6 +64,17 @@ def test_a_plural_slot_keeps_its_envelope_on_the_same_terms_as_the_single_one(se compact = project_inputs_template(descriptor=PipeInputFormDescriptor(fields=[field]), explicit=False) assert (ENVELOPE_CONTENT_KEY in compact[field.name]) is expected, topic + @pytest.mark.parametrize(("topic", "field", "keeps", "compact"), CompactSlotCases.VERDICT_NATIVES) + def test_a_verdict_native_keeps_its_envelope_unless_it_is_a_yes_or_no(self, topic: str, field: InputFormField, keeps: bool, compact: Any): + # The standard's 3.0.0 natives reach the projection through the descriptor's own kinds, with + # no table naming them: `Choice` and `Rating` as `object`s, `YesNo` still as a `boolean`. The + # shared corpus captures neither new native, so the rule is held here. + assert PipeInputFormDescriptor(fields=[field]).fields == [field], topic + assert keeps_envelope(node=field) is keeps, topic + compact_slot = project_inputs_template(descriptor=PipeInputFormDescriptor(fields=[field]), explicit=False)[field.name] + assert compact_slot == compact, topic + assert type(compact_slot) is type(compact), topic + @pytest.mark.parametrize("explicit", [False, True]) def test_each_element_of_a_fixed_count_slot_is_its_own_value(self, explicit: bool): # A `Concept[N]` slot renders N elements that are identical in content, and a template is a diff --git a/tests/unit/test_versions.py b/tests/unit/test_versions.py index 089c69e..b52338b 100644 --- a/tests/unit/test_versions.py +++ b/tests/unit/test_versions.py @@ -12,7 +12,7 @@ def test_constants_match_the_standards_cut(self): # Pinned deliberately: these are copies of a cut made in the standard # (https://mthds.ai/latest/spec/versioning/), so a value moving here without a # cut moving there is exactly what this asserts against. - assert MTHDS_STANDARD_VERSION == "2.1.1" + assert MTHDS_STANDARD_VERSION == "3.0.0" assert PROTOCOL_VERSION == "0.7.0" assert is_valid_semver(MTHDS_STANDARD_VERSION) assert is_valid_semver(PROTOCOL_VERSION) @@ -20,21 +20,22 @@ def test_constants_match_the_standards_cut(self): @pytest.mark.parametrize( ("topic", "constraint", "is_satisfied"), [ - ("the constraint every manifest written before the cut carries", ">=1.0.0", True), - ("an exact pin on the current standard", "2.1.1", True), + ("the constraint every manifest written before the 2.0.0 cut carries", ">=1.0.0", True), + ("an exact pin on the current standard", "3.0.0", True), ("a package needing the crate-key wire form", ">=2.1.0", True), - ("a caret range over the current major", "^2.0.0", True), - ("a tilde range over the current minor", "~2.1.0", True), - ("a wildcard over the current major", "2.*", True), + ("a package needing the verdict natives", ">=3.0.0", True), + ("a caret range over the current major", "^3.0.0", True), + ("a tilde range over the current minor", "~3.0.0", True), + ("a wildcard over the current major", "3.*", True), ("any standard version at all", "*", True), - ("a compound range spelled with a space, as the manifest format documents it", ">=2.0.0, <3.0.0", True), - ("the same compound range spelled without one", ">=2.0.0,<3.0.0", True), - ("a caret range over the superseded major", "^1.0.0", False), - ("an exact pin on a superseded cut", "2.0.0", False), - ("a tilde range over the superseded minor", "~2.0.0", False), - ("a package that predates the cut and says so", "<2.0.0", False), - ("a package needing a standard that does not exist yet", ">=3.0.0", False), - ("a compound range closing below the current standard", ">=1.0.0, <2.0.0", False), + ("a compound range spelled with a space, as the manifest format documents it", ">=3.0.0, <4.0.0", True), + ("the same compound range spelled without one", ">=3.0.0,<4.0.0", True), + ("a caret range over the superseded major", "^2.0.0", False), + ("an exact pin on a superseded cut", "2.1.1", False), + ("a tilde range over a superseded minor", "~2.1.0", False), + ("a package that predates the cut and says so", "<3.0.0", False), + ("a package needing a standard that does not exist yet", ">=4.0.0", False), + ("a compound range closing below the current standard", ">=2.0.0, <3.0.0", False), ], ) def test_manifest_constraint_evaluated_against_the_standard_version(self, topic: str, constraint: str, is_satisfied: bool): From 860db8b125caf149f96b0ac2c6340225d27351bc Mon Sep 17 00:00:00 2001 From: Louis Choquel Date: Sun, 4 Oct 2026 00:54:22 +0200 Subject: [PATCH 5/5] Release v0.19.0 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FnWnaN3BHg8B7aG3MyPbvG --- CHANGELOG.md | 2 +- pyproject.toml | 2 +- uv.lock | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fce85b..9b60e70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [v0.19.0] - 2026-10-04 ### Changed diff --git a/pyproject.toml b/pyproject.toml index de8bd51..4e12643 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "mthds" -version = "0.18.0" +version = "0.19.0" description = "The Python interface for methods — base structures for structured outputs and the base runner for executing methods via API." authors = [{ name = "Evotis S.A.S.", email = "oss@pipelex.com" }] maintainers = [{ name = "Pipelex staff", email = "oss@pipelex.com" }] diff --git a/uv.lock b/uv.lock index ad9ac85..71fefc1 100644 --- a/uv.lock +++ b/uv.lock @@ -212,7 +212,7 @@ wheels = [ [[package]] name = "mthds" -version = "0.18.0" +version = "0.19.0" source = { editable = "." } dependencies = [ { name = "httpx" },