diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index afdbd13..b56c5ff 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -7,14 +7,25 @@ on: permissions: contents: read +# Every action is pinned to a full commit SHA, with the release it came from +# in the comment. A tag or branch ref can be moved by whoever controls the +# upstream repository; in the publish job that would run their code with an +# OIDC token PyPI accepts for contextmesh-graph. +# +# The artifact is built once. The files the verify job installs and exercises +# are the same files the publish job uploads: nothing is rebuilt after it has +# been tested. + jobs: build: name: Build release distributions runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + fetch-depth: 0 - - uses: actions/setup-python@v5 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: "3.12" @@ -36,10 +47,21 @@ jobs: print(f"release identity: {project['name']} {project['version']} from {actual_tag}") PY + - name: Verify the tagged commit is on main + # A release can be cut from a tag on any commit. Only main has been + # through review and the required checks. + run: | + git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main + if ! git merge-base --is-ancestor "$GITHUB_SHA" origin/main; then + echo "::error::$GITHUB_REF_NAME points at $GITHUB_SHA, which is not on main" + exit 1 + fi + echo "$GITHUB_REF_NAME ($GITHUB_SHA) is on main" + - name: Build and check wheel and sdist run: | python -m pip install --upgrade pip - python -m pip install build twine + python -m pip install build==1.6.1 twine==7.0.0 python -m build python -m twine check dist/* python - <<'PY' @@ -58,15 +80,130 @@ jobs: PY - name: Upload release distributions - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: pypi-dist path: dist/ if-no-files-found: error + test: + name: Test suite at the tagged commit + needs: build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.12" + + - name: Run the full suite + run: python -m unittest discover -s tests + + verify: + name: Exercise the built distributions (${{ matrix.os }}) + needs: build + strategy: + matrix: + os: [ubuntu-latest, windows-latest] + runs-on: ${{ matrix.os }} + # No checkout: everything below imports the installed distribution, so a + # packaging mistake cannot be hidden by the source tree on sys.path. + steps: + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.12" + + - name: Retrieve release distributions + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: pypi-dist + path: dist + + - name: Install the wheel, core only + shell: bash + run: | + python -m venv core + if [ -x core/bin/python ]; then echo "PY_CORE=$PWD/core/bin/python" >> "$GITHUB_ENV"; else echo "PY_CORE=$PWD/core/Scripts/python.exe" >> "$GITHUB_ENV"; fi + python - <<'PY' + from pathlib import Path + import subprocess, sys + wheel = next(Path("dist").glob("*.whl")) + py = str(next(p for p in (Path("core/bin/python"), Path("core/Scripts/python.exe")) if p.exists())) + subprocess.check_call([py, "-m", "pip", "install", str(wheel)]) + PY + + - name: The installed package is the release, laid out as a package + shell: bash + run: | + "$PY_CORE" - <<'PY' + import importlib.util, os, sysconfig + from pathlib import Path + import contextmesh, contextmesh_mcp + from contextmesh.ontology import ONTOLOGY_FILE + + version = os.environ["GITHUB_REF_NAME"].removeprefix("v") + assert contextmesh.__version__ == version, (contextmesh.__version__, version) + assert contextmesh_mcp.__version__ == version, (contextmesh_mcp.__version__, version) + + package = Path(contextmesh.__file__).resolve().parent + assert Path(ONTOLOGY_FILE).resolve().parent == package, ONTOLOGY_FILE + assert Path(ONTOLOGY_FILE).is_file(), ONTOLOGY_FILE + loose = Path(sysconfig.get_paths()["purelib"]) / "GRAPH.md" + assert not loose.exists(), f"schema installed loose at {loose}" + + assert importlib.util.find_spec("mcp") is None, "core install pulled in the MCP SDK" + print(f"contextmesh {version}: schema at {ONTOLOGY_FILE}") + PY + + - name: Exercise the installed CLI and a session across processes + shell: bash + run: | + BIN="$(dirname "$PY_CORE")" + "$BIN/contextmesh" --help > /dev/null + "$PY_CORE" -c "from contextmesh_mcp.session import Session; Session.build(rounds=1).save('restart-session')" + "$PY_CORE" -c "from contextmesh_mcp.session import Session; s=Session.load('restart-session'); assert s.generation == 1 and s.describe()['persistent']; print('restart ok')" + + - name: Install the wheel with the MCP extra and exercise the launcher + shell: bash + run: | + python -m venv withmcp + python - <<'PY' + from pathlib import Path + import subprocess + wheel = next(Path("dist").glob("*.whl")).resolve() + py = str(next(p for p in (Path("withmcp/bin/python"), Path("withmcp/Scripts/python.exe")) if p.exists())) + subprocess.check_call([py, "-m", "pip", "install", f"contextmesh-graph[mcp] @ {wheel.as_uri()}"]) + PY + if [ -d withmcp/bin ]; then BIN=withmcp/bin; else BIN=withmcp/Scripts; fi + "$BIN/contextmesh-mcp" --help > /dev/null + "$BIN/contextmesh-mcp" --demo --rounds 1 --save mcp-session > /dev/null + "$BIN/contextmesh-mcp" --session mcp-session --save restored-session > /dev/null + echo "mcp extra ok" + + - name: The sdist builds and installs the same package + shell: bash + run: | + python -m venv fromsdist + python - <<'PY' + from pathlib import Path + import subprocess + sdist = next(Path("dist").glob("*.tar.gz")) + py = str(next(p for p in (Path("fromsdist/bin/python"), Path("fromsdist/Scripts/python.exe")) if p.exists())) + subprocess.check_call([py, "-m", "pip", "install", str(sdist)]) + code = ( + "import os, contextmesh; from pathlib import Path; " + "from contextmesh.ontology import ONTOLOGY_FILE; " + "assert contextmesh.__version__ == os.environ['GITHUB_REF_NAME'].removeprefix('v'); " + "assert Path(ONTOLOGY_FILE).resolve().parent == Path(contextmesh.__file__).resolve().parent; " + "print('sdist ok')" + ) + subprocess.check_call([py, "-c", code]) + PY + publish: name: Publish release to PyPI - needs: build + needs: [build, test, verify] runs-on: ubuntu-latest environment: name: pypi @@ -76,10 +213,10 @@ jobs: id-token: write steps: - name: Retrieve release distributions - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: pypi-dist path: dist/ - name: Publish release distributions to PyPI - uses: pypa/gh-action-pypi-publish@release/v1 + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 diff --git a/GRAPH.md b/GRAPH.md index bd9b9b2..601bf80 100644 --- a/GRAPH.md +++ b/GRAPH.md @@ -1,316 +1,7 @@ -# GRAPH.md — the ontology file +# GRAPH.md has moved -> Read on every write. If a write does not typecheck against this file, it does -> not enter the graph. +The graph contract now lives at [`contextmesh/GRAPH.md`](contextmesh/GRAPH.md). -This is not documentation of the code; it is the schema the code loads. See -`contextmesh/ontology.py`, which parses this file at import time. - -## Reading this file - -**Authority.** This file is authoritative for the node types, the edge types and -their legal pairs, and the rules below. Where an implementation and this file -disagree about any of those, this file is right and the implementation is a bug. - -**What is mechanically enforced.** `ontology.py` parses the `Type` and `Symbol` -columns of the node table, the `Must carry` column of the node table, and the -`Edge`, `Legal pairs` and `Invalidation` columns of the edge table; every write -is checked against them, and `tests/test_ontology.py` asserts that the -`NodeType` and `EdgeType` enums equal what this file declares. `Means` is prose -and is not parsed. The rules below are enforced, or not, one at a time — the -*Declared, not yet realized* section names the ones that currently are not. - -**What `Must carry` means.** It is a *minimum*, not an exhaustive list: a node -may carry more. It is satisfied by a node `attrs` key **or** by a `Node` field of -that name — `provenance` is a field, not an attribute. It requires the value to -be **present**, not to be well-formed: `contextmesh/execute.py` mints sources -with `retrieved_at="at plan time"`, which satisfies the presence requirement -defined here, and the temporal layer separately classifies such a source as -`UNDATED` rather than guessing a date. `ContextGraph.add_node` enforces this at -the write boundary — presence only, nothing more — and refuses a node missing a -required name with `OntologyError`. Every path that constructs a node, -including restoring a persisted snapshot, goes through `add_node`, so this is -one check rather than one per caller. - -**What edge-level assumption binding means.** `Edge.assumption_id` makes one -relationship conditional on one assumption: the edge holds only while that -assumption stands. Rejecting the assumption invalidates the bound edge -directly — `apply_invalidation` marks it regardless of what else falls — but -binding an edge is not a second way to reach a node. `src` and `dst` fall only -if rule 2's propagation reaches them along an edge of a propagating type; a -bound `mentions` or `supports` edge, say, does not pull its endpoints down -just because it was bound. A node that itself needs to fall when an -assumption falls has to say so explicitly with `depends_on` — rule 2 is the -one mechanism for node-to-node invalidation, and edge binding does not open a -second one. `AssumptionLedger.justifies` enforces the binding side of this at -the write boundary: the assumption must exist and be active, the edge must -exist and be live, binding the same pair again is a no-op, and binding an -edge already bound to a *different* assumption is refused rather than -silently replaced. `ContextGraph.add_edge` takes no `assumption_id`, so it -is not a second, unguarded way to bind one; only snapshot restoration sets -the field directly, and it validates what it restores on its own terms. - -**What walk-driven PRUNE means.** Rule 5's second half drops a node only once -an observation window has closed: `Pipeline.prune_unwalked_nodes` takes the -`Walker` that produced the telemetry, and is a no-op below -`MIN_OBSERVATION_WALKS` walks in that window, so one question failing to reach -a node is never mistaken for the corpus having judged it irrelevant. -`Node.walks` — incremented once per node an actual walk visits, in -`Walker.walk` — is the only signal a node's own eligibility is judged on; -degree, inherited unchanged from the build-time half this pass has always -shared it with, is the one other factor, and nothing about a node's label, -age, or type enters the decision beyond `source`, `assumption`, and -`evidence` nodes being preserved outright — an assumption nothing has -questioned yet is not thereby irrelevant, and evidence is what rule 7 -requires an audit be able to find. A node this pass drops cannot be revived -by a later walk: `Walker.seed` will not seed a pruned node and -`out_edges`/`in_edges` will not traverse into one, so its `walks` count is -frozen at whatever it was the moment it fell. A node already not live for a -different reason — invalidated by a rejected assumption — is left alone -rather than separately marked pruned, so this pass never creates a second -state change on top of one that already happened. The demo's export path -(`contextmesh/demo.py`) never opts in, so this pass changes nothing about the -dashboard unless a caller explicitly asks for it. - -**What a node/edge id collision means.** `slug` (`contextmesh/model.py`) truncates -its digest to keep ids short, so it is not injective: two different node -labels, or two different `(src, type, dst)` edge triples, can derive the same -id. `add_node` and `add_edge` are also the merge path for a genuine repeat -observation — the same claim text extracted from a second document, the same -`mentions` edge asserted twice — so a shared id has to be told apart from a -coincidence before it is treated as one. For a node, "the same thing" means -the existing node's `type` and `label` agree with the incoming call; `attrs` -is never part of that comparison, since a repeat observation may legitimately -carry new metadata the first one did not. For an edge, "the same thing" means -the existing edge's `(src, type, dst)` triple — not merely its id — matches. -A collision found either way is refused with `OntologyError` before anything -is written: no node's `label`/`type` is silently overwritten, no edge's -`(src, type, dst)` is silently repointed, and none of the internal indexes -(`edges`, `_edge_key`, `_out`, `_in`) are touched. `from_dict` inherits this -for free, since every node and edge it restores goes through `add_node` and -`add_edge` the same as a live write — but a snapshot's own `nodes`/`edges` -lists are also checked for two rows sharing a literal id before either method -ever runs, since `to_dict` can never produce that either. This is -collision *detection*, not a stronger id scheme: `slug`'s truncation is -unchanged, and a caller that keeps minting distinct content into the same -id space will eventually have a write refused rather than silently lose one. - -**What a decision's identity is.** A `decision` node is an immutable event, -never a content address: two calls to `DecisionLog.decide` with byte-identical -`title` and `rationale` are two decisions, not one being re-observed, because -"we chose this again" and "we never stopped choosing this" are different -facts and only the first is `supersedes`'s job to record. A call made without -an explicit `id` therefore always mints a fresh id, discriminated against -*graph* state rather than `DecisionLog`'s own history — the smallest sequence -number whose derived id is not already a node in this graph — so a repeated -title still cannot collide with its own predecessor after a fresh -`DecisionLog` is constructed over the same graph, which is exactly what a -restored `Runner` does when no `decisions=` is given. An in-memory -discriminator that instead counted this object's own records would forget -every decision minted before it existed and hand out an id that object had -never seen but the graph had — an id collision one caller reuses, not two -callers agreeing. - -An explicit `id` means something narrower: not "this is the decision this -content names" but "this exact call may have already happened — tell me if -it did." The decision node itself carries this call's evidence, in two attrs -that ride along with `rationale`: `decision_identity` (`"auto"` or -`"explicit"`) and `decision_payload_digest`, a fingerprint of the node's -graph-visible content — `title`, `rationale`, the provenance `source_id` -kept as its own field, and the deduplicated, order-independent target sets -of the edges `decide` creates beyond that source (citation, support, -dependency, production, supersession). Keeping `source_id` separate from -the additional-citation set matters: reconstructing both as one combined -set from a node's out-edges cannot tell "the edge that is the provenance -source" from "the edge that is an additional cite to the same target", so -a primary source and an additional cite could otherwise be swapped without -the fingerprint noticing — a different agreed payload producing the same -digest. This is graph state, not a `DecisionLog`-local table, so it -survives a snapshot round-trip and a fresh `DecisionLog` the same way the -id-freshness check does. - -An explicit `id` naming an existing node is an idempotent retry only when -that node is a decision minted with an explicit id of its own — naming an -auto-minted decision, or any non-decision node, is refused, because it is -not this call's event to retry. That check does not stop at reading the -`decision_identity` attr, either: every auto-minted id lands in a reserved -namespace (the `decision:auto:` slug prefix) that no explicit id may ever -use, and `decide` refuses up front to hand out a brand-new explicit id that -collides with it — so membership in that namespace is a permanent fact -about an id string, settled the moment it is minted, never re-derived and -never dependent on anything else in the graph. A decision whose id lands in -that namespace is never accepted as an explicit retry target, even when its -`decision_identity` attr is tampered to say `"explicit"`. This is what -closes the gap a digest check alone cannot: an auto-minted decision's -stored digest is already correct for its real content, so flipping only its -mode label leaves nothing for a digest comparison to disagree with. It is -also why the namespace test cannot be a search bounded by the graph's -current size — an early version of this tried exactly that (recomputing -`slug(f"{title}|{n}", "decision")` for `n` up to the node count), and a -bound that changes as the graph grows is not stable: an explicit id one -write legitimately accepted could fail the identical check moments later, -once that very write (or a snapshot round-trip of it) changed the bound, -with no tampering involved. A plain prefix has no such bound to drift. -Once identity agrees, the retry check compares -the incoming call against the fingerprint of the *existing node's actual -provenance and edges*, not against whatever its attrs claim. The same `id` -with content that produces the same fingerprint is a true no-op that -returns the existing node untouched, and the same `id` with any different -content is refused with `OntologyError` before a single node or edge is -written — the id is not free to start meaning something else partway -through a run, or after a restart. This is an idempotency key, not a -stronger identity scheme: it only protects a caller who reuses one id on -purpose, the same way `add_node`'s type/label check only protects against -`slug`'s truncation, and the two checks answer different questions (one -caller-declared, one derived) that can both apply to the same write. - -A decision's immutability does not stop at `DecisionLog`, and neither does -the trust boundary around its identity metadata. `add_node` refuses to mint -a brand-new decision node at all — only `DecisionLog.decide` and the -snapshot-restoration path in `from_dict` may create one — so `attrs` like -`decision_identity` and `decision_payload_digest` can never be handed to a -node that did not genuinely come from `decide`; a caller with only the -public API cannot forge a decision that a later retry would mistake for a -prior event. `add_node` also refuses a second call for an *existing* -decision id even when type and label match, unlike a claim or entity, which -legitimately merge new attrs on a repeat observation — a decision has no -such second sighting, so reaching that collision means some other caller is -trying to rewrite a decision's rationale through the generic merge path, and -that is refused rather than silently allowed to succeed. And because a -snapshot is untrusted input regardless of what created it, `from_dict` -independently recomputes every `"explicit"` decision's fingerprint from its -restored provenance/edges and refuses to load a file where the stored digest -disagrees with reality, and separately refuses to load one where an -`"explicit"`-labeled decision's id could have been auto-minted for its own -title — a hand-edited snapshot cannot claim a decision holds content its -actual edges do not support, nor turn a real auto-minted event into an -explicit one by editing a single attr. - -`decide` is atomic under either path: a decision and its edges (citation, -support, dependency, production, supersession) either all land or none do, -rolled back the same way `AssumptionLedger.assume` rolls back a -partially-justified assumption. - -**Code is evidence, not authority.** A behaviour may be written into this file -when the implementation *structurally guarantees* it — when no caller can make -it false without changing the implementation's own contract. A behaviour that is -merely one unconstrained implementation choice among several does not become -normative by having been written first; it requires a decision recorded here. - -## Node types - -| Type | Symbol | Means | Must carry | -|---|---|---|---| -| Entity | `entity` | A resolved real-world thing. One id per thing, forever. | `canonical`, `aliases` | -| Claim | `claim` | A statement lifted from a source. | `provenance` | -| Source | `source` | A document, span, run, or artifact that text came from. | `origin`, `retrieved_at` | -| Decision | `decision` | A choice that was made, with the reasoning attached. | `rationale`, `provenance` | -| Assumption | `assumption` | Something taken as true so work could proceed. Versioned. | `status`, `version` | -| Evidence | `evidence` | An observation that bears on a claim, decision, or assumption. | `kind` | - -## Edge types - -An edge is legal only if the pair `(source type, target type)` appears in its row. - -`Invalidation` is rule 2's propagation direction, machine-readable: `backward` -means the edge is walked from target to source when an assumption falls (the -thing that depends falls when its ground falls), `forward` means source to -target (an artefact falls with the decision that made it), and `none` means -the edge never propagates a fall. `contextmesh/assumptions.py` reads this -column through `ontology.py` rather than restating it, so the two cannot -disagree. - -| Edge | Legal pairs | Invalidation | Means | -|---|---|---|---| -| `mentions` | source→entity, claim→entity | none | The text refers to this resolved entity. | -| `derived_from` | claim→source, decision→claim, entity→source | backward | This exists because that exists. | -| `cites` | decision→source, claim→claim | none | Explicit reference made by the author. | -| `contradicts` | claim→claim, evidence→claim, evidence→assumption | none | Both cannot hold. | -| `supports` | evidence→claim, claim→decision, evidence→decision | none | Raises confidence in the target. | -| `depends_on` | decision→assumption, decision→decision, claim→assumption | backward | Target's failure invalidates the source. | -| `produces` | decision→entity, decision→source | forward | The decision brought the target into being. | -| `supersedes` | decision→decision, assumption→assumption, claim→claim | none | Replaces, without deleting. | -| `justified_by` | decision→evidence, claim→evidence, assumption→evidence | none | The reason this was allowed to stand. | -| `resolves_to` | entity→entity | none | An alias node folding into its canonical id. | - -## Rules - -1. `untyped_edges == 0`. There is no constructor for an edge without a type. -2. Invalidation propagates along three edges and no others, and direction - matters: `depends_on` and `derived_from` are followed *backwards* (the thing - that depends falls when its ground falls), `produces` is followed *forwards* - (an artefact falls with the decision that made it). `mentions`, `cites`, - `supports` and `supersedes` never propagate — that is what makes - invalidation selective rather than a purge. -3. `supersedes` never deletes. Decision history is append-only. -4. Every `claim` and `decision` carries provenance to a `source`. A node without - a path to a source is an orphan and is reported by graph health. -5. A node is walkable only after `PRUNE`. `PRUNE` has two halves: the build-time - pass drops what nothing *linked* (degree zero), and a second pass, - `Pipeline.prune_unwalked_nodes`, drops what an observation window of walks - left untouched — see *What walk-driven PRUNE means*. -6. Re-running invalidated work never revives a `decision` — it appends a new one - that `supersedes` it, so rule 3 holds through re-execution. An `entity` *is* - revived when the decision that produces it runs again, because the artefact - was rebuilt; one the rerun stops producing stays invalidated. -7. An `assumption` is only ever rejected by `evidence` that `contradicts` it. A - caller may not mark one false directly, because "why did this fall over" has - to have an answer inside the graph. -8. A node or edge id names one semantic identity. A derived id that would - collide with a different node or edge is refused, never silently merged - into it — see *What a node/edge id collision means*. -9. A `decision` is never content-addressed. Two decisions with the same - title and rationale are two events, not one; an explicit `id` on - `decide` is an idempotency key for one call, not a way to name a - decision by its content — see *What a decision's identity is*. - -## Declared, not yet realized - -Each of these is declared here or in the type system, is modelled and in some -cases persisted and validated, and has **no production path**. They are recorded -so a reader does not mistake a declaration for a live capability, and so that -deciding their intent is a visible act rather than an accident of first use. -None of them may be relied on until it appears above this line. - -| Declared | State | Missing | -|---|---|---| -| `resolves_to` | In the edge table; in `EdgeType`; weighted `0.3` by the walker | No realized lifecycle today: no production writer creates it, and no node-level justification, contradiction, supersession, support, dependency or citation path is legal for `entity` endpoints. | - -## Open items - -Questions this file does not yet answer. Each is a decision, not a bug; none is -resolved by pointing at current behaviour. - -1. **A decision's processing-time field has no name here.** The `Must carry` - cell for Decision previously read `at_build`, which names nothing on a - decision node: the node carries `Node.build`, while `at_build` lives on - `DecisionRecord`, which is not part of the graph snapshot. The false token is - removed; naming the intended field is open. - -Resolved since the list above was first written, kept here as a record rather -than deleted, since a reader following an old reference should land somewhere -that says what happened to it: - -- **`Symbol` was unverified.** `ontology.py` now reads the column and refuses - to load if it disagrees with `Type`; see *What is mechanically enforced*. -- **Rule 2's propagation set was restated in code.** The edge table's - `Invalidation` column is now that machine-readable form, and - `contextmesh/assumptions.py` reads it through `ontology.py` instead of - restating it. -- **`AssumptionLedger.justifies` propagated further than its wording.** Its - docstring marked an *edge* as standing on an assumption, but rejection - reached through the edge to invalidate `edge.dst` too — a second, - undeclared invalidation path alongside rule 2. Binding an edge no longer - seeds its endpoints into the blast radius; see *What edge-level assumption - binding means*. A node that needs to fall with the assumption still has to - say so with `depends_on`, the same as every other node-to-node case. -- **`Must carry` had no enforcement path.** `ContextGraph.add_node` now - enforces it; see *What `Must carry` means*. -- **Walk-driven `PRUNE` had no caller.** `Pipeline.prune_unwalked_nodes` now - takes the `Walker` that produced the telemetry and runs for real, gated on - an observation window rather than firing on the first walk that happened - not to reach a node; see rule 5 and *What walk-driven PRUNE means*. - -Out of scope for this file today: how entity identity should be asserted, -recorded or retracted. `resolves_to` exists as a declaration only, and the -design that would give it a lifecycle is not settled. +It moved inside the package because it is not only documentation: `contextmesh/ontology.py` +parses it at import time, so it has to ship inside the package that reads it. This file is a +pointer for old links and is not read by anything. diff --git a/README.md b/README.md index 479f43b..c7eedc2 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ A: dead end — entity_unresolved ## The ontology is a file -[`GRAPH.md`](GRAPH.md) is not documentation of the schema. It **is** the schema: +[`GRAPH.md`](contextmesh/GRAPH.md) is not documentation of the schema. It **is** the schema: `contextmesh/ontology.py` parses it at import time, and every write typechecks against it. diff --git a/contextmesh/GRAPH.md b/contextmesh/GRAPH.md new file mode 100644 index 0000000..1fc58e4 --- /dev/null +++ b/contextmesh/GRAPH.md @@ -0,0 +1,325 @@ +# GRAPH.md — the ontology file + +> Read on every write. If a write does not typecheck against this file, it does +> not enter the graph. + +This is not documentation of the code; it is the schema the code loads. See +`contextmesh/ontology.py`, which parses this file at import time. + +## Reading this file + +**Authority.** This file is authoritative for the node types, the edge types and +their legal pairs, and the rules below. Where an implementation and this file +disagree about any of those, this file is right and the implementation is a bug. + +**What is mechanically enforced.** `ontology.py` parses the `Type` and `Symbol` +columns of the node table, the `Must carry` column of the node table, and the +`Edge`, `Legal pairs` and `Invalidation` columns of the edge table; every write +is checked against them, and `tests/test_ontology.py` asserts that the +`NodeType` and `EdgeType` enums equal what this file declares. `Means` is prose +and is not parsed. The rules below are enforced, or not, one at a time — the +*Declared, not yet realized* section names the ones that currently are not. + +**What `Must carry` means.** It is a *minimum*, not an exhaustive list: a node +may carry more. It is satisfied by a node `attrs` key **or** by a `Node` field of +that name — `provenance` is a field, not an attribute. It requires the value to +be **present**, not to be well-formed: `contextmesh/execute.py` mints sources +with `retrieved_at="at plan time"`, which satisfies the presence requirement +defined here, and the temporal layer separately classifies such a source as +`UNDATED` rather than guessing a date. `ContextGraph.add_node` enforces this at +the write boundary — presence only, nothing more — and refuses a node missing a +required name with `OntologyError`. Every path that constructs a node, +including restoring a persisted snapshot, goes through `add_node`, so this is +one check rather than one per caller. + +**What edge-level assumption binding means.** `Edge.assumption_id` makes one +relationship conditional on one assumption: the edge holds only while that +assumption stands. Rejecting the assumption invalidates the bound edge +directly — `apply_invalidation` marks it regardless of what else falls — but +binding an edge is not a second way to reach a node. `src` and `dst` fall only +if rule 2's propagation reaches them along an edge of a propagating type; a +bound `mentions` or `supports` edge, say, does not pull its endpoints down +just because it was bound. A node that itself needs to fall when an +assumption falls has to say so explicitly with `depends_on` — rule 2 is the +one mechanism for node-to-node invalidation, and edge binding does not open a +second one. `AssumptionLedger.justifies` enforces the binding side of this at +the write boundary: the assumption must exist and be active, the edge must +exist and be live, binding the same pair again is a no-op, and binding an +edge already bound to a *different* assumption is refused rather than +silently replaced. `ContextGraph.add_edge` takes no `assumption_id`, so it +is not a second, unguarded way to bind one; only snapshot restoration sets +the field directly, and it validates what it restores on its own terms. + +**What walk-driven PRUNE means.** Rule 5's second half drops a node only once +an observation window has closed: `Pipeline.prune_unwalked_nodes` takes the +`Walker` that produced the telemetry, and is a no-op below +`MIN_OBSERVATION_WALKS` walks in that window, so one question failing to reach +a node is never mistaken for the corpus having judged it irrelevant. +`Node.walks` — incremented once per node an actual walk visits, in +`Walker.walk` — is the only signal a node's own eligibility is judged on; +degree, inherited unchanged from the build-time half this pass has always +shared it with, is the one other factor, and nothing about a node's label, +age, or type enters the decision beyond `source`, `assumption`, and +`evidence` nodes being preserved outright — an assumption nothing has +questioned yet is not thereby irrelevant, and evidence is what rule 7 +requires an audit be able to find. A node this pass drops cannot be revived +by a later walk: `Walker.seed` will not seed a pruned node and +`out_edges`/`in_edges` will not traverse into one, so its `walks` count is +frozen at whatever it was the moment it fell. A node already not live for a +different reason — invalidated by a rejected assumption — is left alone +rather than separately marked pruned, so this pass never creates a second +state change on top of one that already happened. The demo's export path +(`contextmesh/demo.py`) never opts in, so this pass changes nothing about the +dashboard unless a caller explicitly asks for it. + +**What a node/edge id collision means.** `slug` (`contextmesh/model.py`) truncates +its digest to keep ids short, so it is not injective: two different node +labels, or two different `(src, type, dst)` edge triples, can derive the same +id. `add_node` and `add_edge` are also the merge path for a genuine repeat +observation — the same claim text extracted from a second document, the same +`mentions` edge asserted twice — so a shared id has to be told apart from a +coincidence before it is treated as one. For a node, "the same thing" means +the existing node's `type` and `label` agree with the incoming call; `attrs` +is never part of that comparison, since a repeat observation may legitimately +carry new metadata the first one did not. For an edge, "the same thing" means +the existing edge's `(src, type, dst)` triple — not merely its id — matches. +A collision found either way is refused with `OntologyError` before anything +is written: no node's `label`/`type` is silently overwritten, no edge's +`(src, type, dst)` is silently repointed, and none of the internal indexes +(`edges`, `_edge_key`, `_out`, `_in`) are touched. `from_dict` inherits this +for free, since every node and edge it restores goes through `add_node` and +`add_edge` the same as a live write — but a snapshot's own `nodes`/`edges` +lists are also checked for two rows sharing a literal id before either method +ever runs, since `to_dict` can never produce that either. This is +collision *detection*, not a stronger id scheme: `slug`'s truncation is +unchanged, and a caller that keeps minting distinct content into the same +id space will eventually have a write refused rather than silently lose one. + +**What a decision's identity is.** A `decision` node is an immutable event, +never a content address: two calls to `DecisionLog.decide` with byte-identical +`title` and `rationale` are two decisions, not one being re-observed, because +"we chose this again" and "we never stopped choosing this" are different +facts and only the first is `supersedes`'s job to record. A call made without +an explicit `id` therefore always mints a fresh id, discriminated against +*graph* state rather than `DecisionLog`'s own history — the smallest sequence +number whose derived id is not already a node in this graph — so a repeated +title still cannot collide with its own predecessor after a fresh +`DecisionLog` is constructed over the same graph, which is exactly what a +restored `Runner` does when no `decisions=` is given. An in-memory +discriminator that instead counted this object's own records would forget +every decision minted before it existed and hand out an id that object had +never seen but the graph had — an id collision one caller reuses, not two +callers agreeing. + +An explicit `id` means something narrower: not "this is the decision this +content names" but "this exact call may have already happened — tell me if +it did." The decision node itself carries this call's evidence, in two attrs +that ride along with `rationale`: `decision_identity` (`"auto"` or +`"explicit"`) and `decision_payload_digest`, a fingerprint of the node's +graph-visible content — `title`, `rationale`, the provenance `source_id` +kept as its own field, and the deduplicated, order-independent target sets +of the edges `decide` creates beyond that source (citation, support, +dependency, production, supersession). Keeping `source_id` separate from +the additional-citation set matters: reconstructing both as one combined +set from a node's out-edges cannot tell "the edge that is the provenance +source" from "the edge that is an additional cite to the same target", so +a primary source and an additional cite could otherwise be swapped without +the fingerprint noticing — a different agreed payload producing the same +digest. This is graph state, not a `DecisionLog`-local table, so it +survives a snapshot round-trip and a fresh `DecisionLog` the same way the +id-freshness check does. + +An explicit `id` naming an existing node is an idempotent retry only when +that node is a decision minted with an explicit id of its own — naming an +auto-minted decision, or any non-decision node, is refused, because it is +not this call's event to retry. That check does not stop at reading the +`decision_identity` attr, either: every auto-minted id lands in a reserved +namespace (the `decision:auto:` slug prefix) that no explicit id may ever +use, and `decide` refuses up front to hand out a brand-new explicit id that +collides with it — so membership in that namespace is a permanent fact +about an id string, settled the moment it is minted, never re-derived and +never dependent on anything else in the graph. A decision whose id lands in +that namespace is never accepted as an explicit retry target, even when its +`decision_identity` attr is tampered to say `"explicit"`. This is what +closes the gap a digest check alone cannot: an auto-minted decision's +stored digest is already correct for its real content, so flipping only its +mode label leaves nothing for a digest comparison to disagree with. It is +also why the namespace test cannot be a search bounded by the graph's +current size — an early version of this tried exactly that (recomputing +`slug(f"{title}|{n}", "decision")` for `n` up to the node count), and a +bound that changes as the graph grows is not stable: an explicit id one +write legitimately accepted could fail the identical check moments later, +once that very write (or a snapshot round-trip of it) changed the bound, +with no tampering involved. A plain prefix has no such bound to drift. +Once identity agrees, the retry check compares +the incoming call against the fingerprint of the *existing node's actual +provenance and edges*, not against whatever its attrs claim. The same `id` +with content that produces the same fingerprint is a true no-op that +returns the existing node untouched, and the same `id` with any different +content is refused with `OntologyError` before a single node or edge is +written — the id is not free to start meaning something else partway +through a run, or after a restart. This is an idempotency key, not a +stronger identity scheme: it only protects a caller who reuses one id on +purpose, the same way `add_node`'s type/label check only protects against +`slug`'s truncation, and the two checks answer different questions (one +caller-declared, one derived) that can both apply to the same write. +Because the check reads the node's actual edges, an explicit-id decision's +fingerprinted edges are fixed once `decide` returns: `add_edge` refuses a +new `cites`, `derived_from`, `produces`, `supersedes`, or `depends_on` into +an assumption out of it, since accepting one would make the next load +refuse a correct snapshot and the next identical retry refuse the original +call. (A `depends_on` into another decision is a task ordering, not part of +the decision, and is still added freely.) A time-travel projection that +drops such an edge because its other end came later re-derives the digest +for what it kept, the same way it rewinds assumption state. + +A decision's immutability does not stop at `DecisionLog`, and neither does +the trust boundary around its identity metadata. `add_node` refuses to mint +a brand-new decision node at all — only `DecisionLog.decide` and the +snapshot-restoration path in `from_dict` may create one — so `attrs` like +`decision_identity` and `decision_payload_digest` can never be handed to a +node that did not genuinely come from `decide`; a caller with only the +public API cannot forge a decision that a later retry would mistake for a +prior event. `add_node` also refuses a second call for an *existing* +decision id even when type and label match, unlike a claim or entity, which +legitimately merge new attrs on a repeat observation — a decision has no +such second sighting, so reaching that collision means some other caller is +trying to rewrite a decision's rationale through the generic merge path, and +that is refused rather than silently allowed to succeed. And because a +snapshot is untrusted input regardless of what created it, `from_dict` +independently recomputes every `"explicit"` decision's fingerprint from its +restored provenance/edges and refuses to load a file where the stored digest +disagrees with reality, and separately refuses to load one where an +`"explicit"`-labeled decision's id could have been auto-minted for its own +title — a hand-edited snapshot cannot claim a decision holds content its +actual edges do not support, nor turn a real auto-minted event into an +explicit one by editing a single attr. + +`decide` is atomic under either path: a decision and its edges (citation, +support, dependency, production, supersession) either all land or none do, +rolled back the same way `AssumptionLedger.assume` rolls back a +partially-justified assumption. + +**Code is evidence, not authority.** A behaviour may be written into this file +when the implementation *structurally guarantees* it — when no caller can make +it false without changing the implementation's own contract. A behaviour that is +merely one unconstrained implementation choice among several does not become +normative by having been written first; it requires a decision recorded here. + +## Node types + +| Type | Symbol | Means | Must carry | +|---|---|---|---| +| Entity | `entity` | A resolved real-world thing. One id per thing, forever. | `canonical`, `aliases` | +| Claim | `claim` | A statement lifted from a source. | `provenance` | +| Source | `source` | A document, span, run, or artifact that text came from. | `origin`, `retrieved_at` | +| Decision | `decision` | A choice that was made, with the reasoning attached. | `rationale`, `provenance` | +| Assumption | `assumption` | Something taken as true so work could proceed. Versioned. | `status`, `version` | +| Evidence | `evidence` | An observation that bears on a claim, decision, or assumption. | `kind` | + +## Edge types + +An edge is legal only if the pair `(source type, target type)` appears in its row. + +`Invalidation` is rule 2's propagation direction, machine-readable: `backward` +means the edge is walked from target to source when an assumption falls (the +thing that depends falls when its ground falls), `forward` means source to +target (an artefact falls with the decision that made it), and `none` means +the edge never propagates a fall. `contextmesh/assumptions.py` reads this +column through `ontology.py` rather than restating it, so the two cannot +disagree. + +| Edge | Legal pairs | Invalidation | Means | +|---|---|---|---| +| `mentions` | source→entity, claim→entity | none | The text refers to this resolved entity. | +| `derived_from` | claim→source, decision→claim, entity→source | backward | This exists because that exists. | +| `cites` | decision→source, claim→claim | none | Explicit reference made by the author. | +| `contradicts` | claim→claim, evidence→claim, evidence→assumption | none | Both cannot hold. | +| `supports` | evidence→claim, claim→decision, evidence→decision | none | Raises confidence in the target. | +| `depends_on` | decision→assumption, decision→decision, claim→assumption | backward | Target's failure invalidates the source. | +| `produces` | decision→entity, decision→source | forward | The decision brought the target into being. | +| `supersedes` | decision→decision, assumption→assumption, claim→claim | none | Replaces, without deleting. | +| `justified_by` | decision→evidence, claim→evidence, assumption→evidence | none | The reason this was allowed to stand. | +| `resolves_to` | entity→entity | none | An alias node folding into its canonical id. | + +## Rules + +1. `untyped_edges == 0`. There is no constructor for an edge without a type. +2. Invalidation propagates along three edges and no others, and direction + matters: `depends_on` and `derived_from` are followed *backwards* (the thing + that depends falls when its ground falls), `produces` is followed *forwards* + (an artefact falls with the decision that made it). `mentions`, `cites`, + `supports` and `supersedes` never propagate — that is what makes + invalidation selective rather than a purge. +3. `supersedes` never deletes. Decision history is append-only. +4. Every `claim` and `decision` carries provenance to a `source`. A node without + a path to a source is an orphan and is reported by graph health. +5. A node is walkable only after `PRUNE`. `PRUNE` has two halves: the build-time + pass drops what nothing *linked* (degree zero), and a second pass, + `Pipeline.prune_unwalked_nodes`, drops what an observation window of walks + left untouched — see *What walk-driven PRUNE means*. +6. Re-running invalidated work never revives a `decision` — it appends a new one + that `supersedes` it, so rule 3 holds through re-execution. An `entity` *is* + revived when the decision that produces it runs again, because the artefact + was rebuilt; one the rerun stops producing stays invalidated. +7. An `assumption` is only ever rejected by `evidence` that `contradicts` it. A + caller may not mark one false directly, because "why did this fall over" has + to have an answer inside the graph. +8. A node or edge id names one semantic identity. A derived id that would + collide with a different node or edge is refused, never silently merged + into it — see *What a node/edge id collision means*. +9. A `decision` is never content-addressed. Two decisions with the same + title and rationale are two events, not one; an explicit `id` on + `decide` is an idempotency key for one call, not a way to name a + decision by its content — see *What a decision's identity is*. + +## Declared, not yet realized + +Each of these is declared here or in the type system, is modelled and in some +cases persisted and validated, and has **no production path**. They are recorded +so a reader does not mistake a declaration for a live capability, and so that +deciding their intent is a visible act rather than an accident of first use. +None of them may be relied on until it appears above this line. + +| Declared | State | Missing | +|---|---|---| +| `resolves_to` | In the edge table; in `EdgeType`; weighted `0.3` by the walker | No realized lifecycle today: no production writer creates it, and no node-level justification, contradiction, supersession, support, dependency or citation path is legal for `entity` endpoints. | + +## Open items + +Questions this file does not yet answer. Each is a decision, not a bug; none is +resolved by pointing at current behaviour. + +1. **A decision's processing-time field has no name here.** The `Must carry` + cell for Decision previously read `at_build`, which names nothing on a + decision node: the node carries `Node.build`, while `at_build` lives on + `DecisionRecord`, which is not part of the graph snapshot. The false token is + removed; naming the intended field is open. + +Resolved since the list above was first written, kept here as a record rather +than deleted, since a reader following an old reference should land somewhere +that says what happened to it: + +- **`Symbol` was unverified.** `ontology.py` now reads the column and refuses + to load if it disagrees with `Type`; see *What is mechanically enforced*. +- **Rule 2's propagation set was restated in code.** The edge table's + `Invalidation` column is now that machine-readable form, and + `contextmesh/assumptions.py` reads it through `ontology.py` instead of + restating it. +- **`AssumptionLedger.justifies` propagated further than its wording.** Its + docstring marked an *edge* as standing on an assumption, but rejection + reached through the edge to invalidate `edge.dst` too — a second, + undeclared invalidation path alongside rule 2. Binding an edge no longer + seeds its endpoints into the blast radius; see *What edge-level assumption + binding means*. A node that needs to fall with the assumption still has to + say so with `depends_on`, the same as every other node-to-node case. +- **`Must carry` had no enforcement path.** `ContextGraph.add_node` now + enforces it; see *What `Must carry` means*. +- **Walk-driven `PRUNE` had no caller.** `Pipeline.prune_unwalked_nodes` now + takes the `Walker` that produced the telemetry and runs for real, gated on + an observation window rather than firing on the first walk that happened + not to reach a node; see rule 5 and *What walk-driven PRUNE means*. + +Out of scope for this file today: how entity identity should be asserted, +recorded or retracted. `resolves_to` exists as a declaration only, and the +design that would give it a lifecycle is not settled. diff --git a/contextmesh/__init__.py b/contextmesh/__init__.py index 1ca9661..179acaf 100644 --- a/contextmesh/__init__.py +++ b/contextmesh/__init__.py @@ -86,7 +86,7 @@ from .telemetry import TelemetryProjection, project_telemetry from .traverse import DeadEnd, Walk, Walker -__version__ = "0.1.0" +__version__ = "0.1.1" __all__ = [ "LIVE_PROVIDERS", diff --git a/contextmesh/assumptions.py b/contextmesh/assumptions.py index 283c694..6260d35 100644 --- a/contextmesh/assumptions.py +++ b/contextmesh/assumptions.py @@ -132,6 +132,11 @@ def assume( digest is truncated, GRAPH.md rule 8) is a collision, not a reuse, and is refused the same way ``add_assumption`` refuses one. + A reuse that asks for justification the existing record does not + already carry is refused too. Returning the record unchanged would + tell the caller its evidence was recorded when no ``justified_by`` + edge was written -- the no-op would silently drop part of the call. + The write is atomic: if any justifying evidence id is invalid, the assumption this call would have created is rolled back rather than left standing without the justification the caller asked for. @@ -144,6 +149,13 @@ def assume( f"{existing.statement!r}; refusing to treat {statement!r} " "as the same assumption" ) + missing = [ev for ev in evidence_ids if ev not in existing.evidence_ids] + if missing: + raise AssumptionError( + f"assumption {existing.id!r} already exists " + f"({existing.status.value}) without evidence {missing!r}; " + "re-asking the same statement does not add justification" + ) return existing assumption = Assumption( @@ -223,7 +235,13 @@ def justifies(self, assumption_id: str, edge_id: str) -> None: edge.assumption_id = assumption_id def depends(self, node_id: str, assumption_id: str) -> None: - """Wire a decision or claim to the assumption it rests on.""" + """Wire a decision or claim to the assumption it rests on. + + An explicit-id decision takes its assumptions through + ``DecisionLog.decide(assumptions=...)`` instead; adding one here + afterwards is refused, because it would change the content that + decision's digest vouches for. + """ self.graph.add_edge(node_id, EdgeType.DEPENDS_ON, assumption_id) # ── the interesting part ───────────────────────────────────────────── @@ -288,6 +306,12 @@ def reject( GRAPH.md Rule 7: An assumption is only ever rejected by evidence that contradicts it. A valid, live NodeType.EVIDENCE node is required. + + ``replacement`` must be new ground. A statement that already names an + assumption -- the one being rejected, or any other, in any state -- + is refused before anything is written: reusing it would rewrite that + record's version and lineage in place, and re-grounding on the + rejected statement itself would make it supersede itself. """ if not evidence_id or not isinstance(evidence_id, str): raise AssumptionError("reject() requires a non-empty evidence_id string") @@ -317,6 +341,17 @@ def reject( f"evidence_id {evidence_id!r} is invalidated" ) + if replacement: + replacement_id = slug(replacement, "assumption") + clash = graph.assumptions.get(replacement_id) + if clash is not None or replacement_id in graph.nodes: + named = clash.statement if clash is not None else replacement + raise AssumptionError( + f"replacement {replacement!r} is not new ground: id " + f"{replacement_id!r} already names {named!r}" + + (f" ({clash.status.value})" if clash is not None else "") + ) + radius = self.blast_radius(assumption_id) node = graph.node(assumption_id) diff --git a/contextmesh/decisions.py b/contextmesh/decisions.py index 2f22b93..b4210e0 100644 --- a/contextmesh/decisions.py +++ b/contextmesh/decisions.py @@ -129,6 +129,17 @@ def decide( cites = tuple(cites) assumptions = tuple(assumptions) produces = tuple(produces) + for target in assumptions: + # depends_on also legally points decision->decision, but that is a + # task ordering wired after decide(), which the fingerprint leaves + # out. Accepting one here would store a digest the decision's own + # edges can never reproduce: unloadable, and never retriable. + found = self.graph.get(target) + if found is not None and found.type is not NodeType.ASSUMPTION: + raise OntologyError( + f"assumptions= takes assumption ids; {target!r} is a " + f"{found.type.value}" + ) fingerprint = decision_fingerprint( title=title, rationale=rationale, @@ -200,7 +211,7 @@ def decide( def _tracked_edge(src: str, etype: EdgeType, dst: str) -> None: key = (src, etype.value, dst) already = key in self.graph._edge_key - edge = self.graph.add_edge(src, etype, dst) + edge = self.graph.add_edge(src, etype, dst, _decision_edge_authorized=True) if not already: created_edge_ids.append(edge.id) diff --git a/contextmesh/evidence.py b/contextmesh/evidence.py index 5fe653c..1dfba68 100644 --- a/contextmesh/evidence.py +++ b/contextmesh/evidence.py @@ -316,9 +316,14 @@ def submit( external_id: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, ) -> EvidenceReceipt: - if not isinstance(text, str) or not text.strip(): + if not isinstance(text, str): raise EvidenceIntakeError("text must be a non-empty string") + # Bounded before strip() for the same reason external_id is below: + # stripping first allocates a temporary proportional to whatever the + # caller sent before the limit gets a chance to refuse it. _check_string_bytes(text, MAX_TEXT_BYTES, "text") + if not text.strip(): + raise EvidenceIntakeError("text must be a non-empty string") if not isinstance(source_id, str) or not source_id.strip(): raise EvidenceIntakeError("source_id must be a non-empty string") source = self.graph.get(source_id) diff --git a/contextmesh/execute.py b/contextmesh/execute.py index 479c303..304b2cb 100644 --- a/contextmesh/execute.py +++ b/contextmesh/execute.py @@ -112,6 +112,10 @@ class Event(str, Enum): CACHED = "cached" BLOCKED = "blocked" FAILED = "failed" + #: A recheck auditor raised instead of returning a verdict. Recorded + #: without changing the task: the work was verified when it ran, and an + #: auditor that could not answer now proves nothing either way. + AUDIT_ERROR = "audit_error" # ── verdicts ───────────────────────────────────────────────────────────── @@ -1651,12 +1655,14 @@ def recheck(self) -> List[InvalidationReport]: verdict = self._audit(task, task.output, assumption) except _AuditorRaised as exc: # One broken auditor must not abort independent rechecks. - # An exception proves no assumption false, so fail only - # this task and keep its ground standing. - task.state = TaskState.FAILED + # An exception proves nothing -- not that the ground is + # false, and not that the work is: it already passed its + # audit when it ran. So the task stays DONE and the error + # is recorded; the next recheck asks again. Failing it here + # would be permanent, since nothing re-runs a FAILED task. self.ledger.record( self.round, - Event.FAILED, + Event.AUDIT_ERROR, name, f"auditor error: {exc}", node_id=task.node_id, diff --git a/contextmesh/graph.py b/contextmesh/graph.py index 78d70d3..c429146 100644 --- a/contextmesh/graph.py +++ b/contextmesh/graph.py @@ -78,6 +78,27 @@ def _no_constants(value: str) -> float: ) +#: Edge kinds out of a decision that ``decide()`` itself writes and that its +#: fingerprint covers. ``depends_on`` counts only into an assumption: a +#: decision->decision dependency is a task ordering the Runner wires after +#: ``decide()`` returns, not part of the decision (see ``_decision_structure``). +_DIGESTED_EDGES = ( + EdgeType.CITES, + EdgeType.DERIVED_FROM, + EdgeType.PRODUCES, + EdgeType.SUPERSEDES, +) + + +def _digest_covers(src: Node, type: EdgeType, dst: Node) -> bool: + """Would this edge change an explicit-id decision's fingerprinted content?""" + if src.type is not NodeType.DECISION or src.attrs.get("decision_identity") != "explicit": + return False + if type is EdgeType.DEPENDS_ON: + return dst.type is NodeType.ASSUMPTION + return type in _DIGESTED_EDGES + + class ContextGraph: """A typed context graph. @@ -264,9 +285,21 @@ def add_edge( *, evidence_ids: Optional[Iterable[str]] = None, weight: float = 1.0, + _decision_edge_authorized: bool = False, ) -> Edge: """Add a typed edge. Raises OntologyError if the pair is not legal. + An explicit-id decision's own edges -- the ones its + ``decision_payload_digest`` covers -- are written by + ``DecisionLog.decide()`` and by snapshot restoration, and by nothing + else. A later edge of one of those kinds would change the content the + digest vouches for, so the next load would refuse a correct snapshot + as tampered and an identical retry of the original ``decide()`` would + be refused as different content. It is refused here instead, where + the caller can see it: pass it to ``decide()``. Adding an edge the + decision already has is still an idempotent repeat. + ``_decision_edge_authorized`` is not part of the public contract. + This is not a binding path: it never takes an ``assumption_id``. A live edge is bound through :meth:`AssumptionLedger.justifies`, which enforces the write boundary (assumption exists and is active, edge @@ -303,6 +336,15 @@ def add_edge( e for e in evidence_ids if e not in edge.evidence_ids ) return edge + if not _decision_edge_authorized and _digest_covers( + self.nodes[src], type, self.nodes[dst] + ): + raise OntologyError( + f"decision {src!r} was recorded with an explicit id, so its " + f"{type.value} edges are part of the content its digest vouches " + f"for; refusing to add {src!r}-[{type.value}]->{dst!r} after the " + "fact -- pass it to DecisionLog.decide() instead" + ) edge_id = slug(f"{src}|{type.value}|{dst}", "edge") if edge_id in self.edges: collision = self.edges[edge_id] @@ -606,6 +648,8 @@ def from_dict( stored.dst, evidence_ids=stored.evidence_ids, weight=stored.weight, + # Restoring is not adding: the digest check below judges these. + _decision_edge_authorized=True, ) # Edge identity is derived from (src, type, dst), so a snapshot id # that disagrees with the recomputed one means the file was edited diff --git a/contextmesh/llm.py b/contextmesh/llm.py index a707883..a61b000 100644 --- a/contextmesh/llm.py +++ b/contextmesh/llm.py @@ -63,6 +63,16 @@ def __init__(self, message: str, *, status: Optional[int] = None) -> None: self.status = status +class LLMResponseTooLargeError(LLMProviderError): + """A successful provider response exceeded ``max_response_bytes``. + + Deliberately not an :class:`LLMTransportError`: an HTTP response *was* + obtained, and the same request will draw the same oversized reply, so + retrying it only re-bills the completion. It fails closed on the first + attempt, with the status and the limit in the message. + """ + + class LLMResponseError(LLMError): """A provider response cannot be interpreted as requested output.""" @@ -284,7 +294,7 @@ def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: D102 return None -def _bounded_read(readable: Any, max_bytes: int) -> bytes: +def _bounded_read(readable: Any, max_bytes: int) -> Optional[bytes]: """Read at most ``max_bytes`` and refuse whatever comes after. ``.read()`` with no argument buffers the whole body regardless of how @@ -296,9 +306,9 @@ def _bounded_read(readable: Any, max_bytes: int) -> bytes: """ data = readable.read(max_bytes + 1) if len(data) > max_bytes: - raise LLMTransportError( - f"provider response exceeded the {max_bytes}-byte limit; refusing to buffer it" - ) + # What an oversized body means depends on the status, so the caller + # decides; this only guarantees nothing past limit + 1 is buffered. + return None return data @@ -317,13 +327,26 @@ def __call__(self, request: HTTPRequest) -> HTTPResponse: ) try: with self._opener.open(raw, timeout=request.timeout) as response: + status = int(response.status) + body = _bounded_read(response, request.max_response_bytes) + if body is None: + raise LLMResponseTooLargeError( + f"provider response (HTTP {status}) exceeded the " + f"{request.max_response_bytes}-byte limit; refusing to buffer it", + status=status, + ) return HTTPResponse( - status=int(response.status), + status=status, headers={str(k): str(v) for k, v in response.headers.items()}, - body=_bounded_read(response, request.max_response_bytes), + body=body, ) except urllib.error.HTTPError as exc: - body = _bounded_read(exc, request.max_response_bytes) + # Nothing reads an error body -- the client acts on the status + # alone -- so an oversized one is dropped after the bounded read + # and the status still decides: a 4xx fails closed at once, a + # 429/5xx is retried. Raising here turned every oversized error + # page into a retried "transport" failure that hid the status. + body = _bounded_read(exc, request.max_response_bytes) or b"" headers = {str(k): str(v) for k, v in exc.headers.items()} if exc.headers else {} return HTTPResponse(status=int(exc.code), headers=headers, body=body) except (urllib.error.URLError, TimeoutError, OSError) as exc: @@ -1173,6 +1196,7 @@ def worker(context: RunContext) -> Dict[str, Any]: "LLMProviderError", "LLMProvenance", "LLMRequestTooLargeError", + "LLMResponseTooLargeError", "LLMResponseError", "LLMResult", "LLMSchemaError", diff --git a/contextmesh/ontology.py b/contextmesh/ontology.py index 1d3ff16..0222b8e 100644 --- a/contextmesh/ontology.py +++ b/contextmesh/ontology.py @@ -1,6 +1,6 @@ """The ontology is a file, not a constant. -``GRAPH.md`` at the repository root is the schema. This module parses it on +``GRAPH.md``, shipped inside this package, is the schema. This module parses it on import so that a change to the documented edge table is immediately a change to what the engine will accept. That is the whole point of the "read on every write" line in the dashboard: the ontology cannot drift away from the docs @@ -14,7 +14,10 @@ from pathlib import Path from typing import Dict, FrozenSet, Iterable, Tuple -ONTOLOGY_FILE = Path(__file__).resolve().parent.parent / "GRAPH.md" +# Inside the package, not beside it: the wheel used to install this as a +# loose top-level site-packages/GRAPH.md, where any other distribution that +# shipped or removed a file of that name would break `import contextmesh`. +ONTOLOGY_FILE = Path(__file__).resolve().parent / "GRAPH.md" _ROW = re.compile(r"^\|(?P.+)\|\s*$") _PAIR = re.compile(r"(?P[a-z_]+)\s*(?:→|->)\s*(?P[a-z_]+)") diff --git a/contextmesh/recheck.py b/contextmesh/recheck.py index 6b784a0..24d5c0a 100644 --- a/contextmesh/recheck.py +++ b/contextmesh/recheck.py @@ -184,13 +184,21 @@ def recheck( graph=runner.graph, ) ) + except EvidenceRecheckError: + # Raised by ctx.disproved() itself: the auditor broke the evidence + # contract. That is the same violation as a disproof returned + # without evidence, which is fatal below, so it is fatal here too + # and the staged MCP transaction is discarded rather than + # committing it as an ordinary auditor outage. + raise except Exception as exc: - # Match Runner.recheck(): an unavailable auditor is a task failure, - # never a disproof, and must not prevent independent rechecks. - task.state = TaskState.FAILED + # Match Runner.recheck(): an unavailable auditor is never a + # disproof, must not prevent independent rechecks, and does not + # fail work that already passed its audit. Record it; the task + # stays DONE and the next recheck asks again. runner.ledger.record( runner.round, - Event.FAILED, + Event.AUDIT_ERROR, task.name, f"auditor error: {type(exc).__name__}: {exc}", node_id=task.node_id, diff --git a/contextmesh/temporal.py b/contextmesh/temporal.py index 49b8d40..e072e09 100644 --- a/contextmesh/temporal.py +++ b/contextmesh/temporal.py @@ -55,7 +55,7 @@ from typing import Any, Dict, Optional from .graph import ContextGraph -from .model import AssumptionStatus, EdgeType, Node, NodeType +from .model import AssumptionStatus, EdgeType, Node, NodeType, decision_fingerprint #: The source attribute GRAPH.md already requires a ``source`` to carry. SOURCE_TIME_ATTR = "retrieved_at" @@ -339,12 +339,41 @@ def as_of_graph(graph: ContextGraph, as_of: Any) -> ContextGraph: for a in payload["assumptions"] if a["id"] in kept ] + for record in payload["nodes"]: + _rewind_decision_digest(record, kept, graph) _mirror_status(payload) projection = ContextGraph.from_dict(payload) _replay_invalidation(projection) return projection +def _rewind_decision_digest( + record: Dict[str, Any], kept: Dict[str, Node], graph: ContextGraph +) -> None: + """Fingerprint an explicit-id decision as it stands in the projection. + + A decision survives by its own source date, while an edge survives only + if its other end does -- so a decision whose ``supported_by`` claim came + later keeps its node and loses that edge. Its stored + ``decision_payload_digest`` still covers the edge, and the ordinary + loader refuses the mismatch as tampering: the projection crashed instead + of answering. The digest is wound back to the projected edges the same + way assumption state is, so the projection stays a graph the ordinary + loader accepts rather than a special case of it. + """ + if ( + record.get("type") != NodeType.DECISION.value + or record.get("attrs", {}).get("decision_identity") != "explicit" + ): + return + structure = graph._decision_structure(record["id"]) + for name in ("cites", "derived_from", "depends_on", "produces"): + structure[name] = {target for target in structure[name] if target in kept} + if structure["supersedes"] not in kept: + structure["supersedes"] = None + record["attrs"]["decision_payload_digest"] = decision_fingerprint(**structure) + + def _replay_invalidation(projection: ContextGraph) -> None: """Invalidation as it stood, derived rather than carried forward. diff --git a/contextmesh_mcp/__init__.py b/contextmesh_mcp/__init__.py index 8a81a9e..7201c38 100644 --- a/contextmesh_mcp/__init__.py +++ b/contextmesh_mcp/__init__.py @@ -39,7 +39,7 @@ mesh_lineage, ) -__version__ = "0.1.0" +__version__ = "0.1.1" __all__ = [ "TOOLS", diff --git a/contextmesh_mcp/writes.py b/contextmesh_mcp/writes.py index 23adb87..d8d89a4 100644 --- a/contextmesh_mcp/writes.py +++ b/contextmesh_mcp/writes.py @@ -279,10 +279,16 @@ def mutate(staged: Session) -> Tuple[Dict[str, Any], bool]: reports = evidence_recheck(runner, require_evidence=True) new_entries = runner.ledger.entries[before:] audited = sum(1 for entry in new_entries if entry.event is Event.AUDITED) + audit_errors = [ + {"task": entry.task, "error": entry.detail} + for entry in new_entries + if entry.event is Event.AUDIT_ERROR + ] return ( { "round": runner.round, "audited": audited, + "audit_errors": audit_errors, "invalidations": [report.to_dict() for report in reports], "ledger_head": runner.ledger.head, }, diff --git a/pyproject.toml b/pyproject.toml index 1b613d5..d869087 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,7 @@ contextmesh-mcp = "contextmesh_mcp.server:main" packages = ["contextmesh", "contextmesh_mcp"] [tool.setuptools.package-data] -contextmesh = ["../GRAPH.md"] +contextmesh = ["GRAPH.md"] [tool.ruff] target-version = "py39" diff --git a/tests/test_assumptions.py b/tests/test_assumptions.py index df6d763..e5b22a8 100644 --- a/tests/test_assumptions.py +++ b/tests/test_assumptions.py @@ -340,5 +340,96 @@ def __setitem__(self, k, v): self.assertEqual(len(self.graph.edges), initial_edges) +class ReplacementMustBeNewGroundTest(unittest.TestCase): + """reject(replacement=...) used to route through assume(), whose + same-statement no-op handed back an existing record for reject() to + rewrite in place -- or, for the rejected statement itself, raised a + self-edge error after the rejection had already been committed.""" + + def setUp(self): + self.graph = ContextGraph() + self.graph.build = 1 + self.ledger = AssumptionLedger(self.graph) + self.source = self.graph.add_node( + NodeType.SOURCE, + "Postmortem", + attrs={"origin": "review", "retrieved_at": "2026-07-01"}, + ) + + def witness(self, text): + return self.graph.add_node( + NodeType.EVIDENCE, + text, + attrs={"kind": "postmortem"}, + provenance=Provenance(source_id=self.source.id, recorded_at_build=1), + ).id + + def snapshot(self, assumption_id): + record = self.graph.assumptions[assumption_id] + node = self.graph.node(assumption_id) + return ( + record.status, + record.version, + record.supersedes, + record.superseded_by, + node.invalidated, + len(self.graph.edges), + ) + + def test_replacing_with_the_rejected_statement_is_refused_before_any_write(self): + a = self.ledger.assume("A holds") + before = self.snapshot(a.id) + with self.assertRaisesRegex(AssumptionError, "not new ground"): + self.ledger.reject(a.id, evidence_id=self.witness("A broke"), replacement="A holds") + self.assertEqual(self.snapshot(a.id), before) + self.assertIs(self.graph.assumptions[a.id].status, AssumptionStatus.ACTIVE) + + def test_replacing_with_an_existing_rejected_statement_leaves_it_untouched(self): + a = self.ledger.assume("A holds") + b = self.ledger.assume("B holds") + self.ledger.reject(b.id, evidence_id=self.witness("B broke")) + b_before, a_before = self.snapshot(b.id), self.snapshot(a.id) + with self.assertRaisesRegex(AssumptionError, r"not new ground.*rejected"): + self.ledger.reject(a.id, evidence_id=self.witness("A broke"), replacement="B holds") + self.assertEqual(self.snapshot(b.id), b_before) + self.assertEqual(self.snapshot(a.id), a_before) + + def test_a_new_replacement_still_supersedes(self): + a = self.ledger.assume("A holds") + report = self.ledger.reject( + a.id, evidence_id=self.witness("A broke"), replacement="C holds" + ) + new = self.graph.assumptions[report.replacement_id] + self.assertEqual((new.statement, new.version, new.supersedes), ("C holds", 2, a.id)) + + +class ReassumeDoesNotDropEvidenceTest(unittest.TestCase): + def setUp(self): + self.graph = ContextGraph() + self.ledger = AssumptionLedger(self.graph) + source = self.graph.add_node( + NodeType.SOURCE, "Bench", attrs={"origin": "bench", "retrieved_at": "2026-07-01"} + ) + self.ev = self.graph.add_node( + NodeType.EVIDENCE, + "linear growth observed", + attrs={"kind": "bench"}, + provenance=Provenance(source_id=source.id), + ).id + + def test_new_evidence_on_an_existing_statement_is_refused_not_dropped(self): + a = self.ledger.assume("X holds") + edges = len(self.graph.edges) + with self.assertRaisesRegex(AssumptionError, "does not add justification"): + self.ledger.assume("X holds", evidence_ids=[self.ev]) + self.assertEqual(self.graph.assumptions[a.id].evidence_ids, []) + self.assertEqual(len(self.graph.edges), edges) + + def test_repeating_the_same_evidence_is_still_a_no_op(self): + a = self.ledger.assume("X holds", evidence_ids=[self.ev]) + self.assertIs(self.ledger.assume("X holds", evidence_ids=[self.ev]), a) + self.assertIs(self.ledger.assume("X holds"), a) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_auditor_failures.py b/tests/test_auditor_failures.py index b93532c..6e9aa1e 100644 --- a/tests/test_auditor_failures.py +++ b/tests/test_auditor_failures.py @@ -96,20 +96,33 @@ def second_audit(ctx): self.assertEqual(reports, []) self.assertEqual(audited, ["first", "second"]) - self.assertIs(runner.state("first"), TaskState.FAILED) + # Work that already passed its audit is not failed by an auditor + # that cannot answer now: nothing would ever re-run it. + self.assertIs(runner.state("first"), TaskState.DONE) self.assertIs(runner.state("second"), TaskState.DONE) assumption = runner.graph.assumptions[runner["first"].assumption_id] self.assertIs(assumption.status, AssumptionStatus.ACTIVE) - failures = [ + errors = [ entry for entry in runner.ledger.of("first") - if entry.event is Event.FAILED + if entry.event is Event.AUDIT_ERROR ] self.assertIn( "auditor error: ConnectionError: audit backend down", - failures[-1].detail, + errors[-1].detail, + ) + self.assertFalse( + [e for e in runner.ledger.of("first") if e.event is Event.FAILED] ) + # The outage clears; the next recheck simply asks again. + fail_now["value"] = False + audited.clear() + self.assertEqual(runner.recheck(), []) + self.assertEqual(audited, ["first", "second"]) + self.assertIs(runner.state("first"), TaskState.DONE) + self.assertIs(runner.ledger.of("first")[-1].event, Event.AUDITED) + def test_invalid_auditor_return_is_still_a_contract_error(self): runner = Runner("bad verdict") runner.task( diff --git a/tests/test_decision_identity.py b/tests/test_decision_identity.py index 23c5bcc..e816a08 100644 --- a/tests/test_decision_identity.py +++ b/tests/test_decision_identity.py @@ -687,5 +687,117 @@ def test_a_decision_to_decision_dependency_survives_a_snapshot_round_trip(self): self.assertEqual(again.id, downstream.id) +class ExplicitDecisionContentCannotGrowAfterDecideTest(unittest.TestCase): + """The fingerprint covers the edges decide() writes, and is checked + against the node's actual edges. An edge of one of those kinds added + afterwards used to be accepted silently -- and then the correct snapshot + failed to load as "tampered with or corrupted", and an identical retry + of the original decide() was refused as different content.""" + + def setUp(self): + self.graph, self.source = _graph_with_source() + self.ledger = AssumptionLedger(self.graph) + self.log = DecisionLog(self.graph) + self.ground = self.ledger.assume("The shard count is stable") + + def decide(self, **extra): + return self.log.decide( + "Partition the rebuild", + "Bounded memory.", + source_id=self.source.id, + id="decision:partition", + **extra, + ) + + def test_a_later_dependency_on_an_assumption_is_refused_where_it_is_added(self): + decision = self.decide() + edges = len(self.graph.edges) + with self.assertRaisesRegex(OntologyError, "pass it to DecisionLog.decide"): + self.ledger.depends(decision.id, self.ground.id) + self.assertEqual(len(self.graph.edges), edges) + # ...so what was saved still loads, and still retries. + restored = ContextGraph.from_dict(self.graph.to_dict()) + self.assertIs(DecisionLog(restored).decide( + "Partition the rebuild", + "Bounded memory.", + source_id=self.source.id, + id="decision:partition", + ).id, decision.id) + + def test_the_same_dependency_passed_to_decide_is_its_own_content(self): + decision = self.decide(assumptions=[self.ground.id]) + ContextGraph.from_dict(self.graph.to_dict()) + # Repeating an edge the decision already has is not new content. + self.ledger.depends(decision.id, self.ground.id) + ContextGraph.from_dict(self.graph.to_dict()) + + def test_auto_minted_decisions_are_not_frozen_by_this(self): + decision = self.log.decide("Auto", "No id.", source_id=self.source.id) + self.ledger.depends(decision.id, self.ground.id) + ContextGraph.from_dict(self.graph.to_dict()) + + def test_a_decision_is_not_accepted_as_an_assumption(self): + upstream = self.log.decide("Upstream", "r", source_id=self.source.id, id="decision:up") + nodes, edges = len(self.graph.nodes), len(self.graph.edges) + with self.assertRaisesRegex(OntologyError, "takes assumption ids"): + self.decide(assumptions=[upstream.id]) + self.assertEqual((len(self.graph.nodes), len(self.graph.edges)), (nodes, edges)) + + +class ProjectionRewindsExplicitDecisionDigestTest(unittest.TestCase): + """as_of_graph keeps a decision by its own date and drops an edge whose + other end came later. The stored digest still covered that edge, so the + loader rejected the projection as tampered and the time-travel query + crashed instead of answering.""" + + def build(self, explicit): + graph = ContextGraph() + graph.build = 1 + early = graph.add_node( + NodeType.SOURCE, "2024 review", + attrs={"origin": "review", "retrieved_at": "2024-03-01"}, + ) + late = graph.add_node( + NodeType.SOURCE, "2025 review", + attrs={"origin": "review", "retrieved_at": "2025-03-01"}, + ) + claim = graph.add_node( + NodeType.CLAIM, "later evidence", provenance=Provenance(source_id=late.id) + ) + graph.add_edge(claim.id, EdgeType.DERIVED_FROM, late.id) + extra = {"id": "decision:choose-x"} if explicit else {} + decision = DecisionLog(graph).decide( + "Choose X", "because", source_id=early.id, supported_by=[claim.id], **extra + ) + return graph, decision, claim + + def test_the_projection_answers_and_keeps_the_decision_without_the_later_edge(self): + from contextmesh.temporal import as_of_graph + + graph, decision, claim = self.build(explicit=True) + projection = as_of_graph(graph, "2024-06-01") + self.assertIn(decision.id, projection.nodes) + self.assertNotIn(claim.id, projection.nodes) + # A graph the ordinary loader accepts, not a special case of it. + ContextGraph.from_dict(projection.to_dict()) + # And the live graph's own record is untouched. + self.assertEqual( + graph.node(decision.id).attrs["decision_payload_digest"], + ContextGraph.from_dict(graph.to_dict()) + .node(decision.id) + .attrs["decision_payload_digest"], + ) + + def test_a_horizon_that_keeps_every_edge_keeps_the_digest(self): + from contextmesh.temporal import as_of_graph + + graph, decision, _ = self.build(explicit=True) + projection = as_of_graph(graph, "2030-01-01") + self.assertEqual( + projection.node(decision.id).attrs["decision_payload_digest"], + graph.node(decision.id).attrs["decision_payload_digest"], + ) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_edge_binding.py b/tests/test_edge_binding.py index 1c19a94..722d7fd 100644 --- a/tests/test_edge_binding.py +++ b/tests/test_edge_binding.py @@ -29,8 +29,10 @@ def _claim(graph, label, source, *, id=None): ) -def _decision(graph, label, source, *, id=None): - return DecisionLog(graph).decide(label, "fixture", source_id=source.id, id=id) +def _decision(graph, label, source, *, id=None, assumptions=()): + return DecisionLog(graph).decide( + label, "fixture", source_id=source.id, id=id, assumptions=assumptions + ) def _evidence(graph, label="Contradiction", *, kind="disproof", id=None): @@ -129,13 +131,18 @@ def setUp(self): ) self.edge_x = self.graph.add_edge(self.claim.id, EdgeType.SUPPORTS, self.decision_x.id) - self.decision_y = _decision( - self.graph, "Adopt tenant-aware sizing", self.source, id="decision:y" - ) - self.assumption = self.ledger.assume("Shard count grows linearly with corpus size") self.ledger.justifies(self.assumption.id, self.edge_x.id) - self.graph.add_edge(self.decision_y.id, EdgeType.DEPENDS_ON, self.assumption.id) + # Y's dependency is recorded by decide() itself. Adding it afterwards + # made this graph one that failed to load (Y is an explicit-id + # decision), and is now refused. + self.decision_y = _decision( + self.graph, + "Adopt tenant-aware sizing", + self.source, + id="decision:y", + assumptions=[self.assumption.id], + ) def test_reject_kills_the_binding_and_the_dependency_but_not_x_s_endpoints(self): report = self.ledger.reject( diff --git a/tests/test_evidence.py b/tests/test_evidence.py index eae3b90..8b61fe0 100644 --- a/tests/test_evidence.py +++ b/tests/test_evidence.py @@ -122,6 +122,25 @@ def test_oversized_text_is_refused_before_mutation(self): # The boundary itself is accepted: this is a limit, not an off-by-one trap. self.submit(text="x" * MAX_TEXT_BYTES, external_id="at-the-text-limit") + def test_oversized_text_is_refused_before_it_is_stripped(self): + """strip() on an oversized string allocates a copy of it before the + limit can refuse it -- the reason external_id is bounded first. Text + used to be stripped first.""" + stripped = [] + + class Recording(str): + def strip(self, *args): + stripped.append(len(self)) + return super().strip(*args) + + with self.assertRaisesRegex(EvidenceIntakeError, "byte limit"): + self.submit(text=Recording(" " * (MAX_TEXT_BYTES + 1))) + self.assertEqual(stripped, []) + # Blank text within the limit is still refused as blank. + with self.assertRaisesRegex(EvidenceIntakeError, "non-empty"): + self.submit(text=Recording(" ")) + self.assertEqual(stripped, [3]) + def test_oversized_metadata_is_refused_before_mutation(self): before = self.graph.to_dict() with self.assertRaisesRegex(EvidenceIntakeError, "byte limit"): diff --git a/tests/test_llm.py b/tests/test_llm.py index c25c5bd..b90451b 100644 --- a/tests/test_llm.py +++ b/tests/test_llm.py @@ -21,6 +21,7 @@ LLMConfigurationError, LLMProviderError, LLMRequestTooLargeError, + LLMResponseTooLargeError, LLMResponseError, LLMSchemaError, LLMTransportError, @@ -940,11 +941,13 @@ def test_a_plain_response_still_arrives(self): self.assertEqual(response.status, 200) self.assertEqual(json.loads(response.body), {"ok": True}) - def oversized_server(self, *, status=200): + def oversized_server(self, *, status=200, hits=None): payload = b"x" * 4096 class Oversized(BaseHTTPRequestHandler): def do_POST(self): + if hits is not None: + hits.append(status) self.send_response(status) self.send_header("Content-Type", "application/json") self.end_headers() @@ -958,15 +961,55 @@ def log_message(self, *args): def test_an_oversized_success_body_is_refused_without_buffering_it(self): port = self.oversized_server(status=200) request = self.request_to(port, max_response_bytes=1024) - with self.assertRaisesRegex(LLMTransportError, "1024-byte limit"): + pattern = r"HTTP 200.*1024-byte limit" + with self.assertRaisesRegex(LLMResponseTooLargeError, pattern) as caught: UrllibTransport()(request) - - def test_an_oversized_error_body_is_refused_the_same_way(self): - """A provider returning a huge *error* page is the same memory risk.""" + self.assertEqual(caught.exception.status, 200) + # Not a transport failure: a response was obtained, so it must not be + # retried as if the network had dropped. + self.assertNotIsInstance(caught.exception, LLMTransportError) + + def test_an_oversized_error_body_is_dropped_and_the_status_kept(self): + """A huge *error* page is the same memory risk, but nothing reads an + error body, so it is discarded after the bounded read and the status + is what the client acts on.""" port = self.oversized_server(status=500) request = self.request_to(port, max_response_bytes=1024) - with self.assertRaisesRegex(LLMTransportError, "1024-byte limit"): - UrllibTransport()(request) + response = UrllibTransport()(request) + self.assertEqual(response.status, 500) + self.assertEqual(response.body, b"") + + def client(self): + return LLMClient( + LLMConfig(provider="openai", model="gpt-test", api_key="sk-not-a-real-key"), + transport=UrllibTransport(), + sleep=lambda _seconds: None, + ) + + def test_an_oversized_success_is_not_retried(self): + hits = [] + port = self.oversized_server(status=200, hits=hits) + with self.assertRaisesRegex(LLMResponseTooLargeError, "1024-byte limit"): + self.client()._send(self.request_to(port, max_response_bytes=1024)) + self.assertEqual(hits, [200]) + + def test_an_oversized_client_error_fails_closed_on_the_first_attempt(self): + hits = [] + port = self.oversized_server(status=400, hits=hits) + with self.assertRaises(LLMProviderError) as caught: + self.client()._send(self.request_to(port, max_response_bytes=1024)) + self.assertEqual(caught.exception.status, 400) + self.assertIn("failed closed", str(caught.exception)) + self.assertEqual(hits, [400]) + + def test_an_oversized_server_error_keeps_its_retry_semantics(self): + hits = [] + port = self.oversized_server(status=503, hits=hits) + client = self.client() + with self.assertRaises(LLMProviderError) as caught: + client._send(self.request_to(port, max_response_bytes=1024)) + self.assertEqual(caught.exception.status, 503) + self.assertEqual(len(hits), client.config.max_attempts) def test_a_body_at_exactly_the_limit_is_accepted(self): port = self.oversized_server(status=200) diff --git a/tests/test_mcp_recheck_auditor_failures.py b/tests/test_mcp_recheck_auditor_failures.py index 647fdbd..355ff62 100644 --- a/tests/test_mcp_recheck_auditor_failures.py +++ b/tests/test_mcp_recheck_auditor_failures.py @@ -5,6 +5,7 @@ from contextmesh.execute import Event, ExecutionError, Runner, TaskRegistry, TaskState from contextmesh.model import AssumptionStatus +from contextmesh.recheck import EvidenceRecheckError from contextmesh_mcp.session import Checkpointer, Session from contextmesh_mcp.writes import mesh_recheck @@ -33,7 +34,7 @@ def _saved_session(self, first_audit, second_audit): base.runner = runner return base, registry - def test_one_auditor_outage_is_committed_as_failure_and_second_recheck_runs(self): + def test_one_auditor_outage_is_reported_not_failed_and_second_recheck_runs(self): fail = {"value": False} calls = [] @@ -60,24 +61,35 @@ def second(ctx): self.assertTrue(result.changed) self.assertEqual(cp.commits, 1) self.assertEqual(calls, ["first", "second"]) + # The client is told, instead of seeing audited: 1 and nothing else. + self.assertEqual(result.payload["audited"], 1) + self.assertEqual( + result.payload["audit_errors"], + [{"task": "first", "error": "auditor error: RuntimeError: auditor outage"}], + ) runner = result.session.runner assert runner is not None - self.assertIs(runner.state("first"), TaskState.FAILED) + self.assertIs(runner.state("first"), TaskState.DONE) self.assertIs(runner.state("second"), TaskState.DONE) assumption = runner.graph.assumptions[runner["first"].assumption_id] self.assertIs(assumption.status, AssumptionStatus.ACTIVE) - failures = [ - entry for entry in runner.ledger.of("first") if entry.event is Event.FAILED + errors = [ + entry for entry in runner.ledger.of("first") if entry.event is Event.AUDIT_ERROR ] - self.assertIn( - "auditor error: RuntimeError: auditor outage", - failures[-1].detail, - ) + self.assertIn("auditor error: RuntimeError: auditor outage", errors[-1].detail) restored = Session.load(tmp, registry=registry) assert restored.runner is not None - self.assertIs(restored.runner.state("first"), TaskState.FAILED) + self.assertIs(restored.runner.state("first"), TaskState.DONE) self.assertIs(restored.runner.state("second"), TaskState.DONE) + # Recovery needs no repair: the next recheck asks again. + fail["value"] = False + calls.clear() + again = mesh_recheck(restored, Checkpointer(restored)) + self.assertEqual(calls, ["first", "second"]) + self.assertEqual(again.payload["audited"], 2) + self.assertEqual(again.payload["audit_errors"], []) + def test_invalid_auditor_return_remains_a_contract_error_and_commits_nothing(self): bad = {"value": False} @@ -104,6 +116,34 @@ def second(ctx): assert live.runner is not None self.assertIs(live.runner.state("first"), TaskState.DONE) + def test_an_evidence_contract_violation_inside_disproved_commits_nothing(self): + """ctx.disproved(evidence_id="") breaks the evidence contract. It used + to be caught as an auditor outage and committed as a task failure; the + same violation detected after return was already fatal.""" + bad = {"value": False} + + def first(ctx): + if bad["value"]: + return ctx.disproved("gone", evidence_id="") + return ctx.ok("holds") + + base, registry = self._saved_session(first, lambda ctx: ctx.ok("holds")) + with tempfile.TemporaryDirectory() as tmp: + base.save(tmp) + live = Session.load(tmp, registry=registry) + cp = Checkpointer(live) + bad["value"] = True + + with self.assertRaises(EvidenceRecheckError): + mesh_recheck(live, cp) + + self.assertEqual(cp.commits, 0) + assert live.runner is not None + self.assertIs(live.runner.state("first"), TaskState.DONE) + self.assertFalse( + [e for e in live.runner.ledger.of("first") if e.event is Event.FAILED] + ) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_ontology.py b/tests/test_ontology.py index f0f6b75..2d540ef 100644 --- a/tests/test_ontology.py +++ b/tests/test_ontology.py @@ -287,5 +287,25 @@ def test_empty_graph_is_truthy(self): self.assertTrue(ContextGraph()) +class SchemaShipsInsideThePackageTest(unittest.TestCase): + """0.1.1 installed the schema as a loose top-level site-packages/GRAPH.md + (package-data "../GRAPH.md"), where another distribution shipping or + removing a file of that name would break `import contextmesh`.""" + + def test_the_schema_is_read_from_inside_the_package(self): + import contextmesh + + package = Path(contextmesh.__file__).resolve().parent + self.assertEqual(Path(ONTOLOGY_FILE).parent, package) + self.assertTrue(Path(ONTOLOGY_FILE).is_file()) + + def test_package_data_does_not_reach_outside_the_package(self): + pyproject = Path(__file__).resolve().parent.parent / "pyproject.toml" + text = pyproject.read_text(encoding="utf-8") + section = text.split("[tool.setuptools.package-data]", 1)[1].split("\n[", 1)[0] + self.assertNotIn("..", section) + self.assertIn('"GRAPH.md"', section) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_version.py b/tests/test_version.py new file mode 100644 index 0000000..9093348 --- /dev/null +++ b/tests/test_version.py @@ -0,0 +1,38 @@ +"""The version a user sees at import time is the version that was released. + +0.1.1 shipped to PyPI while both packages still reported 0.1.0: the release +workflow checks the tag against pyproject.toml and never read __version__. +This fails on the version-bump pull request instead of after publication. +""" + +import re +import unittest +from pathlib import Path + +import contextmesh +import contextmesh_mcp + +PYPROJECT = Path(__file__).resolve().parent.parent / "pyproject.toml" + + +def _project_version() -> str: + # tomllib is 3.11+ and the suite runs on 3.9, so read the one key directly: + # the first top-level `version = "..."` inside the [project] table. + text = PYPROJECT.read_text(encoding="utf-8") + table = re.search(r"^\[project\]\s*$(.*?)(?=^\[|\Z)", text, re.M | re.S) + assert table is not None, "pyproject.toml has no [project] table" + match = re.search(r'^version\s*=\s*"([^"]+)"\s*$', table.group(1), re.M) + assert match is not None, "[project] has no static version" + return match.group(1) + + +class VersionAgreementTest(unittest.TestCase): + def test_contextmesh_reports_the_packaged_version(self): + self.assertEqual(contextmesh.__version__, _project_version()) + + def test_contextmesh_mcp_reports_the_packaged_version(self): + self.assertEqual(contextmesh_mcp.__version__, _project_version()) + + +if __name__ == "__main__": + unittest.main()