diff --git a/CHANGELOG.md b/CHANGELOG.md index 2923c3f..48de4f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -82,6 +82,12 @@ Also: `impact.analyze` gained `pack_overrides`, so a candidate pack that exists *A phrasing guard that matches nothing fails quietly, which is worth a fixture.* Writing the fixture pack's money phrasing, the first guard was `when: { value: "0.00" }` — and a money decision's value is an amount and a currency, not a scalar, so it matched nothing. The demo did not complain: it fell through to the unguarded case and rendered a plausible answer. The correct form is `amount: positive`. The fixture keeps the corrected version with a comment naming the mistake, because a pack author will make it and the failure mode is a verdict that reads fine and came from the wrong branch. +*The two loudest-looking tests were the quietest, and neither showed up as a failure.* Converting the rule studio's suite, the deletion measurement said 29 failures. It was 31. `test_declared_cases_run_green_against_every_committed_pack` looped over a glob of packs, and with the packs deleted the loop body never ran; `test_every_refusal_example_is_reported_not_raised` was `@pytest.mark.parametrize`d over a directory, and parametrize is evaluated at *collection*, so an empty directory produced zero test cases. pytest reports the count that remains and says nothing about what left. Both tests passed. Both were named after claims nothing was checking. The fix in each is one line — assert the glob is non-empty before using it, and loop inside the test rather than parametrizing over the filesystem — and the general form is now in CLAUDE.md, because "asserts over an empty glob" was already written down and these two are the shapes it takes in practice. + +*The blast radius of an edit is not a syntactic property of the edit.* The studio shows a diff, a solver-backed equivalence panel and corpus impact side by side, and it is easy to read that as belt-and-braces for careless authors. The fixture pack now makes the real case reproducible: change one derived value from 50 to 70, and all three of the pack's decisions change what they conclude while two of the three rules stay byte-identical on the page — they read the moved value as a `derived` binding, and nothing in their own text, guards, or grid row moves at all. The equivalence panel returns a witness naming a widget scoring exactly 50, the boundary where the two packs part. The diff is complete and honest and it is still not where the effect lives. That is the argument for the panel, and it is stronger than the one the docs had been making. + +*A fixture corpus can be too agreeable to be useful.* Every case in `fixtures/` scored either 12 or 80 against a threshold of 50, so any edit to that threshold flipped all three restricted cases at once or none — which meant impact analysis over these fixtures could only ever answer "everything moved", and could not demonstrate the thing it exists for: a pack whose *meaning* moved while every declared outcome stayed green. `fx-0006` scores 60. That is the whole reason it exists, and the bar it clears is a new one for this corpus: not a behaviour that was unreachable, but a *distinction* the corpus could not draw. + *A boolean decision never reaches the machinery that words the answer.* Converting the demo's API suite stopped at once: half its tests assert that verdict wording is **pack data** — `phrasing:` blocks, placeholders, tones — and the fixture pack's only decision was boolean, so it took the kernel's Yes/No fallback and never reached that path. The entire phrasing mechanism was untestable except through the teaching packs, which is precisely the dependency this phase exists to sever. The pack grew a money decision with a phrasing block, and the scenario grew a `sensitivity: pii` fact for the report renderer's redaction path. Growing it is a rebuild rather than an edit — `pack.version` is inside every receipt — and the rebuild found two literals that should always have been derived: a decision-digest vector hardcoding a "later" pack version that the bump made the *current* one, collapsing an equivalence class into its base, and a PROV-O test pinning the pack URN. The equivalence test caught the first, which is the whole reason it checks a relation rather than a table of constants. *A corpus is not a scenario, and the demo reads scenarios.* The fixture cases ground their facts in attestations, which is honest for synthetic data and leaves the whole span machinery — the evidence browser's highlighting, quotes in the audit report, span verification itself — with nothing to exercise. So the fixtures grew one: a generated PDF, its rendition, and two facts grounded in character spans of that rendition, one of them below the pack's confidence floor so a review arc has something to resolve. The spans are *found* in the rendition text by the builder rather than typed, and a check asserts every quote equals the slice it claims — a hand-counted offset is a fact that lies about its own evidence. diff --git a/CLAUDE.md b/CLAUDE.md index afce8ad..8d6333d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,7 +23,7 @@ New to the codebase? README for the argument, [docs/demo_tour.md](docs/demo_tour | `rulepacks/` | Six packs (insurance + mortgage closing), each `pack.yaml` + `expected.yaml` (+ `fixtures/`) | | `starters/` | Synthetic documents, renditions, span-verified facts, one demo scenario per vertical | | `golden/` | 351 committed cases + receipts — the replay/impact baseline | -| `fixtures/` | The corpus **duly's own suites** run on: one invented domain, four cases, committed receipts. Toolkit, so it survives `git rm -r examples/` — read [fixtures/README.md](fixtures/README.md) before adding to it | +| `fixtures/` | The corpus **duly's own suites** run on: one invented domain, five cases, committed receipts, declared outcomes, a scenario, DMN inputs. Toolkit, so it survives `git rm -r examples/` — read [fixtures/README.md](fixtures/README.md) before adding to it | | `whatif/` | Backward queries: free one input, solve the pack for it, verify every answer by re-running the kernel (`python -m duly_whatif`, optional z3) | | `dmn/` | DMN 1.3+ decision-table compiler: S-FEEL cell compiler, hit-policy mapping, deterministic pack emitter, CLI (`python -m duly_dmn`) — [dmn/README.md](dmn/README.md) | | `examples/` | Reference wiring: duly consumed from *outside*, by software that is not duly. `minimal-integration` is the whole contract at its smallest (and the only thing proved to run with the source tree absent); `closing-scheduler` is the CP-SAT one — [examples/README.md](examples/README.md) | @@ -73,13 +73,13 @@ Run the full suite, replay, and spec validation before any commit. A change that - **Only `attribute` bindings can prove disjointness — `derived` ones cannot.** Narrower than the boolean-guard gotcha below and less visible: `_equality_guards` inspects only `when` items whose variable resolves to an *attribute* binding, so `category == "ZeroTolerance"` on a `derived` binding proves nothing however string-equal it looks. Two same-priority rules separated only by a derived-value guard need an explicit `overrides`. - **Test filenames collide across suites.** Test dirs have no `__init__.py`, so pytest imports by basename: `dmn/tests/test_cli.py` broke collection against `kernel/tests/test_cli.py`. Before adding a suite, run `find . -name "test_*.py" | sed 's#.*/##' | sort | uniq -d`. - **Boolean guards don't prove disjointness.** The pack validator's same-priority check accepts only *quoted-string* equality guards (`state == "US-NY"`) as a disjointness proof. Two rules split by `x == true` / `x == false` need an explicit `overrides`, even though they look disjoint. -- **A test that would still pass with its subject deleted is not a test, and that is why `fixtures/` exists.** The toolkit's suites assert *toolkit* behaviour, so they run on [`fixtures/`](fixtures/README.md) — never on `golden/`, `rulepacks/` or `starters/`, which are example content M5 relocates under `examples/` for an adopter to delete. Delete the example content and a suite pointed at it does not fail: it stops being collected, or skips, or asserts over an empty glob, all of which read exactly like success. Two consequences. Writing a toolkit test, reach for `fixtures/`; writing a test whose *subject* is the example content (that the six packs load, that their declared outcomes hold), leave it pointed there — it moves with them. And the rule reaches further than tests: `spec/decision-digest-vectors.json` was a **contract artifact** generated from `golden/` receipts, which is the same defect with a longer fuse. +- **A test that would still pass with its subject deleted is not a test, and that is why `fixtures/` exists.** The toolkit's suites assert *toolkit* behaviour, so they run on [`fixtures/`](fixtures/README.md) — never on `golden/`, `rulepacks/` or `starters/`, which are example content M5 relocates under `examples/` for an adopter to delete. Delete the example content and a suite pointed at it does not fail: it stops being collected, or skips, or asserts over an empty glob, all of which read exactly like success. **The two loudest-looking forms are the quietest**, and `test_rules_api` had both: a `for path in glob(...)` loop whose body simply never ran, and a `@pytest.mark.parametrize` over a glob — parametrize is evaluated at *collection*, so an empty directory produced zero test cases and pytest reported only the count that remained. The fix in each is one line: assert the glob is non-empty before using it, and loop inside the test rather than parametrizing over the filesystem. Two consequences. Writing a toolkit test, reach for `fixtures/`; writing a test whose *subject* is the example content (that the six packs load, that their declared outcomes hold), leave it pointed there — it moves with them. And the rule reaches further than tests: `spec/decision-digest-vectors.json` was a **contract artifact** generated from `golden/` receipts, which is the same defect with a longer fuse. - **`expected.yaml` is not the corpus.** Pack outcome declarations run in CI, but impact analysis runs *over `golden/`*. A pack without a generator template in `assurance/duly_assurance/generate.py` gets "0 decisions flip" for every edit. Both are required. - **Non-boolean decisions need *pack* phrasing.** A decision that isn't boolean renders as a bare `attribute = value` unless its `decisions[]` entry carries a `phrasing:` block ([spec/rule-ir.md](spec/rule-ir.md), "Decision phrasing"). The fix is in the pack, never in `demo/app.py`; `validate_pack` rejects a malformed block, an unknown placeholder, or a tone outside `pos/neg/warn/""` where the pack loads. Booleans still get a Yes/No fallback. Phrasing is presentation and must stay out of every hashed body — putting it in a receipt would change every hash. - **Rule ids are permanent, and now conventional.** Ids sit in `rulesFired` on every receipt that cited them, so an id encoding a day count, a year, or a statute section is wrong forever once the law moves — `NY-NR-45` is in 76 golden receipts. New ids are `-[-QUALIFIER][-NN]` with `PREFIX = pack.idPrefix`; `validate_pack` refuses digits outside the two-digit tail, a tail echoing the rule's own numbers, and an id outside the pack's family. The 46 pre-convention ids are exempt by an explicit list in [kernel/duly_kernel/rule_ids.py](kernel/duly_kernel/rule_ids.py) — 17 of them would fail today. Checks run only for packs declaring `idPrefix`; a kernel test requires every committed pack to declare one. - **The studio's decision tables are a projection, and re-emission drops the arguing part.** `demo/rules_api.py` renders packs as DMN-shaped grids using the *kernel's* expression parser (`duly_kernel.expr.parse` + a Var walk) — never a second parser, and never a DMN decompiler, because `duly_dmn` is one-way on purpose. Two consequences when you touch it. A guard referencing more than one binding belongs in the row's `cross` list, not in a column; flattening it into a cell would misstate the rule. And a structured edit re-emits the pack through `duly_dmn.emit.emit_pack`, which is byte-deterministic but carries no comments — so `DEMO-SYNTHETIC`, `TODO(verify)` and `MODELING BOUNDARY` headers are lost, and *nothing* catches it: validation passes, replay is byte-identical, impact reports zero. That is why the studio shows a normalised diff and the raw file diff side by side and keeps a lossless YAML-text path. `emit_pack` emits every top-level key in input order for the same reason — it used to emit a fixed `pack/decisions/rules` allow-list, which silently dropped `abstentionPolicy` and `calendars` from any hand-written pack it round-tripped. - **JavaScript's JSON silently breaks content addressing, and the symptom accuses the wrong party.** JS has one number type, so `JSON.parse`/`JSON.stringify` turns a fact's `"score": 1.0` into `1` — a different canonical body, a different `contentHash`. A browser that parses a receipt and posts it back as an object is asking the server to verify a document nobody emitted, and the server dutifully reports *every* genuine fact as tampered with. So `/api/receipts/inspect` takes raw JSON **text**, one blob per pasted box or chosen file, and Python does the only parse (`_parse_documents`); the server sorts receipts from facts by which field each carries, so no caller ever has to re-serialize to fit a shape. Any future surface that moves hashed bytes through the browser inherits this: move the text, never the object. Python round-trips `1.0` faithfully, which is why the API tests only caught it once they went through the real page. -- **The evidence browser recomputes liveness on purpose, and `demo/tests/test_api.py` forces fixture mode on everyone.** `demo/evidence_api.py` replays the store's event log instead of calling `FactStore.as_of`, because as_of answers "which facts survive" and a browser also has to show the ones that did not — superseded, retracted, not-yet-known. The two projections must agree, and `test_the_browsers_projection_is_the_stores` walks every case at every point on its timeline asserting they do; keep them as separate implementations or that test becomes a tautology. Two more things bite. Its fact universe is seeded from the far-future `as_of` and walked through supersession chains, so a fact retracted *without* replacement is unreachable — nothing in the demo retracts, and the alternative is reaching past the store's public API. And `demo/tests/test_api.py` sets `DULY_DEMO_FORCE_FIXTURE=1` at **import** time, process-wide, so any store-backed demo suite must `monkeypatch.delenv` it and reset the runtime or it will pass alone and fail in the directory run. **The same binding cuts the other way, and is easier to miss:** a suite that points `DULY_DEMO_CONTENT` at a temp root must reload the demo modules *back* on teardown, because `monkeypatch` unsets the variable and un-reloads nothing — converting one suite to a fixture content root turned 0 failures into 50 across the rest of the directory until that teardown existed. [`demo/tests/demotest_helpers.py`](demo/tests/demotest_helpers.py) is the shape, including how a corpus becomes a content root. +- **The evidence browser recomputes liveness on purpose, and `demo/tests/test_api.py` forces fixture mode on everyone.** `demo/evidence_api.py` replays the store's event log instead of calling `FactStore.as_of`, because as_of answers "which facts survive" and a browser also has to show the ones that did not — superseded, retracted, not-yet-known. The two projections must agree, and `test_the_browsers_projection_is_the_stores` walks every case at every point on its timeline asserting they do; keep them as separate implementations or that test becomes a tautology. Two more things bite. Its fact universe is seeded from the far-future `as_of` and walked through supersession chains, so a fact retracted *without* replacement is unreachable — nothing in the demo retracts, and the alternative is reaching past the store's public API. And `demo/tests/test_api.py` sets `DULY_DEMO_FORCE_FIXTURE=1` at **import** time, process-wide, so any store-backed demo suite must `monkeypatch.delenv` it and reset the runtime or it will pass alone and fail in the directory run. **The same binding cuts the other way, and is easier to miss:** a suite that points `DULY_DEMO_CONTENT` at a temp root must reload the demo modules *back* on teardown, because `monkeypatch` unsets the variable and un-reloads nothing — converting one suite to a fixture content root turned 0 failures into 50 across the rest of the directory until that teardown existed. Call [`demotest_helpers.reload_demo()`](demo/tests/demotest_helpers.py) on both sides rather than writing the reload out: it was copied into a second suite once, which is one copy short of the version that gets edited wrong. The same module is where a corpus becomes a content root — including the three path rewrites that assembly needs (`case.yaml`'s pack, `expected.yaml`'s `factsFrom`, the scenario's `rulePack`), each of which fails *silently* if skipped, by offering an empty list rather than an error. - **A receipt viewer is three checks, and only one of them needs the repository.** `demo/receipts_api.py` reports hash, fact-integrity and replay separately because a forged receipt that was *re-sealed* passes the first two — the interesting case, and the one a single "valid/invalid" pill would hide. Three consequences when you touch it. Never collapse the checks into one verdict field: `partial` (inputs unavailable) and `fail` (a check refuted) are different answers, and standalone receipts are the normal case, not the degraded one. Never fall back to the pack now on disk when `rulePack.version` has moved — the module returns `pack-moved` and omits rule text instead, because descriptions from a version the receipt never saw would read as the text its rules carried. And never import `duly_kernel` at module scope here: it is the one surface that can do nothing without the kernel, which is exactly what makes a top-level import tempting, and `demo/app.py` includes this router unconditionally — so that import takes *all four pages* down when the kernel is absent, instead of this one reporting "not checked". It reaches through `_kernel(name)` for the same reason every kernel reach in `app.py` is lazy. Two things also arrive as caller data on `/inspect` and must be treated as such: `rulePack.name` becomes a path (validated against a pack-name pattern before the join), and the receipt bytes themselves (see the JSON-fidelity gotcha above). - **`impact.analyze` takes `pack_overrides`; the CLI never passes it.** Keyed by *resolved* pack path, it seeds the pack cache so a candidate that exists only in memory is measured against the corpus slice it governs. Reading the working tree is the point of the command, so the flag is for callers holding a pack that is not (and may never be) a file — an impact number an author sees before writing the file is the only one that can change their mind. diff --git a/assurance/tests/test_verify.py b/assurance/tests/test_verify.py index 8a4cf30..fd18144 100644 --- a/assurance/tests/test_verify.py +++ b/assurance/tests/test_verify.py @@ -55,7 +55,7 @@ def test_verify_passes_on_the_committed_golden_corpus(capsys): def test_verify_passes_on_a_copied_corpus(corpus, capsys): """Replay does not depend on where the corpus lives.""" assert verify.main(["--golden", str(corpus)]) == 0 - assert "verified 4 cases" in capsys.readouterr().out + assert "verified 5 cases" in capsys.readouterr().out def test_verify_fails_on_tampered_receipt_body(corpus, capsys): diff --git a/demo/tests/demotest_helpers.py b/demo/tests/demotest_helpers.py index fcfd657..ccd8a12 100644 --- a/demo/tests/demotest_helpers.py +++ b/demo/tests/demotest_helpers.py @@ -22,6 +22,7 @@ from __future__ import annotations +import importlib import json import shutil from pathlib import Path @@ -58,6 +59,19 @@ def build_content_root(root: Path) -> Path: shutil.copytree(FIXTURES / "cases", root / "golden" / "cases") shutil.copytree(FIXTURES / "receipts", root / "golden" / "receipts") + # Each case names the pack it was decided under. In `fixtures/` that is the + # repo-relative `fixtures/pack.yaml`; in a content root it is where the pack + # actually sits. The studio matches this string exactly to decide which + # corpus cases a pack governs, so leaving it un-rewritten does not error — + # it silently offers zero golden fact sets, which looks like a pack nothing + # cites rather than like a broken path. + for case_yaml in (root / "golden" / "cases").glob("*/case.yaml"): + case_yaml.write_text( + case_yaml.read_text().replace( + "pack: fixtures/pack.yaml", f"pack: rulepacks/{pack_name}/pack.yaml" + ) + ) + # A receipt names its pack by name, and the surfaces resolve that name to # `rulepacks//pack.yaml`. Placing it anywhere else would make every # receipt here report `pack-moved`, which is a real outcome and not the one @@ -66,8 +80,23 @@ def build_content_root(root: Path) -> Path: pack_dir.mkdir(parents=True) shutil.copy(FIXTURES / "pack.yaml", pack_dir / "pack.yaml") + # The pack's declared cases, with their `factsFrom` rewritten from + # repo-relative (`fixtures/cases/...`, correct where the file is committed) + # to the content root's layout. Same rewrite, same reason, as the scenario + # manifest's `rulePack` below. + expected = (FIXTURES / "expected.yaml").read_text() + (pack_dir / "expected.yaml").write_text( + expected.replace("factsFrom: fixtures/cases/", "factsFrom: golden/cases/") + ) + shutil.copytree(FIXTURES / "ontology", root / "ontologies") + # The DMN inputs the rule studio's import panel reads, under the name it + # discovers them by. One table that compiles and one that is refused: the + # refusal is the more important of the two, because "a refusal is a result, + # not a 500" is the claim the panel exists to make. + shutil.copytree(FIXTURES / "dmn", root / "dmn" / "examples") + # The scenario, under the name the demo discovers starters by. Its # `rulePack` is repo-relative (`fixtures/pack.yaml`) in the committed # artifact, because that is what it resolves to when the demo serves this @@ -81,3 +110,29 @@ def build_content_root(root: Path) -> Path: json.dumps(manifest, indent=2, ensure_ascii=False) + "\n" ) return root + + +def reload_demo() -> None: + """Rebind every demo module's content roots to the current environment. + + The surfaces read `DULY_DEMO_CONTENT` at *import*, which is right for a + server — import is startup — so moving the environment after import moves + nothing until the modules are reloaded, in dependency order. + + Shared rather than copied per suite, and that is the whole point. This + teardown is the reason converting one suite to a fixture content root once + turned 0 failures into 50 across the rest of the directory: `monkeypatch` + unsets the variable and un-reloads nothing, so a suite that reloads on setup + and not on teardown leaves every later file serving a temp directory that no + longer exists. A guard rewritten per call site is a guard that will + eventually be rewritten wrong. + """ + import demo.app + import demo.content + import demo.evidence_api + import demo.receipts_api + import demo.rules_api + + importlib.reload(demo.content) + for module in (demo.rules_api, demo.evidence_api, demo.receipts_api, demo.app): + importlib.reload(module) diff --git a/demo/tests/test_receipts_api.py b/demo/tests/test_receipts_api.py index 6bb34f6..1f052fe 100644 --- a/demo/tests/test_receipts_api.py +++ b/demo/tests/test_receipts_api.py @@ -1,9 +1,11 @@ """API tests for the demo's receipt viewer (demo/receipts_api.py). -These run against the *committed* golden corpus rather than fixtures, for the -same reason the Rule Studio's tests run against the committed packs: the -viewer's job is to open receipts that actually exist, and a verifier that only -works on a hand-made receipt would tell us nothing. +These run against a content root assembled from +[`fixtures/`](../../fixtures/README.md) — the viewer is toolkit (M5 plan, D2), +so its suite must survive `git rm -r examples/` rather than stop being +collected. The receipts it opens are real ones, built and sealed by +`fixtures/build.py`; what changed is who owns them, not whether they are +genuine. The forgery cases below are the ones that earn the module. A receipt that has been edited fails the hash check; a receipt that has been edited *and* @@ -29,7 +31,7 @@ from fastapi.testclient import TestClient # noqa: E402 -from demotest_helpers import CASE, build_content_root # noqa: E402 +from demotest_helpers import CASE, build_content_root, reload_demo # noqa: E402 from duly_kernel.receipt import content_hash # noqa: E402 GOLDEN: Path # the content root's corpus, bound per session by `client` @@ -54,7 +56,7 @@ def client(content_root, monkeypatch): monkeypatch.delenv("DULY_DEMO_FORCE_FIXTURE", raising=False) GOLDEN = content_root / "golden" - _reload_demo() + reload_demo() import demo.app with TestClient(demo.app.app) as c: @@ -64,21 +66,7 @@ def client(content_root, monkeypatch): # pointed at a temp directory — the import-time binding cuts both ways, # and `monkeypatch` unsets the variable without un-reloading anything. monkeypatch.undo() - _reload_demo() - - -def _reload_demo() -> None: - import importlib - - import demo.app - import demo.content - import demo.evidence_api - import demo.receipts_api - import demo.rules_api - - importlib.reload(demo.content) - for module in (demo.rules_api, demo.evidence_api, demo.receipts_api, demo.app): - importlib.reload(module) + reload_demo() def _receipt(case_id: str = CASE) -> dict: diff --git a/demo/tests/test_rules_api.py b/demo/tests/test_rules_api.py index 3c24e7f..b78da81 100644 --- a/demo/tests/test_rules_api.py +++ b/demo/tests/test_rules_api.py @@ -1,10 +1,39 @@ """API tests for the demo's Rule Studio (demo/rules_api.py). -These run against the *committed* rule packs rather than fixtures: the studio's -whole job is to browse and edit what is actually in `rulepacks/`, and a -projection that only works on a hand-made pack would tell us nothing. Every -test that drafts resets the session store afterwards, because drafts are -process-global by design. +The studio is **toolkit** (M5 plan, D2): it browses and edits whatever packs a +deployment has, not any particular ones. So these run against a content root +assembled from [`fixtures/`](../../fixtures/README.md) rather than against the +teaching packs in `rulepacks/`. + +That is a correction, and the reason is worth stating. This module used to say +that a projection which only works on a hand-made pack "would tell us nothing" +— which is true of a pack invented to make one assertion pass, and false of a +corpus built to exercise the projection's actual range. The real cost of +pointing here at `rulepacks/` was invisible: delete the teaching content and +`test_declared_cases_run_green_against_every_committed_pack` did not fail, it +passed *vacuously* over an empty glob, and the refusal parametrization +collected zero tests. Both read exactly like success (CLAUDE.md, "a test that +would still pass with its subject deleted"). + +What the fixture pack has to carry for this suite, and why each is here rather +than convenient: + +* one rule carrying *both* guard shapes at once — a quoted-string equality + that owns a cell (`category == "restricted"`) and a comparison relating two + bindings (`score < minimum`) that no column can hold. Sharper than two rules + with one shape each: it proves the projection separates them *within* a row; +* a *derived* input, so a table can depend on a value another rule concludes; +* two hit policies, because UNIQUE and PRIORITY compile from different priority + structures and a suite that sees one of them proves half the mapping; +* a nested `abstentionPolicy.attributes`, because re-emission dropping a + top-level key is silent and two scalars cannot catch a flattening; +* declared cases (`fixtures/expected.yaml`) and DMN inputs (`fixtures/dmn/`), + neither of which existed before this suite needed them. + +Every test that drafts resets the session store afterwards, because drafts are +process-global by design. The `client` fixture restores the demo modules on +teardown: roots are bound at import, so pointing them at a temp root leaks into +every later file in the directory run unless it is put back. Run from the repo root: PATH="/opt/homebrew/bin:$PATH" uv run pytest demo/tests -q @@ -27,48 +56,102 @@ from fastapi.testclient import TestClient # noqa: E402 -from demo import rules_api # noqa: E402 -from demo.app import app # noqa: E402 +from demotest_helpers import build_content_root, reload_demo # noqa: E402 -NOTICE = "termination-notice-us-states" -TRID = "trid-fee-tolerance-us-federal" +#: The fixture pack's slug in an assembled content root — its `pack.name`, +#: because that is what a receipt's `rulePack.name` resolves to. +PACK = "duly-fixture-pack" + +#: The rule whose two guards are single-input equalities, and whose third +#: relates two bindings. +EXCEPTION = "FX-EXCEPTION-01" + +#: The presumption `EXCEPTION` defeats: lowest priority, binds only the entity. +DEFAULT = "FX-DEFAULT-00" + +#: The effective-dated threshold in force from 2026-01-01, concluded by a rule +#: and read by another as a `derived` binding. +THRESHOLD = "FX-THRESHOLD-02" @pytest.fixture -def client(): - rules_api.reset_drafts() - with TestClient(app) as c: +def content_root(tmp_path_factory) -> Path: + """A fresh content root per test. + + Not session-scoped, unlike the receipt viewer's: the studio *writes* — + drafts, new packs, adopted DMN — and although those live in a session store + rather than on disk, a test that ever gains a filesystem side effect would + otherwise leak into every later test in the file. + """ + return build_content_root(tmp_path_factory.mktemp("content")) + + +@pytest.fixture +def client(content_root, monkeypatch): + monkeypatch.setenv("DULY_DEMO_CONTENT", str(content_root)) + monkeypatch.delenv("DULY_DEMO_FORCE_FIXTURE", raising=False) + reload_demo() + + import demo.app + import demo.rules_api + + demo.rules_api.reset_drafts() + with TestClient(demo.app.app) as c: yield c - rules_api.reset_drafts() + demo.rules_api.reset_drafts() + monkeypatch.undo() + reload_demo() -def _detail(client, slug=NOTICE): + +def _detail(client, slug=PACK): res = client.get(f"/api/rules/packs/{slug}") assert res.status_code == 200, res.text return res.json() -def _draft(client, pack, slug=NOTICE): +def _draft(client, pack, slug=PACK): res = client.put(f"/api/rules/packs/{slug}/draft", json={"pack": pack}) assert res.status_code == 200, res.text return res.json() +def _threshold(pack): + """The rule the editing tests move: the 2026 minimum score. + + Editing a *derived* value is the interesting choice. It is read by two other + rules as a `derived` binding, so a change here reaches the decision without + appearing anywhere in the decision's own table — which is the case where + reading the diff is not enough and running the corpus is the point. + """ + return next(r for r in pack["rules"] if r["id"] == THRESHOLD) + + +#: The corpus case whose score (60) sits between the committed threshold and +#: the raised one, so exactly one decision moves. Every other fixture case +#: scores 12 or 80 and is untouched by the edit. +BOUNDARY_CASE = "fx-0006" + +#: A fact set every deployment of this content root has: the declared cases and +#: the corpus both point at it. +FACTS = "golden/cases/fx-0001/facts" + + # --------------------------------------------------------------------------- # Discovery -def test_every_committed_pack_is_discovered(client): +def test_every_committed_pack_is_discovered(client, content_root): body = client.get("/api/rules/packs").json() slugs = {p["slug"] for p in body["packs"]} - on_disk = {p.parent.name for p in (REPO_ROOT / "rulepacks").glob("*/pack.yaml")} - assert slugs == on_disk + on_disk = {p.parent.name for p in (content_root / "rulepacks").glob("*/pack.yaml")} + assert slugs == on_disk == {PACK} assert body["sessionNote"] def test_a_pack_summary_carries_what_the_rail_shows(client): - [pack] = [p for p in client.get("/api/rules/packs").json()["packs"] if p["slug"] == NOTICE] - assert pack["idPrefix"] == "NC" + [pack] = [p for p in client.get("/api/rules/packs").json()["packs"] if p["slug"] == PACK] + assert pack["idPrefix"] == "FX" assert pack["ruleCount"] > 0 assert pack["expectedCases"] > 0 assert pack["hasAbstentionPolicy"] is True @@ -81,8 +164,13 @@ def test_golden_receipt_counts_are_reported_per_rule(client): by_id = {r["id"]: r["goldenReceipts"] for r in detail["rules"]} # Rule ids are permanent precisely because receipts already cite them; # the studio shows the count so deleting one is an informed act. - assert by_id["NY-NR-45"] > 0 + assert by_id[THRESHOLD] > 0 assert sum(by_id.values()) > 0 + # FX-THRESHOLD-01's window closed before every committed case, so it fires + # in none of them. Zero is a real answer and must be reported as one — a + # rule the corpus does not reach is exactly the rule an author is most + # likely to delete carelessly. + assert by_id["FX-THRESHOLD-01"] == 0 # --------------------------------------------------------------------------- @@ -97,23 +185,30 @@ def test_rules_are_grouped_into_one_table_per_concluded_attribute(client): def test_a_single_input_guard_lands_in_that_input_s_cell(client): + """One guard, one column, and the indices point back at the `when` entry.""" detail = _detail(client) - [table] = [t for t in detail["tables"] if t["attribute"] == "nc:requiredMinimumNoticeDays"] - [row] = [r for r in table["rows"] if r["ruleId"] == "NY-NR-45"] + [table] = [t for t in detail["tables"] if t["attribute"] == "fx:permitted"] + [row] = [r for r in table["rows"] if r["ruleId"] == EXCEPTION] cells = {c["column"]: c for c in row["cells"]} - assert cells["state"]["conditions"] == ['state == "US-NY"'] - assert cells["noticeType"]["conditions"] == ['noticeType == "Nonrenewal"'] - assert row["cross"] == [] + assert cells["category"]["conditions"] == ['category == "restricted"'] + assert cells["category"]["indices"] == [1] def test_a_multi_input_guard_is_named_as_one_the_grid_cannot_hold(client): - """`days_between(mailed, expiration) < minDays` relates three bindings, so - no column owns it. The projection says so rather than picking one.""" + """`score < minimum` relates two bindings, so no column owns it. The + projection says so rather than picking one. + + Same rule as the test above, on purpose: a row can hold both shapes, and + flattening the cross guard into either column would misstate what fires. + """ detail = _detail(client) - [table] = [t for t in detail["tables"] if t["attribute"] == "nc:noticeCompliant"] - [row] = [r for r in table["rows"] if r["ruleId"] == "NC-NR-01"] - assert row["cross"] == ["days_between(mailed, expiration) < minDays"] - assert all(not c["conditions"] for c in row["cells"]) + [table] = [t for t in detail["tables"] if t["attribute"] == "fx:permitted"] + [row] = [r for r in table["rows"] if r["ruleId"] == EXCEPTION] + assert row["cross"] == ["score < minimum"] + # The cross guard names `score` and `minimum`; neither cell claims it. + cells = {c["column"]: c for c in row["cells"]} + assert cells["score"]["conditions"] == [] + assert cells["minimum"]["conditions"] == [] def test_unbound_is_distinguished_from_bound_and_unconstrained(client): @@ -122,37 +217,43 @@ def test_unbound_is_distinguished_from_bound_and_unconstrained(client): without constraining it does. Conflating them changes which cases a rule reaches.""" detail = _detail(client) - [table] = [t for t in detail["tables"] if t["attribute"] == "nc:noticeCompliant"] + [table] = [t for t in detail["tables"] if t["attribute"] == "fx:permitted"] rows = {r["ruleId"]: r for r in table["rows"]} - default_cells = {c["column"]: c for c in rows["NC-DEF-00"]["cells"]} - deficiency_cells = {c["column"]: c for c in rows["NC-NR-01"]["cells"]} - assert default_cells["mailed"]["bound"] is False # not required at all - assert deficiency_cells["mailed"]["bound"] is True # required, unconstrained - assert deficiency_cells["mailed"]["conditions"] == [] + default_cells = {c["column"]: c for c in rows[DEFAULT]["cells"]} + exception_cells = {c["column"]: c for c in rows[EXCEPTION]["cells"]} + assert default_cells["score"]["bound"] is False # not required at all + assert exception_cells["score"]["bound"] is True # required, unconstrained + assert exception_cells["score"]["conditions"] == [] def test_entity_type_bindings_are_the_subject_not_a_column(client): detail = _detail(client) for table in detail["tables"]: assert all(c["kind"] != "entityType" for c in table["columns"]) - [table] = [t for t in detail["tables"] if t["attribute"] == "nc:noticeCompliant"] - assert table["entityTypes"] == ["nc:TerminationNotice"] + [table] = [t for t in detail["tables"] if t["attribute"] == "fx:permitted"] + assert table["entityTypes"] == ["fx:Widget"] + # A `derived` column is a column — it is an input to this table, concluded + # by another. Collapsing it into the subject would hide the dependency the + # studio exists to show. + assert {c["name"]: c["kind"] for c in table["columns"]}["minimum"] == "derived" def test_hit_policy_reflects_the_priority_structure(client): - trid = _detail(client, TRID) - by_attr = {t["attribute"]: t for t in trid["tables"]} - assert by_attr["trid:toleranceCategory"]["hitPolicy"] == "UNIQUE" - assert by_attr["trid:toleranceCureAmount"]["hitPolicy"] == "PRIORITY" + """Both policies from one pack: UNIQUE where the rows sit at equal priority + and nothing depends on order, PRIORITY where an exception outranks a + presumption. A suite that only ever saw one of them proved half the map.""" + by_attr = {t["attribute"]: t for t in _detail(client)["tables"]} + assert by_attr["fx:requiredMinimumScore"]["hitPolicy"] == "UNIQUE" + assert by_attr["fx:permitted"]["hitPolicy"] == "PRIORITY" def test_cell_indices_point_back_into_the_rule_s_when_list(client): """Cells carry their `when` positions so a grid edit rewrites exactly those entries — the reason a one-cell change is a two-line diff.""" detail = _detail(client) - [table] = [t for t in detail["tables"] if t["attribute"] == "nc:requiredMinimumNoticeDays"] - [row] = [r for r in table["rows"] if r["ruleId"] == "NY-NR-45"] - rule = next(r for r in detail["pack"]["rules"] if r["id"] == "NY-NR-45") + [table] = [t for t in detail["tables"] if t["attribute"] == "fx:permitted"] + [row] = [r for r in table["rows"] if r["ruleId"] == EXCEPTION] + rule = next(r for r in detail["pack"]["rules"] if r["id"] == EXCEPTION) for cell in row["cells"]: for position, text in zip(cell["indices"], cell["conditions"]): assert rule["when"][position] == text @@ -162,24 +263,24 @@ def test_cell_indices_point_back_into_the_rule_s_when_list(client): # Drafting -def test_a_draft_never_touches_the_file_on_disk(client): - path = REPO_ROOT / "rulepacks" / NOTICE / "pack.yaml" +def test_a_draft_never_touches_the_file_on_disk(client, content_root): + path = content_root / "rulepacks" / PACK / "pack.yaml" before = path.read_bytes() pack = copy.deepcopy(_detail(client)["pack"]) - next(r for r in pack["rules"] if r["id"] == "NY-NR-45")["then"]["value"]["expr"] = "60" + _threshold(pack)["then"]["value"]["value"] = "70" body = _draft(client, pack) assert body["dirty"] is True assert path.read_bytes() == before -def test_the_committed_pack_comes_back_after_discarding(client): +def test_the_committed_pack_comes_back_after_discarding(client, content_root): pack = copy.deepcopy(_detail(client)["pack"]) - next(r for r in pack["rules"] if r["id"] == "NY-NR-45")["then"]["value"]["expr"] = "60" + _threshold(pack)["then"]["value"]["value"] = "70" _draft(client, pack) - client.delete(f"/api/rules/packs/{NOTICE}/draft") + client.delete(f"/api/rules/packs/{PACK}/draft") detail = _detail(client) assert detail["dirty"] is False - assert detail["yaml"] == (REPO_ROOT / "rulepacks" / NOTICE / "pack.yaml").read_text() + assert detail["yaml"] == (content_root / "rulepacks" / PACK / "pack.yaml").read_text() def test_a_structured_edit_does_not_lose_a_top_level_key(client): @@ -189,7 +290,10 @@ def test_a_structured_edit_does_not_lose_a_top_level_key(client): detail = _detail(client) body = _draft(client, copy.deepcopy(detail["pack"])) assert body["pack"]["abstentionPolicy"] == detail["pack"]["abstentionPolicy"] - assert body["abstentionPolicy"]["attributes"]["nc:noticeMailedDate"] == 0.9 + # The *nested* value specifically. A re-emitter that dropped or flattened + # `attributes` would still round-trip `minConfidence` and `routeTo`, so + # asserting only those would pass through the bug this test is named for. + assert body["abstentionPolicy"]["attributes"]["fx:category"] == 0.95 def test_an_invalid_draft_is_kept_and_reported_not_rejected(client): @@ -204,18 +308,18 @@ def test_an_invalid_draft_is_kept_and_reported_not_rejected(client): def test_unparseable_yaml_source_is_refused_before_it_is_stored(client): - res = client.put(f"/api/rules/packs/{NOTICE}/draft", json={"yaml": "pack: [unclosed"}) + res = client.put(f"/api/rules/packs/{PACK}/draft", json={"yaml": "pack: [unclosed"}) assert res.status_code == 422 assert _detail(client)["dirty"] is False def test_the_semantic_diff_isolates_the_change_the_file_diff_buries(client): pack = copy.deepcopy(_detail(client)["pack"]) - next(r for r in pack["rules"] if r["id"] == "NY-NR-45")["then"]["value"]["expr"] = "60" + _threshold(pack)["then"]["value"]["value"] = "70" diff = _draft(client, pack)["diff"] semantic = "".join(diff["semantic"]) - assert '- value: { kind: decimal, expr: "45" }' in semantic - assert '+ value: { kind: decimal, expr: "60" }' in semantic + assert '- value: { kind: decimal, value: "50" }' in semantic + assert '+ value: { kind: decimal, value: "70" }' in semantic # The file diff is larger because a structured edit re-emits the pack and # drops its YAML comments — a real cost the studio shows rather than hides. assert len(diff["file"]) > len(diff["semantic"]) @@ -225,9 +329,13 @@ def test_a_source_edit_preserves_comments_and_produces_a_small_diff(client): """The source editor is the lossless path, and its diff proves it.""" text = _detail(client)["yaml"] edited = text.replace('version: "2026.3.0"', 'version: "2026.4.0"', 1) - res = client.put(f"/api/rules/packs/{NOTICE}/draft", json={"yaml": edited}) + assert edited != text, "the pack version moved; update this edit" + res = client.put(f"/api/rules/packs/{PACK}/draft", json={"yaml": edited}) body = res.json() - assert "# the family every new rule id joins" in body["yaml"] + # A comment from the committed file. The structured path re-emits and + # loses every one of these; the source path is the lossless one, and this + # is what proves the difference is real rather than claimed. + assert "# The default presumption." in body["yaml"] assert len(body["diff"]["file"]) < 15 @@ -235,19 +343,26 @@ def test_a_source_edit_preserves_comments_and_produces_a_small_diff(client): # Testing a pack -def test_declared_cases_run_green_against_every_committed_pack(client): - for path in sorted((REPO_ROOT / "rulepacks").glob("*/pack.yaml")): +def test_declared_cases_run_green_against_every_committed_pack(client, content_root): + """Note the `assert packs` — without it this test passed *vacuously* under + a content root with no packs, which is the state an adopter starts in and + the state `git rm -r examples/` produces. A green empty loop is the failure + mode this whole conversion exists to remove.""" + packs = sorted((content_root / "rulepacks").glob("*/pack.yaml")) + assert packs, "no packs discovered; this test would otherwise pass on nothing" + for path in packs: slug = path.parent.name body = client.post(f"/api/rules/packs/{slug}/test/expected").json() assert body["failed"] == 0, (slug, body["cases"]) assert body["passed"] == len(body["cases"]) + assert body["cases"], slug def test_a_broken_rule_surfaces_as_a_failing_declared_case(client): pack = copy.deepcopy(_detail(client)["pack"]) - next(r for r in pack["rules"] if r["id"] == "NY-NR-45")["then"]["value"]["expr"] = "1" + _threshold(pack)["then"]["value"]["value"] = "1" _draft(client, pack) - body = client.post(f"/api/rules/packs/{NOTICE}/test/expected").json() + body = client.post(f"/api/rules/packs/{PACK}/test/expected").json() assert body["failed"] > 0 failing = [c for c in body["cases"] if not c["ok"]] assert any("decision:" in f for c in failing for f in c["failures"]) @@ -255,37 +370,40 @@ def test_a_broken_rule_surfaces_as_a_failing_declared_case(client): def test_an_ad_hoc_case_adjudicates_and_an_override_flips_it(client): detail = _detail(client) - factset = next(f for f in detail["factSets"] if f["factsPath"] == "starters/notice-ny/facts") + factset = next(f for f in detail["factSets"] if f["factsPath"] == FACTS) request = { "factsPath": factset["factsPath"], - "asOfEffective": "2026-07-25", - "attribute": "nc:noticeCompliant", + "asOfEffective": "2026-06-01", + "attribute": "fx:permitted", "overrides": {}, } - before = client.post(f"/api/rules/packs/{NOTICE}/test/case", json=request).json() + before = client.post(f"/api/rules/packs/{PACK}/test/case", json=request).json() assert before["decision"]["value"] == {"kind": "boolean", "value": False} - assert {r["ruleId"] for r in before["rulesFired"]} == {"NC-NR-01", "NY-NR-45"} + # Both chains: the kernel fires every rule whose guards hold, not only the + # ones concluding the attribute asked about. + assert {r["ruleId"] for r in before["rulesFired"]} == {EXCEPTION, THRESHOLD, "FX-FEE-01"} - request["overrides"] = {"nc:noticeMailedDate": "2026-01-01"} - after = client.post(f"/api/rules/packs/{NOTICE}/test/case", json=request).json() + # Raise the score above the threshold and the exception stops firing. + request["overrides"] = {"fx:score": "99"} + after = client.post(f"/api/rules/packs/{PACK}/test/case", json=request).json() assert after["decision"]["value"] == {"kind": "boolean", "value": True} assert after["applied"] == [ - {"attribute": "nc:noticeMailedDate", "value": {"kind": "date", "value": "2026-01-01"}} + {"attribute": "fx:score", "value": {"kind": "decimal", "value": "99"}} ] def test_an_ad_hoc_case_compares_the_draft_against_the_committed_pack(client): - # 38 days of notice were given. Dropping the NY minimum to 1 makes the - # same case compliant, so the draft and the committed pack disagree. + # fx-0001 scores 12. Dropping the minimum to 1 clears it, so the exception + # stops firing and the draft and the committed pack disagree. pack = copy.deepcopy(_detail(client)["pack"]) - next(r for r in pack["rules"] if r["id"] == "NY-NR-45")["then"]["value"]["expr"] = "1" + _threshold(pack)["then"]["value"]["value"] = "1" _draft(client, pack) body = client.post( - f"/api/rules/packs/{NOTICE}/test/case", + f"/api/rules/packs/{PACK}/test/case", json={ - "factsPath": "starters/notice-ny/facts", - "asOfEffective": "2026-07-25", - "attribute": "nc:noticeCompliant", + "factsPath": FACTS, + "asOfEffective": "2026-06-01", + "attribute": "fx:permitted", "overrides": {}, }, ).json() @@ -297,12 +415,12 @@ def test_an_ad_hoc_case_compares_the_draft_against_the_committed_pack(client): def test_an_override_of_an_attribute_with_no_live_fact_is_refused(client): res = client.post( - f"/api/rules/packs/{NOTICE}/test/case", + f"/api/rules/packs/{PACK}/test/case", json={ - "factsPath": "starters/notice-ny/facts", - "asOfEffective": "2026-07-25", - "attribute": "nc:noticeCompliant", - "overrides": {"nc:notAThing": "7"}, + "factsPath": FACTS, + "asOfEffective": "2026-06-01", + "attribute": "fx:permitted", + "overrides": {"fx:notAThing": "7"}, }, ) assert res.status_code == 422 @@ -311,11 +429,11 @@ def test_an_override_of_an_attribute_with_no_live_fact_is_refused(client): def test_a_facts_path_cannot_escape_the_repository(client): res = client.post( - f"/api/rules/packs/{NOTICE}/test/case", + f"/api/rules/packs/{PACK}/test/case", json={ "factsPath": "../../../etc", - "asOfEffective": "2026-07-25", - "attribute": "nc:noticeCompliant", + "asOfEffective": "2026-06-01", + "attribute": "fx:permitted", "overrides": {}, }, ) @@ -323,25 +441,30 @@ def test_a_facts_path_cannot_escape_the_repository(client): def test_impact_reports_the_flip_a_declared_case_suite_misses(client): - """The point of running both: the notice pack's declared cases still pass - under a 45→60 change, and the corpus finds a decision that moves. Declared - outcomes catch a pack that breaks; only the corpus catches one whose - meaning moved.""" + """The point of running both, and the reason the fixture corpus carries a + case no declared case covers. + + Raising the threshold 50 → 70 leaves every declared outcome green: they + score 12 (below both) and 80 (above both). One corpus case scores 60, and + it moves. Declared outcomes catch a pack that *breaks*; only the corpus + catches one whose *meaning* moved. + """ pack = copy.deepcopy(_detail(client)["pack"]) - next(r for r in pack["rules"] if r["id"] == "NY-NR-45")["then"]["value"]["expr"] = "60" + _threshold(pack)["then"]["value"]["value"] = "70" _draft(client, pack) - declared = client.post(f"/api/rules/packs/{NOTICE}/test/expected").json() + declared = client.post(f"/api/rules/packs/{PACK}/test/expected").json() assert declared["failed"] == 0 + assert declared["passed"] > 0 - impact = client.post(f"/api/rules/packs/{NOTICE}/impact").json() + impact = client.post(f"/api/rules/packs/{PACK}/impact").json() assert impact["flipCount"] == 1 - assert impact["flips"][0]["caseId"] == "notice-ny-0048" + assert impact["flips"][0]["caseId"] == BOUNDARY_CASE assert impact["packCases"] > 0 def test_impact_on_an_unedited_pack_flips_nothing(client): - body = client.post(f"/api/rules/packs/{NOTICE}/impact").json() + body = client.post(f"/api/rules/packs/{PACK}/impact").json() assert body["flipCount"] == 0 assert body["reasoningChangeCount"] == 0 @@ -359,7 +482,7 @@ def test_impact_refuses_a_draft_the_kernel_will_not_load(client): pack = copy.deepcopy(_detail(client)["pack"]) pack["rules"][1]["id"] = pack["rules"][0]["id"] _draft(client, pack) - res = client.post(f"/api/rules/packs/{NOTICE}/impact") + res = client.post(f"/api/rules/packs/{PACK}/impact") assert res.status_code == 422 @@ -373,25 +496,31 @@ def test_the_committed_dmn_example_compiles_and_compiling_examples_come_first(cl assert any(e["refusal"] for e in examples) body = client.post( - "/api/rules/dmn/compile", json={"path": "dmn/examples/trid-fee-tolerance.dmn"} + "/api/rules/dmn/compile", json={"path": "dmn/examples/widget-fee.dmn"} ).json() assert body["ok"] is True - assert body["ruleCount"] == 3 + assert body["ruleCount"] == 4 assert yaml.safe_load(body["yaml"]) == body["pack"] assert body["tables"] -@pytest.mark.parametrize( - "name", sorted(p.stem for p in (REPO_ROOT / "dmn" / "examples" / "refusals").glob("*.dmn")) -) -def test_every_refusal_example_is_reported_not_raised(client, name): +def test_every_refusal_example_is_reported_not_raised(client, content_root): """A refusal is a result, not a 500: it names the decision, the row and - the cell, and it is what the import panel exists to show.""" - body = client.post( - "/api/rules/dmn/compile", json={"path": f"dmn/examples/refusals/{name}.dmn"} - ).json() - assert body["ok"] is False - assert body["error"] + the cell, and it is what the import panel exists to show. + + Not parametrized over the glob any more. `pytest.mark.parametrize` is + evaluated at *collection*, so an empty directory yielded zero test cases — + the suite reported success by having nothing to say. Looping inside the + test, with the count asserted, makes an empty directory a failure. + """ + names = sorted(p.stem for p in (content_root / "dmn" / "examples" / "refusals").glob("*.dmn")) + assert names, "no refusal examples found; this test would otherwise be empty" + for name in names: + body = client.post( + "/api/rules/dmn/compile", json={"path": f"dmn/examples/refusals/{name}.dmn"} + ).json() + assert body["ok"] is False, name + assert body["error"], name def test_only_committed_dmn_examples_load_by_path(client): @@ -402,7 +531,7 @@ def test_only_committed_dmn_examples_load_by_path(client): def test_an_adopted_dmn_pack_is_a_pack_like_any_other(client): body = client.post( "/api/rules/dmn/adopt", - json={"slug": "studio-dmn", "path": "dmn/examples/trid-fee-tolerance.dmn"}, + json={"slug": "studio-dmn", "path": "dmn/examples/widget-fee.dmn"}, ).json() assert body["validation"]["ok"] is True assert body["committed"] is False @@ -436,7 +565,7 @@ def test_a_new_pack_skeleton_validates_out_of_the_box(client): def test_a_new_pack_cannot_shadow_a_committed_one(client): res = client.post( - "/api/rules/packs", json={"slug": NOTICE, "name": "shadow", "idPrefix": "SHD"} + "/api/rules/packs", json={"slug": PACK, "name": "shadow", "idPrefix": "SHD"} ) assert res.status_code == 409 @@ -472,17 +601,17 @@ def test_the_bundle_bytes_depend_only_on_the_pack(client): def test_pack_yaml_downloads_the_effective_text(client): pack = copy.deepcopy(_detail(client)["pack"]) - next(r for r in pack["rules"] if r["id"] == "NY-NR-45")["then"]["value"]["expr"] = "60" + _threshold(pack)["then"]["value"]["value"] = "70" _draft(client, pack) - res = client.get(f"/api/rules/packs/{NOTICE}/pack.yaml") + res = client.get(f"/api/rules/packs/{PACK}/pack.yaml") assert res.status_code == 200 - assert 'expr: "60"' in res.text + assert 'value: "70"' in res.text assert "attachment" in res.headers["content-disposition"] def test_prove_degrades_honestly_without_the_solver(client): """Without z3 the panel must say so, not disappear and not 500.""" - body = client.post(f"/api/rules/packs/{NOTICE}/prove").json() + body = client.post(f"/api/rules/packs/{PACK}/prove").json() if body["available"]: pytest.skip("z3 is installed; the solver-backed path is covered below") assert "z3" in body["note"] @@ -494,27 +623,43 @@ def test_prove_reports_disjointness_and_an_equivalence_witness(client): fixture list cannot answer. It must come back with the input where the two packs part, not merely with 'they differ'.""" pytest.importorskip("z3") - clean = client.post(f"/api/rules/packs/{NOTICE}/prove").json() + clean = client.post(f"/api/rules/packs/{PACK}/prove").json() assert clean["available"] is True assert clean["fatal"] is False assert clean["equivalence"] is None # nothing to compare against yet + + # The pack has one same-priority pair — the two effective-dated thresholds — + # and the solver separates them by their windows. Asserted non-empty first, + # because `all()` over no pairs is a green test that checked nothing. + assert clean["report"]["pairs"] assert all(p["verdict"] == "PROVED-DISJOINT" for p in clean["report"]["pairs"]) pack = copy.deepcopy(_detail(client)["pack"]) - next(r for r in pack["rules"] if r["id"] == "NY-NR-45")["then"]["value"]["expr"] = "60" + _threshold(pack)["then"]["value"]["value"] = "70" _draft(client, pack) - body = client.post(f"/api/rules/packs/{NOTICE}/prove").json() + body = client.post(f"/api/rules/packs/{PACK}/prove").json() equivalence = body["equivalence"] assert equivalence["onlyDraft"] == [] and equivalence["onlyCommitted"] == [] - [days] = [ - d for d in equivalence["decisions"] - if d["attribute"] == "nc:requiredMinimumNoticeDays" - ] - assert days["verdict"] == "NOT-PROVED" - assert (days["committedValue"], days["draftValue"]) == ("45", "60") - witness = dict(tuple(pair) for pair in days["witness"]) - assert witness["nc:governingState"] == '"US-NY"' + by_attr = {d["attribute"]: d for d in equivalence["decisions"]} + + minimum = by_attr["fx:requiredMinimumScore"] + assert minimum["verdict"] == "NOT-PROVED" + assert (minimum["committedValue"], minimum["draftValue"]) == ("50", "70") + + # The edit reaches two decisions it never mentions. `fx:requiredMinimumScore` + # is read by the other two rules as a `derived` binding, so moving it moves + # what they conclude — and nothing in either rule's own text changed. This + # is the case the equivalence panel exists for: a diff a careful reader can + # inspect in full and still misjudge. + assert by_attr["fx:permitted"]["verdict"] == "NOT-PROVED" + assert by_attr["fx:assessedFee"]["verdict"] == "NOT-PROVED" + + # And the witness is a real input, not a shrug: a restricted widget scoring + # exactly the committed threshold is precisely where the two packs part. + witness = dict(tuple(pair) for pair in by_attr["fx:permitted"]["witness"]) + assert witness["fx:score"] == "50" + assert witness["fx:category"] == '"restricted"' def test_an_unknown_pack_is_a_404(client): diff --git a/docs/m5-plan.md b/docs/m5-plan.md index a8bdd0c..3e27f98 100644 --- a/docs/m5-plan.md +++ b/docs/m5-plan.md @@ -49,19 +49,28 @@ produce and needs only Phase 3's layout, so it can run alongside 4 or 5. **Where this stands (2026-08-06):** Phases 0, 1 and 2 are complete. **Phase 3 is in progress and is the whole current front.** The fixture corpus exists; the kernel, the assurance commands, whatif's CLI and the receipt viewer run on -it; and the fixtures now carry everything the remaining suites need — a -scenario with span-grounded facts, a PII-marked fact, and a non-boolean -decision with phrasing. **No conversion left is blocked on a missing -artifact**, which was not true of any earlier part. - -Remaining, in order: the rest of the demo (part 4b(ii), 63 failures under -deletion), then kernel report + whatif + dmn + extraction (part 5), then the -move, then the deletion gate — which the part-3 measurement showed **cannot -pass before the move**, because most of what breaks under deletion is example -tests that the move deletes alongside their subject. Phases 4, 5 and 6 all -wait on Phase 3. - -**Twenty M5 PRs are merged** (#40–#59), plus three pre-plan ones — see +it; and the fixtures carry a scenario with span-grounded facts, a PII-marked +fact, a non-boolean decision with phrasing, declared outcomes, DMN inputs and a +threshold-boundary case. + +*This paragraph used to claim **"no conversion left is blocked on a missing +artifact."** It has since been falsified twice — c-0 found that the review arc +needs extraction targets the fixture scenario lacks, and c-1 found the studio +needs declared cases and DMN inputs that did not exist. Both were invisible +until the conversion was attempted. The claim is not repaired here, because the +lesson is that it is not the kind of claim this plan can make in advance: +**what a suite needs is discovered by running it under deletion, not by reading +it.** Assume the next conversion is blocked on something, and find out by +measuring.* + +Remaining, in order: the evidence browser and review arc (part 4b(ii)c-2, 21 of +the 28 demo failures left under deletion), then kernel report + whatif + dmn + +extraction (part 5), then the move, then the deletion gate — which the part-3 +measurement showed **cannot pass before the move**, because most of what breaks +under deletion is example tests that the move deletes alongside their subject. +Phases 4, 5 and 6 all wait on Phase 3. + +**Twenty-six M5 PRs are merged** (#40–#65), plus three pre-plan ones — see Appendix B, which is the count of record. The original estimate was ~19–21 *total*, and it is already spent with Phase 3 unfinished and Phases 4–6 not started. That is not drift to be tidied away: every overrun came from a @@ -396,7 +405,7 @@ plus the code that backs the two claims needing it. and the conflation of "the same artifact" with "the same decision" that this document had carried since M2.* -## 7. Phase 3 — the move (5 fixture PRs merged, 2 + move + gate remaining) +## 7. Phase 3 — the move (9 fixture PRs merged, 1 + part 5 + move + gate remaining) **Objective:** teaching content relocates under `examples/`; toolkit directories contain zero example content; `git rm -r examples/` leaves a @@ -568,17 +577,57 @@ proves it. scenario has none — it falls back to disk-backed, and the arc is store-only by construction. The hardcoded name was hiding a second dependency behind it. Fixture targets are part of 4b(ii)c below.* -- [ ] **Fixtures, part 4b(ii)c: the last three demo suites (1 PR).** 52 - failures: `test_rules_api` 31, `test_evidence_api` 13, `test_review_arc` - 8, plus the one in `test_content_roots` that asserts six packs and moves. - All three want the content root `demotest_helpers` already builds. - `test_evidence_api` and `test_review_arc` assert on the review arc, which - c-0 made content-declared but which a fixture deployment still cannot - produce: store-backed ingest needs extraction **targets**, and the - fixture scenario has none. Add them here — the shape is - `starters/tools/targets/.json`, and `fixtures/build.py` should - emit them beside the scenario it already builds. `test_rules_api` (31, - the largest) needs none of this and can go first. +- [x] **Fixtures, part 4b(ii)c-1: `test_rules_api` (1 PR).** 29 failures under + deletion → **0**; the demo directory falls from 57 to 28. + *The count was re-measured rather than trusted, and the plan was wrong + three ways. The suite is 29 failures, not the 31 recorded here — though + 31 is coincidentally the number of tests that needed converting, because + two of them were not failing (below). The two 31s are unrelated; do not + read the old estimate as having been right. `test_api` has **6** + residual failures, not 4 — two example tests were never counted, and + checking `#62` confirmed they were there all along rather than regressions + from c-0. And this task's stated prerequisite for part 5 — + "`test_every_placeholder…` needs a **date** attribute in the fixture pack" + — is false: placeholder validation is syntactic and that test builds its + own facts, so it needs no pack growth at all.* + *Two of the 29 were not failures. `test_declared_cases_run_green…` looped + over an empty glob and `test_every_refusal_example…` was + `@pytest.mark.parametrize`d over one, so under deletion the first passed + vacuously and the second collected **zero cases**. Both read as success; + neither appeared in the failure count. That is the sharpest instance of + this phase's founding rule the repository has produced, and it is now a + named form in CLAUDE.md's gotcha.* + *Three artifacts the fixtures lacked, each added because a test needed it + and not for completeness: `fixtures/expected.yaml` (declared outcomes — + the studio runs them beside impact, so a corpus without them left half + the surface untestable), `fixtures/dmn/` (one table that compiles, one + that is refused), and a nested `abstentionPolicy.attributes`, since two + scalars cannot catch a re-emitter that flattens. Also `fx-0006`, a + restricted widget scoring 60 — the only case in the corpus that sits + between the two thresholds the pack has declared, and therefore the only + one an edit can move on its own. Without it, impact analysis over these + fixtures could only ever answer "everything moved", which cannot + demonstrate what impact analysis is for.* + *`reload_demo()` moved into `demotest_helpers`, where the content-root + assembly already lives. It had been copied into a second suite, which is + one copy short of the version that gets edited wrong. The assembly now + performs three path rewrites — `case.yaml`'s pack, `expected.yaml`'s + `factsFrom`, the scenario's `rulePack` — and every one of them fails + **silently** if skipped, by offering an empty list rather than raising.* + *Found on the way, and worth more than the conversion: the equivalence + panel's real subject. Editing the derived threshold moves all three + decisions while two of the three rules stay byte-identical on the page — + the blast radius of an edit is not a syntactic property of the edit. + Sharpened into [docs/neuro-symbolic-architecture.md](neuro-symbolic-architecture.md).* +- [ ] **Fixtures, part 4b(ii)c-2: the evidence browser and the review arc + (1 PR).** 21 failures: `test_evidence_api` 13, `test_review_arc` 8, plus + the one in `test_content_roots` that asserts six packs and moves. Both + assert on the review arc, which c-0 made content-declared but which a + fixture deployment still cannot produce: store-backed ingest needs + extraction **targets**, and the fixture scenario has none. Add them here — + the shape is `starters/tools/targets/.json`, and + `fixtures/build.py` should emit them beside the scenario it already + builds. - [ ] **Fixtures, part 5: kernel report, whatif, dmn, extraction (1 PR).** `kernel/tests/test_report.py` — **its blocker is gone.** The scenario supplies document-grounded facts with quotes and spans, a @@ -588,8 +637,12 @@ proves it. moves with the packs. Also: `whatif/tests/test_whatif.py` split (a) from (b); the whatif CLI test needing a pack whose kinds are all inferable, which the same rebuild - supplies; a fixture decision table for `dmn/`; and `extraction/tests` off - `starters/tools`. + supplies; and `extraction/tests` off `starters/tools`. + **Two prerequisites this task listed are already delivered.** The fixture + decision table exists — `fixtures/dmn/widget-fee.dmn`, two decisions and + four rules, plus a refusal — because `test_rules_api` needed it in c-1; + `dmn/tests` only has to point at it. And the "date attribute" this task + inherited was never needed: see c-1. - [ ] **The move itself (1 PR).** `git mv` of `rulepacks/`, `starters/`, `golden/` (D8), the teaching ontologies, `dmn/examples/`, and the @@ -1094,20 +1147,20 @@ and every exception behind it.** finding is that a default which is right in this repository hides both the wrong-path case and every exception behind it. `wheel_smoke.py` also stopped claiming whatif "has no repo-relative file reads", which was false. -- 2026-08-06 — **Phase 3, part 4b(ii)c scoped** — found that the demo's review +- 2026-08-06 — [#63](https://github.com/kjpatel/duly/pull/63) — **Phase 3, part 4b(ii)c scoped** — found that the demo's review arc names a starter (`REVIEW_SOURCE_SCENARIO = "notice-ny"`) inside toolkit code and fails silently when it is absent, which blocks two of the three remaining suites. Routed as its own task rather than half-refactored; recorded as a CLAUDE.md gotcha so it cannot be rediscovered by surprise. The one example test in `test_content_roots` is labelled as such. -- 2026-08-06 — **Phase 3, part 4b(ii)b: `test_api`** — 10 failures under +- 2026-08-06 — [#62](https://github.com/kjpatel/duly/pull/62) — **Phase 3, part 4b(ii)b: `test_api`** — 10 failures under deletion down to 4, three of which are example tests that move. Found a mis-authored phrasing guard in the pack added one PR earlier: `value:` on a money decision matches nothing, and the demo falls through silently rather than complaining. Also: CLAUDE.md's definition of done now *requires* a documentation pass as the last step of every PR, so it stops being something Kushan has to ask for. -- 2026-08-06 — **Phase 3, part 4b(ii)a: the pack grown** — a non-boolean +- 2026-08-06 — [#61](https://github.com/kjpatel/duly/pull/61) — **Phase 3, part 4b(ii)a: the pack grown** — a non-boolean money decision with phrasing, and a `sensitivity: pii` fact, because `test_api` splits on machinery a boolean decision never reaches. Part 5's prerequisite, delivered early because 4b(ii) hit it first. The rebuild moved @@ -1174,3 +1227,23 @@ and every exception behind it.** branch merges, and merging it then puts the work somewhere `main` will never see. The check is `git log main` containing the commit — cheap, and it is the one that failed here.* + +- 2026-08-06 — [#64](https://github.com/kjpatel/duly/pull/64) — **Phase 3, part + 4b(ii)c-0: the review arc becomes content** — the four constants naming + `notice-ny` are gone; a scenario opts in with a `reviewArc` block and the demo + finds the first that declares one. This repository's arc is unchanged in every + field, which was the check that mattered. Removing the name exposed what it + had been hiding: a fixture-only deployment still gets no arc, because + store-backed ingest needs extraction targets the fixture scenario lacks. +- 2026-08-06 — [#65](https://github.com/kjpatel/duly/pull/65) — **Phase 3, part + 4b(ii)c-1: `test_rules_api`** — 29 failures under deletion → 0; the demo + directory falls 57 → 28. Added what the studio needs and the fixtures lacked: + declared outcomes, two DMN inputs, a nested `abstentionPolicy.attributes`, and + `fx-0006` — the only corpus case an edit to the threshold can move on its own, + without which impact analysis over these fixtures could only ever answer + "everything moved". `reload_demo()` hoisted into `demotest_helpers` after + being copied once. *Two of the 29 were not failures at all*: one looped over + an empty glob and one was parametrized over one, so under deletion they passed + vacuously and collected zero cases respectively — the sharpest instance of + this phase's founding rule yet, now a named form in CLAUDE.md. Three of the + plan's own claims about this task were wrong and are corrected in §7. diff --git a/docs/neuro-symbolic-architecture.md b/docs/neuro-symbolic-architecture.md index 1bf99ab..ffc82b7 100644 --- a/docs/neuro-symbolic-architecture.md +++ b/docs/neuro-symbolic-architecture.md @@ -167,6 +167,22 @@ that preserves comments. That is the same move as impact analysis. A change that cannot be prevented mechanically is instead made impossible to make accidentally. +Which sharpens why the studio runs a solver and the corpus *beside* the diff, +rather than trusting a careful reader to the diff alone. The instructive case +is not a large change but a one-line one: move the value a rule concludes, and +every rule that reads it as a `derived` binding changes what it decides — +while its own text, its own guards and its own row in the grid stay +byte-identical. The toolkit's fixture pack is built so this is reproducible: +editing the threshold from 50 to 70 leaves both downstream rules untouched on +the page, and the equivalence panel reports all three decisions as +NOT-PROVED, with a witness naming a widget scoring exactly 50 — the boundary +where the two packs part. The diff is complete and honest, and it is still not +where the change's effect lives. That is the general shape: **in a defeasible +rule system, the blast radius of an edit is not a syntactic property of the +edit**, so no amount of reading discipline substitutes for re-running the +rules. The panel is not a convenience for careless authors; it is the only +instrument that answers the question the diff cannot be asked. + In T-Box/A-Box terms, the ontology is T-Box-like and grounded facts are A-Box-like. Rule packs are a separate policy artifact. duly does not currently use an RDF/OWL knowledge base or an OWL reasoner, so the analogy should not be diff --git a/fixtures/README.md b/fixtures/README.md index 72a22e2..555cd5b 100644 --- a/fixtures/README.md +++ b/fixtures/README.md @@ -1,8 +1,9 @@ # `fixtures/` — the corpus the toolkit owns Everything here exists so that **duly's own test suites do not depend on duly's -teaching content**. It is deliberately boring: one invented domain, one pack, -three cases, three receipts, one ontology. Nobody should learn anything from it. +teaching content**. It is deliberately boring: one invented domain, one pack +with its declared cases, five cases, five receipts, one ontology, one scenario +and two DMN inputs. Nobody should learn anything from it. ## Why it exists @@ -28,12 +29,14 @@ deleted is not a test.** |---|---| | `ontology/duly-fixture/0.1.0.yaml` | The vocabulary the fixture facts pin. Invented; models nothing real | | `pack.yaml` | One rule pack: a default, an exception that defeats it, a derived intermediate, an effective-dated pair, an abstention floor, and a **non-boolean decision with a `phrasing:` block** | -| `cases/fx-000N/` | Four cases: `case.yaml` plus content-addressed facts | +| `expected.yaml` | The pack's **declared outcomes**, run by the rule studio beside impact analysis. Declared cases catch a pack that *breaks*; only the corpus catches one whose *meaning moved* — the studio shows both, so the fixtures have to supply both | +| `cases/fx-000N/` | Five cases: `case.yaml` plus content-addressed facts | | `receipts/fx-000N.json` | The receipt each case produces, committed | | `scenario/` | One *scenario*, which is what the demo surfaces read: a document, the extractor's rendition of it, and facts grounded in character spans of that rendition | -| `build.py` | Regenerates every artifact above, deterministically | +| `dmn/` | Two DMN decision tables: one that compiles, one that is refused. The studio's import panel and the compiler's happy path were reachable only through `dmn/examples/`, which an adopter deletes | +| `build.py` | Regenerates the cases, receipts and scenario, deterministically | -Three cases, chosen to cover what the toolkit's own tests need rather than to +Five cases, chosen to cover what the toolkit's own tests need rather than to teach anything: - **`fx-0001`** — the exception fires and defeats the default. A derived value @@ -46,10 +49,18 @@ teach anything: below-floor fact, the abstention is answered and the decision flips. It commits the *post-correction projection*, because supersession is a store-level projection and `adjudicate` is handed a fact list, not a store. +- **`fx-0006`** — restricted *and* above the threshold, so the category matches + and the exception still does not fire. It exists for one reason: its score + (60) is the only one in the corpus that sits **between** the two thresholds + this pack has ever declared. Every other case scores 12 or 80, so an edit to + the threshold moves all three restricted cases together or none — and a + corpus that can only answer "everything moved" cannot demonstrate what impact + analysis is *for*, which is a pack whose meaning moved while every declared + outcome stayed green. And one scenario, which is a different artifact from a case: -- **`fx-0005`** (`scenario/`) — a generated PDF, its rendition, and two facts +- **`fx-0005`** (`scenario/`) — a generated PDF, its rendition, and three facts grounded in **character spans** of that rendition — one below the confidence floor, and one marked `sensitivity: pii` so the report renderer's redaction path has something to redact (the name is invented and refers to nobody). The cases above all use attestation grounding, which is honest for @@ -84,12 +95,16 @@ this corpus is not the place to discover it; `golden/` is. is boolean, so it takes the kernel's Yes/No fallback and never reaches a `phrasing:` block — the *whole* phrasing machinery was unreachable until the pack grew `fx:assessedFee`. That is what "cannot be asserted otherwise" - looks like. + looks like. `fx-0006` met the same bar from the other direction: not a + behaviour that was unreachable, but a *distinction* the corpus could not + draw, since every existing case moved together under the edit that mattered. - **Growing the pack is a rebuild, not an edit.** `pack.version` is inside - every receipt, so adding a rule moves all four receipts, the decision-digest + every receipt, so adding a rule moves every receipt, the decision-digest vectors and the corpus aggregate. Bump the version, re-run both builders, and re-pin what moved — the tests name what they are pinning, so the failures - read as instructions. + read as instructions. Batch growth into one rebuild where you can: two + separate additions cost two rounds of re-pinning across four files, and the + second round is the one where a pinned literal quietly becomes wrong. - **Invent nothing that looks real.** No statute numbers, no jurisdictions, no plausible citations. `FX-` rule ids, `fx:` attributes, a `citation.text` that says it is fictional. A reader must never mistake this for domain content — diff --git a/fixtures/build.py b/fixtures/build.py index a060ff2..4b46e52 100644 --- a/fixtures/build.py +++ b/fixtures/build.py @@ -61,6 +61,23 @@ "score": ("12", 0.62), # below minConfidence 0.80, on purpose "category": "restricted", }, + # 0005 is the scenario, not a corpus case; the gap is deliberate. + { + "id": "fx-0006", + "effective": "2026-06-01", + "note": "restricted but above the threshold — the category matches and " + "the score still clears, so the exception does not fire", + # 60 sits *between* the two thresholds this pack has ever declared (50 + # from 2026, 10 before it) and above both. That is the point of the + # case: it is the only one an edit to the threshold can move on its + # own. Every other corpus case scores 12 or 80, so a threshold change + # either flips all three restricted cases together or none — and a + # corpus that can only answer "everything moved" cannot demonstrate + # what impact analysis is for, which is a pack whose *meaning* moved + # while its declared outcomes stayed green. + "score": ("60", 1.0), + "category": "restricted", + }, ] @@ -376,7 +393,12 @@ def main() -> int: ) _build_scenario() - print(f"wrote {written} fixture cases") + # Receipts, not `CASES` entries: fx-0004 is derived from fx-0003 rather + # than declared, so counting the loop under-reports by one — and this + # number is what the corpus-size pins in the toolkit suites are checked + # against. + receipts = len(list((ROOT / "receipts").glob("*.json"))) + print(f"wrote {written} declared cases, {receipts} receipts") return 0 diff --git a/fixtures/cases/fx-0006/case.yaml b/fixtures/cases/fx-0006/case.yaml new file mode 100644 index 0000000..16c48d0 --- /dev/null +++ b/fixtures/cases/fx-0006/case.yaml @@ -0,0 +1,6 @@ +# restricted but above the threshold — the category matches and the score still clears, so the exception does not fire +id: fx-0006 +pack: fixtures/pack.yaml +question: fx:permitted +asOfEffective: "2026-06-01" +asOfKnowledge: "2026-03-02T12:00:00Z" diff --git a/fixtures/cases/fx-0006/facts/fx-category.json b/fixtures/cases/fx-0006/facts/fx-category.json new file mode 100644 index 0000000..b32fa08 --- /dev/null +++ b/fixtures/cases/fx-0006/facts/fx-category.json @@ -0,0 +1,40 @@ +{ + "id": "urn:duly:fact:sha256:6d36c690c9f1654d462c57bfd284481b128cd23b840f0599ef0a159023e82ab8", + "contentHash": "6d36c690c9f1654d462c57bfd284481b128cd23b840f0599ef0a159023e82ab8", + "caseId": "case:fixture:fx-0006", + "entity": { + "id": "widget:fx-0006", + "type": "fx:Widget" + }, + "attribute": "fx:category", + "value": { + "kind": "code", + "value": "restricted", + "codeSystem": "duly-fixture/widget-categories", + "codeSystemVersion": "0.1.0" + }, + "grounding": { + "kind": "attestation", + "actor": "duly-fixture-builder", + "channel": "synthetic", + "at": "2026-03-01T00:00:00Z" + }, + "assertion": { + "kind": "machine", + "at": "2026-03-01T00:00:00Z", + "extractor": { + "name": "duly-fixture-builder", + "version": "0.1.0" + } + }, + "confidence": { + "score": 1.0, + "method": "raw" + }, + "recordedAt": "2026-03-01T00:00:00Z", + "status": "asserted", + "schemaRef": { + "ontology": "duly-fixture", + "version": "0.1.0" + } +} diff --git a/fixtures/cases/fx-0006/facts/fx-score.json b/fixtures/cases/fx-0006/facts/fx-score.json new file mode 100644 index 0000000..a1d026b --- /dev/null +++ b/fixtures/cases/fx-0006/facts/fx-score.json @@ -0,0 +1,38 @@ +{ + "id": "urn:duly:fact:sha256:b66aa90f2ab52ae3552faff85e29cb468434283c19e63e7c67ecdb95417dc7db", + "contentHash": "b66aa90f2ab52ae3552faff85e29cb468434283c19e63e7c67ecdb95417dc7db", + "caseId": "case:fixture:fx-0006", + "entity": { + "id": "widget:fx-0006", + "type": "fx:Widget" + }, + "attribute": "fx:score", + "value": { + "kind": "decimal", + "value": "60" + }, + "grounding": { + "kind": "attestation", + "actor": "duly-fixture-builder", + "channel": "synthetic", + "at": "2026-03-01T00:00:00Z" + }, + "assertion": { + "kind": "machine", + "at": "2026-03-01T00:00:00Z", + "extractor": { + "name": "duly-fixture-builder", + "version": "0.1.0" + } + }, + "confidence": { + "score": 1.0, + "method": "raw" + }, + "recordedAt": "2026-03-01T00:00:00Z", + "status": "asserted", + "schemaRef": { + "ontology": "duly-fixture", + "version": "0.1.0" + } +} diff --git a/fixtures/dmn/refusals/uncited-row.dmn b/fixtures/dmn/refusals/uncited-row.dmn new file mode 100644 index 0000000..133c922 --- /dev/null +++ b/fixtures/dmn/refusals/uncited-row.dmn @@ -0,0 +1,54 @@ + + + + + + + + + What minimum score must this widget meet? + + + + + + fx:category + + + + + + + + "restricted" + 50 + FXR-MINIMUM-01 + Fixture threshold, restricted regime (fictional) + 2026-01-01 + + + + "ordinary" + 10 + FXR-MINIMUM-02 + + 2026-01-01 + + + + diff --git a/fixtures/dmn/widget-fee.dmn b/fixtures/dmn/widget-fee.dmn new file mode 100644 index 0000000..4dfe9f4 --- /dev/null +++ b/fixtures/dmn/widget-fee.dmn @@ -0,0 +1,154 @@ + + + + + Fixture fee schedule for widgets. Fictional. + + + + + + + + + + + + What minimum score must this widget meet? + + + + + + + + + + + fx:category + + + + + + + + + Restricted widgets are held to the stricter threshold. + "restricted" + 50 + FXD-MINIMUM-01 + Fixture threshold, restricted regime (fictional) + 2026-01-01 + + + + Ordinary widgets are held to the looser one. + "ordinary" + 10 + FXD-MINIMUM-02 + Fixture threshold, ordinary regime (fictional) + 2026-01-01 + + + + + + + What fee is assessed? + + + + + + + + + + + + + + fx:requiredMinimumScore + + + + + fx:score + + + + + + + + + A widget below the minimum in force owes the fixture fee. + - + < minimum + 250.00 + FXD-FEE-01 + Fixture fee for a deficient widget (fictional) + 2026-01-01 + + + + Otherwise nothing is owed. Binds no fact, so it holds even where the score is missing. + - + - + 0.00 + FXD-FEE-00 + Fixture fee schedule, default (fictional) + 2026-01-01 + + + + diff --git a/fixtures/expected.yaml b/fixtures/expected.yaml new file mode 100644 index 0000000..e84fce6 --- /dev/null +++ b/fixtures/expected.yaml @@ -0,0 +1,76 @@ +# Declared outcomes for duly-fixture-pack. +# +# FICTIONAL, like the pack itself — these assert what the fixture rules do, not +# what any law requires. +# +# Why this file exists at all: a pack's declared cases and the golden corpus +# answer *different* questions, and the rule studio runs both side by side. +# `expected.yaml` catches a pack that **breaks** — a rule that stops firing, a +# defeat chain that comes apart. Only the corpus catches a pack whose +# **meaning moved** — every declared case still green while a real decision +# flips (rulepacks/README.md). A fixture corpus without declared cases left the +# studio's whole left-hand panel with nothing to run. +# +# `factsFrom` paths are written relative to **this repository**, which is where +# they resolve when the fixtures are read in place. A demo content root lays the +# same cases out under `golden/cases//facts`, so +# demo/tests/demotest_helpers.py rewrites them on assembly — the same treatment, +# and for the same reason, as the scenario manifest's `rulePack`. The committed +# artifact is correct for the tree it sits in; the two paths are not the same +# path and pretending otherwise is how a fixture starts lying about itself. + +cases: + # The exception fires: score 12 is below the 2026 threshold of 50 and the + # widget is restricted, so FX-EXCEPTION-01 concludes not-permitted and + # defeats the FX-DEFAULT-00 presumption. The threshold itself is *derived* + # (FX-THRESHOLD-02), which is what gives the derivation depth. + - name: restricted-below-threshold-not-permitted + factsFrom: fixtures/cases/fx-0001/facts + asOfEffective: "2026-06-01" + question: fx:permitted + expectDecision: { kind: boolean, value: false } + # Both chains, because the kernel fires every rule whose guards hold — not + # only those concluding the attribute asked about. The fee rules appear + # here for the same reason they appear on the receipt. + expectRulesFired: [FX-EXCEPTION-01, FX-FEE-01, FX-THRESHOLD-02] + expectDefeated: + FX-EXCEPTION-01: [FX-DEFAULT-00] + FX-FEE-01: [FX-FEE-00] + + # Nothing overrides: score 80 clears the threshold and the category is + # ordinary, so the default presumption survives as the decision. The pair + # with the case above is the point — same pack, opposite outcome, and the + # difference is in the facts rather than in the rules. + - name: ordinary-above-threshold-permitted + factsFrom: fixtures/cases/fx-0002/facts + asOfEffective: "2026-06-01" + question: fx:permitted + expectDecision: { kind: boolean, value: true } + expectRulesFired: [FX-DEFAULT-00, FX-FEE-00, FX-THRESHOLD-02] + expectDefeated: {} + + # Effective-dated replay. The same facts as the first case, read as of + # 2025-12-15, when FX-THRESHOLD-01 was in force with its looser minimum of + # 10. Score 12 clears it, the exception does not fire, and the presumption + # stands. This is the as-of dial doing the one thing no re-run of today's + # rules can reproduce. + - name: restricted-under-the-pre-2026-threshold-permitted + factsFrom: fixtures/cases/fx-0001/facts + asOfEffective: "2025-12-15" + question: fx:permitted + expectDecision: { kind: boolean, value: true } + expectRulesFired: [FX-DEFAULT-00, FX-FEE-00, FX-THRESHOLD-01] + expectDefeated: {} + + # The non-boolean decision, so the declared cases cover the money path and + # not only the boolean one. Same facts as the first case; a restricted widget + # below the threshold owes the fee FX-FEE-01 assesses. + - name: restricted-below-threshold-owes-the-fee + factsFrom: fixtures/cases/fx-0001/facts + asOfEffective: "2026-06-01" + question: fx:assessedFee + expectDecision: { kind: money, amount: "250.00", currency: USD } + expectRulesFired: [FX-EXCEPTION-01, FX-FEE-01, FX-THRESHOLD-02] + expectDefeated: + FX-EXCEPTION-01: [FX-DEFAULT-00] + FX-FEE-01: [FX-FEE-00] diff --git a/fixtures/pack.yaml b/fixtures/pack.yaml index 946a476..41788f2 100644 --- a/fixtures/pack.yaml +++ b/fixtures/pack.yaml @@ -7,7 +7,7 @@ pack: name: duly-fixture-pack - version: "2026.2.0" + version: "2026.3.0" ontology: duly-fixture ontologyVersion: "0.1.0" idPrefix: FX @@ -15,6 +15,16 @@ pack: abstentionPolicy: minConfidence: 0.80 routeTo: fixture-review + # A per-attribute floor, and the only *nested* structure in this pack's + # top-level keys. That is what it is here for: the rule studio re-emits a + # structured edit through `duly_dmn.emit.emit_pack`, and a key dropped there + # produces a draft that validates, adjudicates, and answers differently — + # silently. Two scalars cannot catch a flattening; a mapping under a mapping + # can. Set above the global floor so it is the binding constraint for this + # attribute, and above every committed fixture's category confidence (1.0), + # so raising it is a decision change rather than a no-op. + attributes: + fx:category: 0.95 decisions: - attribute: fx:permitted diff --git a/fixtures/receipts/fx-0001.json b/fixtures/receipts/fx-0001.json index 953d896..cabce68 100644 --- a/fixtures/receipts/fx-0001.json +++ b/fixtures/receipts/fx-0001.json @@ -1,6 +1,6 @@ { - "id": "urn:duly:receipt:sha256:521917aa7373eab2b71fc652f4533ac7ff3f00eb505eee0f2de27bf1fc8792eb", - "receiptSha256": "521917aa7373eab2b71fc652f4533ac7ff3f00eb505eee0f2de27bf1fc8792eb", + "id": "urn:duly:receipt:sha256:f374205a898c66abcb6fd311f407094badd6363aad0d466eee95a1b69fe42c3f", + "receiptSha256": "f374205a898c66abcb6fd311f407094badd6363aad0d466eee95a1b69fe42c3f", "caseId": "case:fixture:fx-0001", "decision": { "entity": "widget:fx-0001", @@ -16,7 +16,7 @@ }, "rulePack": { "name": "duly-fixture-pack", - "version": "2026.2.0" + "version": "2026.3.0" }, "rulesFired": [ { diff --git a/fixtures/receipts/fx-0002.json b/fixtures/receipts/fx-0002.json index 8888a05..ab00015 100644 --- a/fixtures/receipts/fx-0002.json +++ b/fixtures/receipts/fx-0002.json @@ -1,6 +1,6 @@ { - "id": "urn:duly:receipt:sha256:1445e6b8df22f3f69808869ceb74c12e4f4bfabb20740d6f9b5a941326127b31", - "receiptSha256": "1445e6b8df22f3f69808869ceb74c12e4f4bfabb20740d6f9b5a941326127b31", + "id": "urn:duly:receipt:sha256:f3dc0eb6645503f78aa4c9c94db12b80e1fd88bad339a9abce5cda2a25b85501", + "receiptSha256": "f3dc0eb6645503f78aa4c9c94db12b80e1fd88bad339a9abce5cda2a25b85501", "caseId": "case:fixture:fx-0002", "decision": { "entity": "widget:fx-0002", @@ -16,7 +16,7 @@ }, "rulePack": { "name": "duly-fixture-pack", - "version": "2026.2.0" + "version": "2026.3.0" }, "rulesFired": [ { diff --git a/fixtures/receipts/fx-0003.json b/fixtures/receipts/fx-0003.json index 9a8cf55..ca405d1 100644 --- a/fixtures/receipts/fx-0003.json +++ b/fixtures/receipts/fx-0003.json @@ -1,6 +1,6 @@ { - "id": "urn:duly:receipt:sha256:d10aea516c5b47966774ef9811a4abe1ae022bd631ac1143d3631c7de098e14c", - "receiptSha256": "d10aea516c5b47966774ef9811a4abe1ae022bd631ac1143d3631c7de098e14c", + "id": "urn:duly:receipt:sha256:b4804a3b3caa4ab68e264fc6983e6c9d0524eb2c303831ee2e8b774b6f3ee2d4", + "receiptSha256": "b4804a3b3caa4ab68e264fc6983e6c9d0524eb2c303831ee2e8b774b6f3ee2d4", "caseId": "case:fixture:fx-0003", "decision": { "entity": "widget:fx-0003", @@ -16,7 +16,7 @@ }, "rulePack": { "name": "duly-fixture-pack", - "version": "2026.2.0" + "version": "2026.3.0" }, "rulesFired": [ { @@ -79,7 +79,7 @@ "minConfidence": 0.8, "source": "default", "pack": "duly-fixture-pack", - "packVersion": "2026.2.0" + "packVersion": "2026.3.0" }, "routedTo": "fixture-review" } diff --git a/fixtures/receipts/fx-0004.json b/fixtures/receipts/fx-0004.json index bc12a2d..84407e1 100644 --- a/fixtures/receipts/fx-0004.json +++ b/fixtures/receipts/fx-0004.json @@ -1,6 +1,6 @@ { - "id": "urn:duly:receipt:sha256:bc00dd016ec73ce8b7420f87376fb1344b9d829897c5668a8fcfe6351b87e063", - "receiptSha256": "bc00dd016ec73ce8b7420f87376fb1344b9d829897c5668a8fcfe6351b87e063", + "id": "urn:duly:receipt:sha256:30bd784cfeeb2b4c00df7118e4da43aa8b5424bc97a1bb63c2fcba0d850cf4ec", + "receiptSha256": "30bd784cfeeb2b4c00df7118e4da43aa8b5424bc97a1bb63c2fcba0d850cf4ec", "caseId": "case:fixture:fx-0003", "decision": { "entity": "widget:fx-0003", @@ -16,7 +16,7 @@ }, "rulePack": { "name": "duly-fixture-pack", - "version": "2026.2.0" + "version": "2026.3.0" }, "rulesFired": [ { diff --git a/fixtures/receipts/fx-0006.json b/fixtures/receipts/fx-0006.json new file mode 100644 index 0000000..56ac958 --- /dev/null +++ b/fixtures/receipts/fx-0006.json @@ -0,0 +1,72 @@ +{ + "id": "urn:duly:receipt:sha256:7b1722102aa0ed5d39ba629377e1288f622f67beab4f52c60479491b7176e432", + "receiptSha256": "7b1722102aa0ed5d39ba629377e1288f622f67beab4f52c60479491b7176e432", + "caseId": "case:fixture:fx-0006", + "decision": { + "entity": "widget:fx-0006", + "attribute": "fx:permitted", + "value": { + "kind": "boolean", + "value": true + } + }, + "asOf": { + "effective": "2026-06-01T00:00:00Z", + "knowledge": "2026-03-02T12:00:00Z" + }, + "rulePack": { + "name": "duly-fixture-pack", + "version": "2026.3.0" + }, + "rulesFired": [ + { + "ruleId": "FX-DEFAULT-00", + "version": "1.0.0", + "citation": { + "text": "Fixture default presumption (fictional)" + }, + "priority": 0, + "effectiveFrom": "1900-01-01T00:00:00Z", + "defeated": [] + }, + { + "ruleId": "FX-THRESHOLD-02", + "version": "1.0.0", + "citation": { + "text": "Fixture threshold, 2026 edition (fictional)" + }, + "priority": 100, + "effectiveFrom": "2026-01-01T00:00:00Z", + "defeated": [] + }, + { + "ruleId": "FX-FEE-00", + "version": "1.0.0", + "citation": { + "text": "Fixture fee schedule (fictional)" + }, + "priority": 0, + "effectiveFrom": "1900-01-01T00:00:00Z", + "defeated": [] + } + ], + "derivation": { + "conclusion": { + "entity": "widget:fx-0006", + "attribute": "fx:permitted", + "value": { + "kind": "boolean", + "value": true + } + }, + "rule": "FX-DEFAULT-00", + "premises": [] + }, + "inputFacts": [], + "abstentions": [], + "engine": { + "kernel": "duly-kernel", + "version": "0.0.1", + "backend": "reference" + } +} diff --git a/kernel/tests/test_decision_digest.py b/kernel/tests/test_decision_digest.py index f7eec32..4445377 100644 --- a/kernel/tests/test_decision_digest.py +++ b/kernel/tests/test_decision_digest.py @@ -154,7 +154,7 @@ def test_the_projection_is_the_hashed_input(receipt): #: order. Regenerate deliberately: this moving means the determinant set or the #: canonical form changed, which is a breaking change to the digest (C4), not a #: corpus event. `verify` is what notices a corpus event. -CORPUS_AGGREGATE = "c0361067e2f84939b4bcb1c08e8a72e8120825d8adac63733758d587be54b90f" +CORPUS_AGGREGATE = "4fd1edab3a6b49043356fa0db34bf6c00e2fe0dc9f730fcd13e80e6ffe8237fc" def _corpus_aggregate() -> tuple[str, int]: @@ -166,7 +166,7 @@ def _corpus_aggregate() -> tuple[str, int]: def test_every_fixture_receipt_has_a_stable_digest(): aggregate, count = _corpus_aggregate() - assert count == 4 + assert count == 5 assert aggregate == CORPUS_AGGREGATE, ( "decision digests over the fixture corpus moved. If `fixtures/build.py` " "reproduces the receipts unchanged, the corpus did not move and the " diff --git a/kernel/tests/test_semantics.py b/kernel/tests/test_semantics.py index 6fba37d..0ead746 100644 --- a/kernel/tests/test_semantics.py +++ b/kernel/tests/test_semantics.py @@ -90,6 +90,6 @@ def test_every_fixture_receipt_passes_the_guard(): must be, since there has only ever been one semantics version. A guard that changed a verdict here would be a defect in the guard.""" receipts = fixture_receipts() - assert len(receipts) == 4 + assert len(receipts) == 5 for doc in receipts: check_replayable(doc) diff --git a/spec/decision-digest-vectors.json b/spec/decision-digest-vectors.json index 7d23d3f..715b0e8 100644 --- a/spec/decision-digest-vectors.json +++ b/spec/decision-digest-vectors.json @@ -34,8 +34,8 @@ "description": "the committed fixture receipt fx-0001, unmodified", "equivalenceClass": "fx-0001-as-decided", "receipt": { - "id": "urn:duly:receipt:sha256:521917aa7373eab2b71fc652f4533ac7ff3f00eb505eee0f2de27bf1fc8792eb", - "receiptSha256": "521917aa7373eab2b71fc652f4533ac7ff3f00eb505eee0f2de27bf1fc8792eb", + "id": "urn:duly:receipt:sha256:f374205a898c66abcb6fd311f407094badd6363aad0d466eee95a1b69fe42c3f", + "receiptSha256": "f374205a898c66abcb6fd311f407094badd6363aad0d466eee95a1b69fe42c3f", "caseId": "case:fixture:fx-0001", "decision": { "entity": "widget:fx-0001", @@ -51,7 +51,7 @@ }, "rulePack": { "name": "duly-fixture-pack", - "version": "2026.2.0" + "version": "2026.3.0" }, "rulesFired": [ { @@ -138,14 +138,14 @@ "backend": "reference" } }, - "decisionDigest": "9c60d7bfbfbc2a532e830c957a1fd1d994de07ff594f4a4a03d5321d5283ebea" + "decisionDigest": "955ef8adf8a06dd2a966ef3143915b19ff64228f09a0674bfa45b5c72efafa89" }, { "description": "same adjudication, different case id — caseId is bookkeeping", "equivalenceClass": "fx-0001-as-decided", "receipt": { - "id": "urn:duly:receipt:sha256:521917aa7373eab2b71fc652f4533ac7ff3f00eb505eee0f2de27bf1fc8792eb", - "receiptSha256": "521917aa7373eab2b71fc652f4533ac7ff3f00eb505eee0f2de27bf1fc8792eb", + "id": "urn:duly:receipt:sha256:f374205a898c66abcb6fd311f407094badd6363aad0d466eee95a1b69fe42c3f", + "receiptSha256": "f374205a898c66abcb6fd311f407094badd6363aad0d466eee95a1b69fe42c3f", "caseId": "case:acme:loan-4471", "decision": { "entity": "widget:fx-0001", @@ -161,7 +161,7 @@ }, "rulePack": { "name": "duly-fixture-pack", - "version": "2026.2.0" + "version": "2026.3.0" }, "rulesFired": [ { @@ -248,14 +248,14 @@ "backend": "reference" } }, - "decisionDigest": "9c60d7bfbfbc2a532e830c957a1fd1d994de07ff594f4a4a03d5321d5283ebea" + "decisionDigest": "955ef8adf8a06dd2a966ef3143915b19ff64228f09a0674bfa45b5c72efafa89" }, { "description": "same adjudication, pack provenance added — where a pack was fetched from is not what it says", "equivalenceClass": "fx-0001-as-decided", "receipt": { - "id": "urn:duly:receipt:sha256:521917aa7373eab2b71fc652f4533ac7ff3f00eb505eee0f2de27bf1fc8792eb", - "receiptSha256": "521917aa7373eab2b71fc652f4533ac7ff3f00eb505eee0f2de27bf1fc8792eb", + "id": "urn:duly:receipt:sha256:f374205a898c66abcb6fd311f407094badd6363aad0d466eee95a1b69fe42c3f", + "receiptSha256": "f374205a898c66abcb6fd311f407094badd6363aad0d466eee95a1b69fe42c3f", "caseId": "case:fixture:fx-0001", "decision": { "entity": "widget:fx-0001", @@ -271,7 +271,7 @@ }, "rulePack": { "name": "duly-fixture-pack", - "version": "2026.2.0", + "version": "2026.3.0", "gitCommit": "9f1c0b2", "url": "https://example.invalid/packs/duly-fixture-pack" }, @@ -360,14 +360,14 @@ "backend": "reference" } }, - "decisionDigest": "9c60d7bfbfbc2a532e830c957a1fd1d994de07ff594f4a4a03d5321d5283ebea" + "decisionDigest": "955ef8adf8a06dd2a966ef3143915b19ff64228f09a0674bfa45b5c72efafa89" }, { "description": "same adjudication, different evaluation backend — this is the cross-backend agreement claim, executable", "equivalenceClass": "fx-0001-as-decided", "receipt": { - "id": "urn:duly:receipt:sha256:521917aa7373eab2b71fc652f4533ac7ff3f00eb505eee0f2de27bf1fc8792eb", - "receiptSha256": "521917aa7373eab2b71fc652f4533ac7ff3f00eb505eee0f2de27bf1fc8792eb", + "id": "urn:duly:receipt:sha256:f374205a898c66abcb6fd311f407094badd6363aad0d466eee95a1b69fe42c3f", + "receiptSha256": "f374205a898c66abcb6fd311f407094badd6363aad0d466eee95a1b69fe42c3f", "caseId": "case:fixture:fx-0001", "decision": { "entity": "widget:fx-0001", @@ -383,7 +383,7 @@ }, "rulePack": { "name": "duly-fixture-pack", - "version": "2026.2.0" + "version": "2026.3.0" }, "rulesFired": [ { @@ -470,7 +470,7 @@ "backend": "souffle" } }, - "decisionDigest": "9c60d7bfbfbc2a532e830c957a1fd1d994de07ff594f4a4a03d5321d5283ebea" + "decisionDigest": "955ef8adf8a06dd2a966ef3143915b19ff64228f09a0674bfa45b5c72efafa89" }, { "description": "same adjudication, different kernel implementation, and the artifact identity fields deliberately wrong — the digest is over what was decided, not over a correctly sealed document", @@ -493,7 +493,7 @@ }, "rulePack": { "name": "duly-fixture-pack", - "version": "2026.2.0" + "version": "2026.3.0" }, "rulesFired": [ { @@ -580,14 +580,14 @@ "backend": "reference" } }, - "decisionDigest": "9c60d7bfbfbc2a532e830c957a1fd1d994de07ff594f4a4a03d5321d5283ebea" + "decisionDigest": "955ef8adf8a06dd2a966ef3143915b19ff64228f09a0674bfa45b5c72efafa89" }, { "description": "different decision semantics — engine.version is determinant because different semantics can mean different decisions", "equivalenceClass": "fx-0001-under-other-semantics", "receipt": { - "id": "urn:duly:receipt:sha256:521917aa7373eab2b71fc652f4533ac7ff3f00eb505eee0f2de27bf1fc8792eb", - "receiptSha256": "521917aa7373eab2b71fc652f4533ac7ff3f00eb505eee0f2de27bf1fc8792eb", + "id": "urn:duly:receipt:sha256:f374205a898c66abcb6fd311f407094badd6363aad0d466eee95a1b69fe42c3f", + "receiptSha256": "f374205a898c66abcb6fd311f407094badd6363aad0d466eee95a1b69fe42c3f", "caseId": "case:fixture:fx-0001", "decision": { "entity": "widget:fx-0001", @@ -603,7 +603,7 @@ }, "rulePack": { "name": "duly-fixture-pack", - "version": "2026.2.0" + "version": "2026.3.0" }, "rulesFired": [ { @@ -690,14 +690,14 @@ "backend": "reference" } }, - "decisionDigest": "01ec30ef120fea5881f3768070cf3749bfd3c7d96012972ba360a13d32de2576" + "decisionDigest": "0f6edded3a697348e79b50c980079b2572e3af8f7c11921d3a3171535bf563b0" }, { "description": "different pack version — a pack's identity is its name and version", "equivalenceClass": "fx-0001-under-later-pack", "receipt": { - "id": "urn:duly:receipt:sha256:521917aa7373eab2b71fc652f4533ac7ff3f00eb505eee0f2de27bf1fc8792eb", - "receiptSha256": "521917aa7373eab2b71fc652f4533ac7ff3f00eb505eee0f2de27bf1fc8792eb", + "id": "urn:duly:receipt:sha256:f374205a898c66abcb6fd311f407094badd6363aad0d466eee95a1b69fe42c3f", + "receiptSha256": "f374205a898c66abcb6fd311f407094badd6363aad0d466eee95a1b69fe42c3f", "caseId": "case:fixture:fx-0001", "decision": { "entity": "widget:fx-0001", @@ -713,7 +713,7 @@ }, "rulePack": { "name": "duly-fixture-pack", - "version": "2027.2.0" + "version": "2027.3.0" }, "rulesFired": [ { @@ -800,14 +800,14 @@ "backend": "reference" } }, - "decisionDigest": "51e83c2c3206c8cd3581868ba9802c022e10a221db653fc91e5a6efc13ec349c" + "decisionDigest": "818b8973becf36a276857e72399e22343df9d391113ad9ab22f0451a2f3f798c" }, { "description": "the opposite conclusion (synthetic: the value flipped in both the decision and the derivation)", "equivalenceClass": "fx-0001-flipped", "receipt": { - "id": "urn:duly:receipt:sha256:521917aa7373eab2b71fc652f4533ac7ff3f00eb505eee0f2de27bf1fc8792eb", - "receiptSha256": "521917aa7373eab2b71fc652f4533ac7ff3f00eb505eee0f2de27bf1fc8792eb", + "id": "urn:duly:receipt:sha256:f374205a898c66abcb6fd311f407094badd6363aad0d466eee95a1b69fe42c3f", + "receiptSha256": "f374205a898c66abcb6fd311f407094badd6363aad0d466eee95a1b69fe42c3f", "caseId": "case:fixture:fx-0001", "decision": { "entity": "widget:fx-0001", @@ -823,7 +823,7 @@ }, "rulePack": { "name": "duly-fixture-pack", - "version": "2026.2.0" + "version": "2026.3.0" }, "rulesFired": [ { @@ -910,14 +910,14 @@ "backend": "reference" } }, - "decisionDigest": "fedfcb0630d7ec82c52fd73695348af11b20c34e1bdb5af47123efe19f8189ee" + "decisionDigest": "5bd150aed47e4225802bb595fbfb0f971426adb70c2ab6c2e0f251e1716e837d" }, { "description": "the committed fixture receipt fx-0003, unmodified — a live low_confidence abstention alongside a decision", "equivalenceClass": "fx-0003-as-decided", "receipt": { - "id": "urn:duly:receipt:sha256:d10aea516c5b47966774ef9811a4abe1ae022bd631ac1143d3631c7de098e14c", - "receiptSha256": "d10aea516c5b47966774ef9811a4abe1ae022bd631ac1143d3631c7de098e14c", + "id": "urn:duly:receipt:sha256:b4804a3b3caa4ab68e264fc6983e6c9d0524eb2c303831ee2e8b774b6f3ee2d4", + "receiptSha256": "b4804a3b3caa4ab68e264fc6983e6c9d0524eb2c303831ee2e8b774b6f3ee2d4", "caseId": "case:fixture:fx-0003", "decision": { "entity": "widget:fx-0003", @@ -933,7 +933,7 @@ }, "rulePack": { "name": "duly-fixture-pack", - "version": "2026.2.0" + "version": "2026.3.0" }, "rulesFired": [ { @@ -996,7 +996,7 @@ "minConfidence": 0.8, "source": "default", "pack": "duly-fixture-pack", - "packVersion": "2026.2.0" + "packVersion": "2026.3.0" }, "routedTo": "fixture-review" } @@ -1007,7 +1007,7 @@ "backend": "reference" } }, - "decisionDigest": "bc38ba0eeb9451cc5e3b0ea77ceac168096e50d8715414271e9b9f2c96f211e3" + "decisionDigest": "873d6ca975d1531ac70b301b3c3d69cd5f99fd245ce8d80c6c4eb9ae7321c47c" } ] }