diff --git a/.claude/board/LATEST_STATE.md b/.claude/board/LATEST_STATE.md index 53ecd0586..4e62d5d32 100644 --- a/.claude/board/LATEST_STATE.md +++ b/.claude/board/LATEST_STATE.md @@ -1,3 +1,8 @@ +## 2026-10-30 — main after #1862 (merged `abe0b7afc`) + +- #1862 landed docs and two tests: D-DT64-6 Amendment A, the Morton aligned-square test, and the mask-risc dose test; D-LITHO folded into D-DT64-6. +- No bind store on main yet: `crates/lance-graph-dt64` is in the follow-up PR (stranded on the branch after the merge, rebased onto main). + ## 2026-10-26 — main after #1803 (merged `13e37026`) - Code on main since #1759: `crates/drf-focus-probe` (D-DRF P1/P2, writeback W1–W4/W6–W8, budget, churn, TLB, DRAV-AV1; workspace-excluded, no CI job) and, from #1792, D-NFR-1 falsifier tests only (`cognitive-shader-driver/tests/escaped_reuse.rs`, `lance-graph-planner/tests/horizon_is_not_a_snapshot.rs`, `soa-view-probe/tests/falsifiers.rs`). No production source changed. DR256 global-mask labs (#1790, #1791, #1792 P2) and D-TFLP 6.5 probes (#1800, #1801, #1803) are lab evidence, production impact none. diff --git a/.claude/board/PR_ARC_INVENTORY.md b/.claude/board/PR_ARC_INVENTORY.md index 10a6fc7a6..e7debab20 100644 --- a/.claude/board/PR_ARC_INVENTORY.md +++ b/.claude/board/PR_ARC_INVENTORY.md @@ -1,3 +1,9 @@ +## 2026-10-30 — lance-graph PR #1862 (merged `abe0b7afc`, branch `ccr-45a41fb4-0j3i0l`, head `4707683dc`) — D-DT64-6 Amendment A; D-LITHO folded + +- **Added:** D-DT64-6 Amendment A (the DR facts a DT binding applies, the `tract-harvest-probe` reference path, measured gates); `morton8x8::tests::every_aligned_code_run_is_an_aligned_square`; `lance-graph-mask-risc/tests/dose.rs`; D-LITHO retired and folded into D-DT64-6. +- **Not in it:** the `lance-graph-dt64` crate. Its commits were pushed after the merge and land in the follow-up PR. +- **Confidence:** two lab tests PASS (disable runs red); the rest is docs. + ## 2026-10-26 — lance-graph PR #1806 (merged `99a08622`, branch `docs/d-tflp-v2-synergy-20261026`, head `19a75ae3`) — docs(D-TFLP v2): synergy revision after #1786–#1803 - **Added:** docs only: plan `d-tflp-tflite-palette-polyfill-capstone-v2.md`, entry `2026-10-26-d-tflp-v2-synergy-revision.md`, v1 plan note, STATUS_BOARD/INTEGRATION_PLANS rows. D-TFLP-4 now defaults to the source reciprocal, with #1800's fixtures as its test oracle. diff --git a/.claude/board/STATUS_BOARD.md b/.claude/board/STATUS_BOARD.md index fabb37896..42f04c304 100644 --- a/.claude/board/STATUS_BOARD.md +++ b/.claude/board/STATUS_BOARD.md @@ -55,6 +55,7 @@ | D-DT64-6-3 | register OGIT verbs as DT64 predicates in 64-wide blocks | Queued | D-DT64-6-2 green | | D-DT64-6-4 | consumer read-only access via HotPlug activation | Queued | D-DT64-6-3 green | | D-DT64-6-5 | OGIT becomes cross-check oracle once a domain has attested predicates | Queued | one attested domain | +| D-DT64-6-BS | `crates/lance-graph-dt64`: the bind store (workspace member, contract-only dep). Closes D-DRDT §4 gaps 1–4 (MethodId + SigRev, K3 guard as `SetExpr` + `Logic`, ordered effects, one `resolve`); D5 immutable classid, derived classid + versioned route log; D-DRDT §5 falsifier | In PR (#1869) | 19 tests green, clippy/fmt clean; disable runs (Amendment A.5) | ## D-DT64 — DTO Tensor 64: versioned semantic predicate adapter and DR projections (2026-10-26) diff --git a/.claude/board/entries/2026-10-30-d-dt64-6-bs-bind-store-crate.md b/.claude/board/entries/2026-10-30-d-dt64-6-bs-bind-store-crate.md new file mode 100644 index 000000000..668def579 --- /dev/null +++ b/.claude/board/entries/2026-10-30-d-dt64-6-bs-bind-store-crate.md @@ -0,0 +1,25 @@ +# D-DT64-6-BS — the bind store as production code: `crates/lance-graph-dt64` + +**Date 2026-10-30 · Status VERIFIED-IN-CODE · Plan [`d-dt64-6-routing-bindstore-behavior-v1.md`](../../plans/d-dt64-6-routing-bindstore-behavior-v1.md) Amendment A.5 · Crate `crates/lance-graph-dt64` (workspace member, depends on `lance-graph-contract` only)** + +DT is the ClassId-addressed binding store (D-DRDT §1). The crate stores bindings and their version log, never business data, and evaluates nothing. It closes the four D-DRDT §4 gaps: +- `MethodId` + `SigRev`; +- a K3 guard as `set_algebra::SetExpr` + `Logic`, with `Guard::witness` as the normal form; +- an ordered `effects` list; +- one `resolve(view, classid, schema_rev)`. + +Class behaviour is immutable (D5). An override is a derived classid plus a re-route. Only the route log is versioned: `routed_at(version, position)`. + +Refusals are named. UNKNOWN is never empty: +- unknown class, unrouted position, unknown method; +- schema revision and field-basis drift; +- a dropped, added or mismatched guard; +- a stale signature revision. + +The D-DRDT §5 falsifier passes: two methods on one cell with equal read and write sets and different effect order stay distinct, and a dropped guard refuses. 11 tests; clippy and fmt clean. + +**OPEN:** +- OGAR `CapabilityAuthority` wiring (D6); +- persistence (EXO-55); +- a DR #4 router carrying `MethodRef`; +- the immutable unit (§6 Q1). The crate implements the D5 wording: the classid. diff --git a/.claude/board/entries/README.md b/.claude/board/entries/README.md index e3fd3fb7e..8750a21ef 100644 --- a/.claude/board/entries/README.md +++ b/.claude/board/entries/README.md @@ -25,7 +25,7 @@ index row, (3) no duplicate entry id. Checks 1 and 2 are deliberately opposite directions; the stranding this convention prevents shows up in exactly one of them, never both. -509 entries, 2026-08-06 .. 2026-10-30. +510 entries, 2026-08-06 .. 2026-10-30. | date | entry id | finding | file | |---|---|---|---| @@ -35,6 +35,7 @@ exactly one of them, never both. | 2026-10-30 | `D-TRACT-HARVEST-0` | | [2026-10-30-d-tract-harvest-0-native-algebra-bridge.md](2026-10-30-d-tract-harvest-0-native-algebra-bridge.md) | | 2026-10-30 | `D-LITHO` | | [2026-10-30-d-litho-photolithography-contract.md](2026-10-30-d-litho-photolithography-contract.md) | | 2026-10-30 | `D-LITHO-1` | | [2026-10-30-d-litho-1-xlog-register-steps-and-bag-fixture.md](2026-10-30-d-litho-1-xlog-register-steps-and-bag-fixture.md) | +| 2026-10-30 | `D-DT64-6-BS` | | [2026-10-30-d-dt64-6-bs-bind-store-crate.md](2026-10-30-d-dt64-6-bs-bind-store-crate.md) | | 2026-10-30 | `D-DRCA-3` | | [2026-10-30-d-drca-3-dr4-method-routing.md](2026-10-30-d-drca-3-dr4-method-routing.md) | | 2026-10-29 | `palette512-turbovec9-nsl-worksteal` | | [2026-10-29-palette512-turbovec9-nsl-worksteal.md](2026-10-29-palette512-turbovec9-nsl-worksteal.md) | | 2026-10-28 | `thought-layers-nan-skip` | | [2026-10-28-thought-layers-nan-skip.md](2026-10-28-thought-layers-nan-skip.md) | diff --git a/.claude/plans/d-dt64-6-routing-bindstore-behavior-v1.md b/.claude/plans/d-dt64-6-routing-bindstore-behavior-v1.md index 7d47bbb1b..8209568b6 100644 --- a/.claude/plans/d-dt64-6-routing-bindstore-behavior-v1.md +++ b/.claude/plans/d-dt64-6-routing-bindstore-behavior-v1.md @@ -238,3 +238,56 @@ Production step (registry module doc): the lab ordinals become OGAR-minted conce ## A.4 Open (unchanged owners) The Bag carrier (who admits a counted population into `bind_dr64`); the D-DRDT §4 DT gaps (MethodId, K3 guards, effect order, one lookup); §6 questions 1–9. + +## A.5 (2026-10-30) The bind store as production code: `crates/lance-graph-dt64` + +**Status:** VERIFIED-IN-CODE. This supersedes A.2's status line ("no bind store type exists in the codebase") and closes the four D-DRDT §4 gaps listed in A.4. A workspace member; its only dependency is `lance-graph-contract` (zero-dep). It stores bindings and their version log, never business data, and evaluates nothing. + +| Decision / gap | In `lance-graph-dt64` | Test (`tests/bind_store.rs`) | +|---|---|---| +| D-DRDT §4 gap 1: method identity independent of name | `MethodId` + `SigRev`; the source name rides in `MethodBinding::name` | `same_read_write_sets_…` (stale `SigRev` refused) | +| gap 2: K3, conjunctive WHEN | `Guard { expr: SetExpr, logic: Logic }` from `set_algebra`, the description Quack's WHERE evaluates (§3); `Guard::witness` is the normal form under its own logic (`normalize_kleene` for K3) | `a_kleene_guard_never_treats_unknown_as_true` (`x<5 ∨ x≥5` is `All` two-valued, not under Kleene) | +| gap 3: effect order | `MethodBinding::effects: Vec` (field position + `AlgebraRef`, the D4 polyfill reference); `writes()` is the unordered set | `same_read_write_sets_…` (equal reads/writes, unequal bindings, both kept) | +| gap 4: one lookup | `BindStore::resolve(view, classid, schema_rev)`: fields borrowed from the `ClassView`, methods from the store; `resolve_at` by DR position and route version | `one_lookup_joins_…`, `override_is_…` | +| D5 immutable classid; override = derived classid + re-route | `register` idempotent for an identical binding, else `BehaviourImmutable`; `register_derived` records the base | `class_behaviour_is_immutable`, `override_is_…` | +| Only routing is versioned (§3) | append-only route log, `route` / `retract` / `routed_at(version, position)`; future versions refused | `override_is_…`, `routes_and_derivations_…` | +| F06: unregistered is UNKNOWN | `Refusal::{UnknownClass, Unrouted, UnknownMethod}`, never an empty answer | `an_unregistered_class_is_unknown_not_empty` | +| D-DRDT §5: refuse another schema revision; preserve guard witnesses; the first DR #4 falsifier | `resolve` refuses `SchemaRevisionMismatch` and `FieldBasisChanged`; `admit(MethodRef)` refuses `GuardDropped` / `GuardAdded` / `GuardMismatch` / `SignatureRevisionMismatch` | `schema_revision_and_field_basis_are_checked`, `same_read_write_sets_…`, `a_guard_added_…` | +| A.1: population, truth, value demand | `Population` / `Truth` from `set_rules` and `ValueDemand` ride the binding; a Bag is recorded, never refused here (admitting it is the binder's call) | `population_truth_and_demand_ride_the_binding` | +| Registration hygiene | `DuplicateMethodId`, `FieldOutOfBasis` (ON, reads, effect targets), `BasisTooWide` (> 256) | `registration_refuses_…` | + +Disable runs (on commit `7936665e2`; each patch asserted to apply exactly once; restored with `git checkout`): each of the eight guards removed in turn leaves exactly one test red (10 passed, 1 failed): the `GuardDropped` arm, the `BehaviourImmutable` arm, the route-log version cut, the Kleene witness, the field-basis check, the schema-revision check, the duplicate-MethodId check, the out-of-basis check. + +Not done: wiring OGAR's `CapabilityAuthority` (D6, the production route for consumer classids); persistence of the store (EXO-55 owns the writer path); a router (DR #4) that carries `MethodRef`s; §6 questions 1–9, including whether the immutable unit is the classid or `(classid, binding version)` — this crate implements the D5 wording (the classid). + +**A.5 addendum (2026-10-30, after the XLOG session's critique and the operator's correction).** A classid is a codebook lens, never one per codebook item. Two changes follow: +- `ClassSchema` no longer declares a view class. `view_class(classid)` is the codebook's own split (`ogar_codebook::classid_concept`). +- `Effect` carries an `EffectOp`: `Algebra(AlgebraRef)`, or `Call(MethodRef)` for reusing another lens's entry by reference. A `Call` is admitted when the caller registers (`CallRefused` wraps the reason), and `callers_of(lens)` lists who reuses it. + +In the test, XLOG's per-Y count is three Calls into one algebra lens: group-count twice, then checked i64 Mul. That binds two classes in total; no algebra item gets a classid. Route targets stay bare classids (XLOG: a per-route mask would break D5). Open, with the owners: whether tract-harvest-probe's per-op lab classids (`0x0F01…`) become entries under one lens (EXO-50 message 27); the `MethodSignature` vs `MethodRef` identity (dr-math owner); where the ONNX/XLOG algebra entries are registered. + +**A.5 addendum 2 (2026-10-30, XLOG correction).** A Call had no operands, so the stored binding could not tell XLOG's count from one that counts R twice. Fix: `Effect { target, op, args: Vec }`, where `Arg` is `Field(u8)` (a position checked against the caller's basis) or `Const(i64)` (a key column, a lane). `MethodBinding::inputs` is part of the entry's signature, and a Call passing another arity is refused at registration (`ArityMismatch` inside `CallRefused`). XLOG's count: `cnt_r = Call(group-count, [r, key 1])`, `cnt_s = Call(group-count, [s, key 0])`, `count = Call(Mul i64, [cnt_r, cnt_s])`. `demand` stays `Values`, because the key column is read as a value; that is stated here, not settled. + +**A.5 addendum 3 (2026-10-30, operator).** "The classid switches the behaviour, labels and polyfill over otherwise agnostic addresses or algebra methods." Addendum 1 gave the algebra its own lens classid and had consumers reach it by a class-to-class `Call`, which was wrong. Now: +- The algebra is a classid-free catalogue in the store: `AlgebraEntry { id: AlgebraId, sig_rev, polyfill, inputs }`, declared once with `declare_algebra`, immutable, and versioned in DT directly. This is D6: "global behaviour needs no classid". +- A consumer classid switches labels (its `ClassView`) and polyfill (`Effect { target, algebra: AlgebraRef, args }`) over those entries. `register` refuses `UnknownAlgebra`, `AlgebraRevisionMismatch` and `ArityMismatch`. +- `users_of(AlgebraId)` lists the classids that use an entry. +- `EffectOp::Call` and `MethodBinding::inputs` are removed. + +In the test, XLOG's count and an ONNX `Mul(int64)` node reuse the same entry under their own classids; only the two consumers carry classids. + +**A.5 addendum 4 (2026-10-30, operator).** "Classid widefieldmask ergonomics is the ERB Redmine fieldview pattern." This matches OGAR `ogar-render-askama::field_view` ("the ERB pattern fieldview ClassView rendering from memory without serialization") and openproject-nexgen-rs ("fieldview/erb → classview/askama"). `Resolved::field_view(&WideFieldMask)` yields `FieldRow { position, label, predicate }`, ascending, with the position as the layout address and labels borrowed late from the ClassView. It is OGAR's `FieldView` minus the value, since the store holds none. `Resolved::action_ordinals()` gives the methods as action addresses (T2). Not wired into OGAR's renderer: OGAR builds its `FieldView`s itself, and that call site is the OGAR owner's. +Scope of that projection (XLOG session; quote verified at `.claude/knowledge/ogar-consumer-preflight.md:277` and `cross-glove-business-parity-v1.md:881`): operator, 2026-10-10, *"Classview can be used for any compute masking, we explicitly expanded the ERB redmine fieldview pattern for risk mask of everything."* `ClassView × WideFieldMask` is one masking pattern; rendering, RBAC, SPOG and risk masks are uses of it, and so are a binding's ON and `reads` sets. `field_view` is that pattern's read, not a render-only API. + +**A.5 addendum 5 (2026-10-30): field meaning and meaning-masks through the lens.** DT owns field semantics (D-DRDT §1). The contract already carries them per predicate: `property::PropertySpec { kind, codec_route, semantic_type, marking }`, keyed by the same predicate as `FieldRef::predicate_iri`. +- `Resolved::field_meaning(mask, schema)` joins each fieldview row to its spec. +- `Resolved::mask_where(schema, pred)` derives `WideFieldMask`s from meaning: a risk mask from `marking >= Pii`, an obligation mask from `kind == Required`. This is the "risk mask of everything" over the same pattern. +- `Resolved::unspecified(schema)` surfaces the fields with no spec. A field with no spec is UNKNOWN and never takes the spec default (`Internal` / `PlainText`); a test pins that. + +The physical lane kind (u32 code vs i32, which picks XLOG's comparator) is the SoA column's (`soa_envelope::ColumnKind`), not a meaning. The store does not hold it. + +**A.5 addendum 6 (2026-10-30): dataflow and behaviour footprint.** +- `register` refuses `UndeclaredRead` when a step reads a field that is neither in the method's `reads` nor written by an earlier step. A recipe's order must be what it says: XLOG's count with the multiply moved first is refused. +- `MethodBinding::footprint()` is ON ∪ reads ∪ writes, and `Resolved::methods_touching(mask)` lists the methods a meaning mask reaches. A risk or RBAC mask thereby applies to behaviour, not only to fields. + +Open, for the frame owner (D8): registering a class or declaring an algebra entry moves no revision a frame can check; only routes are versioned. Classes and entries are immutable and append-only, so nothing a frame read can change. Whether a frame should still record the store's catalogue extent (for example a count of bindings) is undecided. diff --git a/Cargo.toml b/Cargo.toml index f4f274c88..779d3b4a0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,9 @@ members = [ # ONNX mathematical compatibility facade over existing ndarray functions # (D-ONNX-NATIVE-0). Depends on ndarray only; nothing native depends on it. "crates/lance-graph-onnx", + # DT64: the ClassId-addressed binding store (D-DRDT §1/§4, D-DT64-6 §1/§3). + # Depends on the zero-dep contract only. + "crates/lance-graph-dt64", # EXPERIMENTAL, routed nowhere: Cypher (upstream front end, read-only) → # bound LogicalOperator → Quack, with typed refusals. "crates/lance-graph-cypher-quack", diff --git a/crates/lance-graph-dt64/Cargo.toml b/crates/lance-graph-dt64/Cargo.toml new file mode 100644 index 000000000..5f20b0326 --- /dev/null +++ b/crates/lance-graph-dt64/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "lance-graph-dt64" +version = "0.1.0" +edition = "2021" +publish = false +description = "DT64: the ClassId-addressed binding store. Class schema (through ClassView), method schema (MethodId, signature revision, K3 guard, ordered effects), immutable class behaviour, and the versioned route log from DR positions to classids. Stores bindings and their version log, never business data; executes nothing." + +[dependencies] +# The one dependency. The contract is zero-dep, so this crate is too. +lance-graph-contract = { path = "../lance-graph-contract" } diff --git a/crates/lance-graph-dt64/src/lib.rs b/crates/lance-graph-dt64/src/lib.rs new file mode 100644 index 000000000..7ce4f4b70 --- /dev/null +++ b/crates/lance-graph-dt64/src/lib.rs @@ -0,0 +1,836 @@ +//! DT64: the ClassId-addressed binding store. +//! +//! D-DRDT §1: "DT is the existing DTO binding store, addressed by ClassId: +//! `ClassId → class schema + method schema`". D-DT64-6 §1 (D4, D5, D6) and §3 +//! give its rules; D-DRDT §4 lists what the contract did not yet carry, and +//! this crate closes those four gaps: +//! +//! 1. **Method identity independent of its name.** [`MethodId`] plus a +//! [`SigRev`] signature revision. The source name +//! (`MethodSig::name`, `ActionDef::predicate`) rides along as +//! [`MethodBinding::name`] and is never the identity. +//! 2. **K3, conjunctive guards.** [`Guard`] is a +//! [`set_algebra::SetExpr`](lance_graph_contract::set_algebra::SetExpr) +//! under a declared [`Logic`]: the same description Quack's WHERE +//! evaluates (D-DT64-6 §3, "WHEN is a Quack WHERE into a guard mask G"). +//! The store keeps it; it evaluates nothing. +//! 3. **Effect order.** [`MethodBinding::effects`] is an ordered list, so two +//! methods with the same read and write sets and different order stay +//! distinct (the CR-H0 negative mold, D-DT64-6 §3). +//! 4. **One lookup.** [`BindStore::resolve`] joins the class schema (read +//! through a [`ClassView`], never copied) with the method schema for one +//! classid at one schema revision. +//! +//! # What is stored, and what is not +//! +//! Bindings and their version log only (the D-DT64-6 §4 reading of the Q0 +//! contract): per classid, its [`ClassSchema`] and [`MethodBinding`]s; and the +//! append-only route log from a DR position to a classid. No business value, +//! no field label (labels come from the [`ClassView`] at resolution), no +//! evaluator, no persistence path. +//! +//! # Immutability (D5) and replay +//! +//! A classid's behaviour never changes once registered. +//! [`BindStore::register`] is idempotent for an identical binding and refuses +//! any other with [`Refusal::BehaviourImmutable`]. An override is a new +//! derived classid ([`BindStore::register_derived`]) plus a re-route +//! ([`BindStore::route`]). Only routing is versioned: [`BindStore::routed_at`] +//! answers which classid a position routed to at a [`RouteVersion`], and that +//! classid's behaviour is the same today as then. +//! +//! # UNKNOWN is never empty +//! +//! An unregistered classid, an unrouted position, an unknown method, a +//! dropped guard, a mismatched revision: each is a named [`Refusal`], never an +//! empty answer that reads as "no behaviour" (D-SAG-6 F06). +//! +//! # Classids +//! +//! A bound class is a `u32`, the key [`ActionDef::object_class`] and +//! [`ClassMethods::classid`] already use. A classid is a codebook lens: its +//! field basis is the [`ClassView`] of its concept half ([`view_class`], the +//! codebook's own `classid_concept` split), labels are resolved late through +//! that view, and its entries are the [`MethodId`]s inside it. A codebook item +//! never gets a classid of its own. +//! +//! # Agnostic algebra, switched by the classid +//! +//! Addresses and algebra methods are agnostic. The algebra catalogue +//! ([`AlgebraEntry`], [`BindStore::declare_algebra`]) is classid-free global +//! behaviour (D-DT64-6 D6). A classid switches labels (its [`ClassView`]) and +//! polyfill (its bindings' [`Effect`]s, each an [`AlgebraRef`] with ordered +//! operands) over them. Two consumers reuse one entry by referring to the +//! same [`AlgebraId`]; [`BindStore::users_of`] lists them. +//! +//! [`ActionDef::object_class`]: lance_graph_contract::action::ActionDef::object_class +//! [`ClassMethods::classid`]: lance_graph_contract::codegen_manifest::ClassMethods::classid + +#![forbid(unsafe_code)] + +use std::collections::BTreeMap; + +use lance_graph_contract::class_view::{ClassId, ClassView, WideFieldMask}; +use lance_graph_contract::ogar_codebook::classid_concept; +use lance_graph_contract::ontology::FieldRef; +use lance_graph_contract::property::{PropertySchema, PropertySpec}; +use lance_graph_contract::set_algebra::{normalize, normalize_kleene, Logic, SetExpr}; +use lance_graph_contract::set_rules::{Population, Truth}; + +/// A method's identity within its class, independent of its source name. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct MethodId(pub u32); + +/// A method's signature revision. A reference made against one revision is +/// refused under another. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct SigRev(pub u32); + +/// A class's schema revision. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct SchemaRev(pub u32); + +/// A position in the append-only route log. `RouteVersion(0)` is the empty +/// log; each route or retract advances it by one. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct RouteVersion(pub u64); + +/// A classid-free algebra method: global behaviour (D-DT64-6 D6, "global +/// behaviour needs no classid and lives in lance-graph"). Addresses and +/// algebra methods are agnostic; a classid's binding is what switches labels +/// and polyfill over them. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct AlgebraId(pub u32); + +/// One entry of the algebra catalogue: the polyfill (D4: over mask-risc / +/// ndarray) named by reference, how many operands it takes, and its signature +/// revision. Immutable once declared; versioned in the store directly (D6). +/// The store never resolves or runs the polyfill. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct AlgebraEntry { + pub id: AlgebraId, + pub sig_rev: SigRev, + pub polyfill: String, + pub inputs: u8, +} + +/// What a class binds an effect to: an algebra entry at the signature +/// revision it was bound against. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct AlgebraRef { + pub id: AlgebraId, + pub sig_rev: SigRev, +} + +impl AlgebraRef { + /// The faithful reference to `entry`. + pub fn of(entry: &AlgebraEntry) -> AlgebraRef { + AlgebraRef { + id: entry.id, + sig_rev: entry.sig_rev, + } + } +} + +/// One DO step: which field position of the class it writes, the algebra +/// entry that writes it, and what it reads. A method's effects are an ORDERED +/// list. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct Effect { + /// Field position in the class basis (the `WideFieldMask` bit). + pub target: u8, + pub algebra: AlgebraRef, + /// What the step reads, in order. Order is load-bearing (a `Sub` or a + /// keyed count), so two steps with the same entry and different arguments + /// are different steps. + pub args: Vec, +} + +/// One input of an effect. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Arg { + /// A field position of the bound class (checked against its basis). + Field(u8), + /// A constant parameter: a key column index, a lane. + Const(i64), +} + +/// WHEN: a guard over the population, stated as the set description Quack's +/// WHERE evaluates, under a declared logic. Conjunction is +/// [`SetExpr::And`]; a [`Logic::Kleene`] guard is three-valued, and a row +/// where it is UNKNOWN does not fire. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct Guard { + pub expr: SetExpr, + pub logic: Logic, +} + +impl Guard { + pub fn new(expr: SetExpr, logic: Logic) -> Guard { + Guard { expr, logic } + } + + /// What a router carries to prove it kept this guard: the expression's + /// normal form under the guard's own logic. Two guards with equal + /// witnesses fire on the same rows. Under [`Logic::Kleene`] the witness is + /// the TRUE-rows form ([`normalize_kleene`]), which is all a guard is read + /// as. + pub fn witness(&self) -> GuardWitness { + let normal = match self.logic { + Logic::TwoValued => normalize(&self.expr), + Logic::Kleene => normalize_kleene(&self.expr).into_where_true(), + }; + GuardWitness { + normal, + logic: self.logic, + } + } +} + +/// See [`Guard::witness`]. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct GuardWitness { + normal: SetExpr, + logic: Logic, +} + +/// Whether a method needs field values, or only the map (D-DRAV-1 §2: map and +/// value lifetimes are separate). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ValueDemand { + MapOnly, + Values, +} + +/// One method's binding: identity, ON × WHEN × DO, and what it needs from the +/// population. Several bindings may share a cell; all are kept. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct MethodBinding { + pub id: MethodId, + pub sig_rev: SigRev, + /// The source name (`MethodSig::name`, `ActionDef::predicate`). Carried, + /// never used as identity. + pub name: String, + /// ON: the field positions whose change triggers the method. + pub on: WideFieldMask, + /// WHEN: `None` for an unguarded method. + pub when: Option, + pub reads: WideFieldMask, + /// DO, in order. + pub effects: Vec, + pub population: Population, + pub truth: Truth, + pub demand: ValueDemand, +} + +impl MethodBinding { + /// The field positions the effects write, as an unordered set. Equal + /// write sets do not make two bindings equal: the order is in + /// [`MethodBinding::effects`]. + pub fn writes(&self) -> WideFieldMask { + let targets: Vec = self.effects.iter().map(|e| e.target).collect(); + WideFieldMask::from_positions(&targets) + } + + /// Every field the method touches: ON, reads and writes. Intersected with + /// a meaning mask it says what behaviour touches risk. + pub fn footprint(&self) -> WideFieldMask { + self.on.union(&self.reads).union(&self.writes()) + } +} + +/// A class's schema as bound: the schema revision and the field count the +/// methods were bound against. The field basis itself is the [`ClassView`] +/// class [`view_class`] of the classid, never declared here. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct ClassSchema { + pub schema_rev: SchemaRev, + pub field_count: usize, +} + +/// The [`ClassView`] class a classid is read through: its concept half, by +/// the codebook's own split ([`classid_concept`]). The classid is the lens; +/// this crate does no bit arithmetic of its own. +pub fn view_class(class: u32) -> ClassId { + classid_concept(class) +} + +/// The widest basis a [`WideFieldMask`] addresses (positions are `u8`). +pub const MAX_FIELDS: usize = 256; + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ClassEntry { + schema: ClassSchema, + methods: Vec, + derived_from: Option, +} + +/// A reference a router (DR #4) carries to a method: the class, the method +/// identity, the signature revision it was made against, and the witness of +/// the guard it kept. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct MethodRef { + pub class: u32, + pub method: MethodId, + pub sig_rev: SigRev, + pub guard: Option, +} + +impl MethodRef { + /// The faithful reference to `binding` in `class`. + pub fn of(class: u32, binding: &MethodBinding) -> MethodRef { + MethodRef { + class, + method: binding.id, + sig_rev: binding.sig_rev, + guard: binding.when.as_ref().map(Guard::witness), + } + } +} + +/// Why the store refused. Every arm is a named UNKNOWN or conflict; none is an +/// empty answer. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Refusal { + /// The classid has no binding. + UnknownClass(u32), + /// The classid is bound to other behaviour (D5). Override with a derived + /// classid and a re-route. + BehaviourImmutable(u32), + /// Two bindings in one class share a MethodId. + DuplicateMethodId { class: u32, method: MethodId }, + /// A binding names a field position at or past the class's field count. + FieldOutOfBasis { + class: u32, + method: MethodId, + position: u8, + }, + /// The declared field count exceeds [`MAX_FIELDS`]. + BasisTooWide { class: u32, field_count: usize }, + /// Asked at another schema revision than the one bound. + SchemaRevisionMismatch { + class: u32, + bound: SchemaRev, + asked: SchemaRev, + }, + /// The [`ClassView`] no longer has the field count the methods were bound + /// against. + FieldBasisChanged { + class: u32, + bound: usize, + view: usize, + }, + /// The class has no method with this identity. + UnknownMethod { class: u32, method: MethodId }, + SignatureRevisionMismatch { + class: u32, + method: MethodId, + bound: SigRev, + asked: SigRev, + }, + /// The method is guarded and the reference carries no guard witness. + GuardDropped { class: u32, method: MethodId }, + /// The reference carries a guard the method does not have. + GuardAdded { class: u32, method: MethodId }, + /// The reference carries another guard than the bound one. + GuardMismatch { class: u32, method: MethodId }, + /// An effect names an algebra entry the catalogue does not hold. + UnknownAlgebra { + class: u32, + method: MethodId, + algebra: AlgebraId, + }, + /// An effect was bound against another revision of the entry. + AlgebraRevisionMismatch { + class: u32, + method: MethodId, + algebra: AlgebraId, + bound: SigRev, + asked: SigRev, + }, + /// An effect passes another number of operands than the entry takes. + ArityMismatch { + class: u32, + method: MethodId, + algebra: AlgebraId, + expected: u8, + got: usize, + }, + /// Step `effect` reads a field that is neither in the method's `reads` + /// nor written by an earlier step: read before written. + UndeclaredRead { + class: u32, + method: MethodId, + effect: usize, + position: u8, + }, + /// The algebra entry is already declared with other content. + AlgebraImmutable(AlgebraId), + /// No classid was routed to the position at that version. + Unrouted { + position: u16, + version: RouteVersion, + }, + /// The version is past the end of the route log. + FutureVersion { + asked: RouteVersion, + current: RouteVersion, + }, +} + +impl std::fmt::Display for Refusal { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{self:?}") + } +} + +impl std::error::Error for Refusal {} + +/// The result of one lookup: the class schema as bound, its fields as the +/// [`ClassView`] holds them now (borrowed), and its methods. +#[derive(Debug, Clone, Copy)] +pub struct Resolved<'s, 'v> { + pub class: u32, + pub schema: &'s ClassSchema, + pub fields: &'v [FieldRef], + pub methods: &'s [MethodBinding], + pub derived_from: Option, +} + +/// One row of the fieldview projection (the Redmine ERB fieldview pattern, +/// OGAR `ogar-render-askama::field_view::FieldView` minus the value): the +/// field's mask position is its layout address, and its label and predicate +/// are borrowed late from the [`ClassView`]. The store holds no values; a +/// renderer pairs each row with the consumer's value. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct FieldRow<'v> { + pub position: u8, + pub label: &'v str, + pub predicate: &'v str, +} + +/// A fieldview row with its meaning (see [`Resolved::field_meaning`]). +#[derive(Debug, Clone, Copy)] +pub struct FieldMeaning<'v, 'p> { + pub row: FieldRow<'v>, + /// The field's semantics by predicate; `None` is UNKNOWN. + pub spec: Option<&'p PropertySpec>, +} + +impl<'s, 'v> Resolved<'s, 'v> { + /// Every method whose ON includes `field`, in registration order. Several + /// are normal and all are returned. + pub fn methods_on(&self, field: u8) -> impl Iterator { + self.methods.iter().filter(move |m| m.on.has(field)) + } + + /// The fieldview: the fields `mask` selects, ascending by position, with + /// their labels. A position past the class's basis selects nothing. + pub fn field_view<'m>(&self, mask: &'m WideFieldMask) -> impl Iterator> + 'm + where + 'v: 'm, + { + let fields = self.fields; + mask_positions(mask).filter_map(move |p| { + fields.get(usize::from(p)).map(|f| FieldRow { + position: p, + label: f.label.as_str(), + predicate: f.predicate_iri.as_str(), + }) + }) + } + + /// The fieldview with meaning: each selected row joined by predicate to + /// its [`PropertySpec`] (obligation, semantic type, marking). `spec` is + /// `None` when the schema has no entry: UNKNOWN meaning, never a default. + pub fn field_meaning<'m, 'p>( + &self, + mask: &'m WideFieldMask, + schema: &'p PropertySchema, + ) -> impl Iterator> + 'm + where + 'v: 'm, + 'p: 'm, + { + self.field_view(mask).map(move |row| FieldMeaning { + row, + spec: schema.get(row.predicate), + }) + } + + /// A mask from meaning: the lens's fields whose spec satisfies `pred` + /// (a risk mask is `|s| s.marking >= Marking::Pii`, a presence obligation + /// is `|s| s.kind == PropertyKind::Required`). A field with no spec is + /// never selected, whatever `pred` would say about a default. + pub fn mask_where( + &self, + schema: &PropertySchema, + pred: impl Fn(&PropertySpec) -> bool, + ) -> WideFieldMask { + self.positions(|f| schema.get(&f.predicate_iri).is_some_and(&pred)) + } + + /// The lens's fields the schema says nothing about: UNKNOWN meaning, + /// surfaced rather than defaulted. + pub fn unspecified(&self, schema: &PropertySchema) -> WideFieldMask { + self.positions(|f| schema.get(&f.predicate_iri).is_none()) + } + + fn positions(&self, keep: impl Fn(&FieldRef) -> bool) -> WideFieldMask { + let picked: Vec = self + .fields + .iter() + .enumerate() + .filter(|(_, f)| keep(f)) + .filter_map(|(i, _)| u8::try_from(i).ok()) + .collect(); + WideFieldMask::from_positions(&picked) + } + + /// The methods whose [`MethodBinding::footprint`] meets `mask`: the + /// behaviour a risk or RBAC mask reaches, in registration order. + pub fn methods_touching<'m>( + &self, + mask: &'m WideFieldMask, + ) -> impl Iterator + 'm + where + 's: 'm, + { + self.methods + .iter() + .filter(move |m| !m.footprint().intersect(mask).is_empty()) + } + + /// The action addresses the class exposes: its method ordinals, in + /// registration order (fieldview charter T2: behaviour by address only). + pub fn action_ordinals(&self) -> impl Iterator + 's { + self.methods.iter().map(|m| m.id) + } +} + +/// The binding store. Building it takes `&mut self`; every read is `&self`. +#[derive(Debug, Clone, Default)] +pub struct BindStore { + classes: BTreeMap, + /// The classid-free algebra catalogue (D6). + algebra: BTreeMap, + /// Per position, the route events in version order: `(version, class)`, + /// `None` for a retract. + routes: BTreeMap)>>, + version: RouteVersion, +} + +impl BindStore { + pub fn new() -> BindStore { + BindStore::default() + } + + /// Bind `class`. Idempotent for an identical binding; any other binding of + /// an already bound class is refused (D5). + pub fn register( + &mut self, + class: u32, + schema: ClassSchema, + methods: Vec, + ) -> Result<(), Refusal> { + self.insert(class, schema, methods, None) + } + + /// Bind `class` as an override of the bound class `base` (D-DT64-6 §3). + /// `base` keeps its behaviour; route positions to `class` to use it. + pub fn register_derived( + &mut self, + base: u32, + class: u32, + schema: ClassSchema, + methods: Vec, + ) -> Result<(), Refusal> { + if !self.classes.contains_key(&base) { + return Err(Refusal::UnknownClass(base)); + } + if base == class { + return Err(Refusal::BehaviourImmutable(class)); + } + self.insert(class, schema, methods, Some(base)) + } + + fn insert( + &mut self, + class: u32, + schema: ClassSchema, + methods: Vec, + derived_from: Option, + ) -> Result<(), Refusal> { + validate(class, &schema, &methods)?; + for m in &methods { + for e in &m.effects { + let (method, algebra) = (m.id, e.algebra.id); + let entry = self.algebra.get(&algebra).ok_or(Refusal::UnknownAlgebra { + class, + method, + algebra, + })?; + if entry.sig_rev != e.algebra.sig_rev { + return Err(Refusal::AlgebraRevisionMismatch { + class, + method, + algebra, + bound: entry.sig_rev, + asked: e.algebra.sig_rev, + }); + } + if usize::from(entry.inputs) != e.args.len() { + return Err(Refusal::ArityMismatch { + class, + method, + algebra, + expected: entry.inputs, + got: e.args.len(), + }); + } + } + } + let entry = ClassEntry { + schema, + methods, + derived_from, + }; + match self.classes.get(&class) { + Some(bound) if *bound == entry => Ok(()), + Some(_) => Err(Refusal::BehaviourImmutable(class)), + None => { + self.classes.insert(class, entry); + Ok(()) + } + } + } + + /// Declare a classid-free algebra entry. Idempotent for an identical + /// entry; any other content under a declared id is refused. + pub fn declare_algebra(&mut self, entry: AlgebraEntry) -> Result<(), Refusal> { + match self.algebra.get(&entry.id) { + Some(bound) if *bound == entry => Ok(()), + Some(_) => Err(Refusal::AlgebraImmutable(entry.id)), + None => { + self.algebra.insert(entry.id, entry); + Ok(()) + } + } + } + + /// The algebra entry `id`, if declared. + pub fn algebra(&self, id: AlgebraId) -> Option<&AlgebraEntry> { + self.algebra.get(&id) + } + + /// Every `(classid, method)` whose DO uses algebra entry `id`: which + /// classids switch over it. + pub fn users_of(&self, id: AlgebraId) -> Vec<(u32, MethodId)> { + let mut out = Vec::new(); + for (&class, entry) in &self.classes { + for m in &entry.methods { + if m.effects.iter().any(|e| e.algebra.id == id) { + out.push((class, m.id)); + } + } + } + out + } + + /// Number of bound classes. + pub fn class_count(&self) -> usize { + self.classes.len() + } + + /// The current end of the route log. + pub fn current_version(&self) -> RouteVersion { + self.version + } + + /// Route `position` to the bound `class`; returns the new version. + pub fn route(&mut self, position: u16, class: u32) -> Result { + if !self.classes.contains_key(&class) { + return Err(Refusal::UnknownClass(class)); + } + Ok(self.push_route(position, Some(class))) + } + + /// Remove `position`'s route; returns the new version. The classid it + /// routed to keeps its behaviour. + pub fn retract(&mut self, position: u16) -> RouteVersion { + self.push_route(position, None) + } + + fn push_route(&mut self, position: u16, class: Option) -> RouteVersion { + self.version = RouteVersion(self.version.0 + 1); + self.routes + .entry(position) + .or_default() + .push((self.version, class)); + self.version + } + + /// The classid `position` routed to at `version`, `None` if it was + /// unrouted then. + pub fn routed_at(&self, version: RouteVersion, position: u16) -> Result, Refusal> { + if version > self.version { + return Err(Refusal::FutureVersion { + asked: version, + current: self.version, + }); + } + let Some(events) = self.routes.get(&position) else { + return Ok(None); + }; + // Events are in version order; take the last one at or before `version`. + let seen = events.partition_point(|(v, _)| *v <= version); + Ok(seen.checked_sub(1).and_then(|i| events[i].1)) + } + + /// The one lookup: `(classid, schema revision) → { fields, methods }`. + pub fn resolve<'s, 'v, V: ClassView + ?Sized>( + &'s self, + view: &'v V, + class: u32, + schema_rev: SchemaRev, + ) -> Result, Refusal> { + let entry = self + .classes + .get(&class) + .ok_or(Refusal::UnknownClass(class))?; + if entry.schema.schema_rev != schema_rev { + return Err(Refusal::SchemaRevisionMismatch { + class, + bound: entry.schema.schema_rev, + asked: schema_rev, + }); + } + let fields = view.fields(view_class(class)); + if fields.len() != entry.schema.field_count { + return Err(Refusal::FieldBasisChanged { + class, + bound: entry.schema.field_count, + view: fields.len(), + }); + } + Ok(Resolved { + class, + schema: &entry.schema, + fields, + methods: &entry.methods, + derived_from: entry.derived_from, + }) + } + + /// The lookup by DR position: the classid routed there at `version`, then + /// [`BindStore::resolve`]. + pub fn resolve_at<'s, 'v, V: ClassView + ?Sized>( + &'s self, + view: &'v V, + version: RouteVersion, + position: u16, + schema_rev: SchemaRev, + ) -> Result, Refusal> { + let class = self + .routed_at(version, position)? + .ok_or(Refusal::Unrouted { position, version })?; + self.resolve(view, class, schema_rev) + } + + /// Check a router's method reference against the binding and return the + /// binding (D-DRDT §5): the method must exist, at the referenced signature + /// revision, with exactly the guard the reference witnesses. + pub fn admit(&self, r: &MethodRef) -> Result<&MethodBinding, Refusal> { + let entry = self + .classes + .get(&r.class) + .ok_or(Refusal::UnknownClass(r.class))?; + let (class, method) = (r.class, r.method); + let binding = entry + .methods + .iter() + .find(|m| m.id == method) + .ok_or(Refusal::UnknownMethod { class, method })?; + if binding.sig_rev != r.sig_rev { + return Err(Refusal::SignatureRevisionMismatch { + class, + method, + bound: binding.sig_rev, + asked: r.sig_rev, + }); + } + match (&binding.when, &r.guard) { + (None, None) => Ok(binding), + (Some(_), None) => Err(Refusal::GuardDropped { class, method }), + (None, Some(_)) => Err(Refusal::GuardAdded { class, method }), + (Some(g), Some(w)) if g.witness() == *w => Ok(binding), + (Some(_), Some(_)) => Err(Refusal::GuardMismatch { class, method }), + } + } +} + +/// Registration checks: the basis fits a [`WideFieldMask`], MethodIds are +/// unique in the class, and every field position a binding names is inside +/// the declared basis. +fn validate(class: u32, schema: &ClassSchema, methods: &[MethodBinding]) -> Result<(), Refusal> { + if schema.field_count > MAX_FIELDS { + return Err(Refusal::BasisTooWide { + class, + field_count: schema.field_count, + }); + } + for (i, m) in methods.iter().enumerate() { + if methods[..i].iter().any(|p| p.id == m.id) { + return Err(Refusal::DuplicateMethodId { + class, + method: m.id, + }); + } + } + let inside = |p: u8| usize::from(p) < schema.field_count; + for m in methods { + let positions = mask_positions(&m.on) + .chain(mask_positions(&m.reads)) + .chain(m.effects.iter().map(|e| e.target)) + .chain(m.effects.iter().flat_map(|e| { + e.args.iter().filter_map(|a| match a { + Arg::Field(p) => Some(*p), + Arg::Const(_) => None, + }) + })); + for position in positions { + if !inside(position) { + return Err(Refusal::FieldOutOfBasis { + class, + method: m.id, + position, + }); + } + } + // Dataflow: a step reads only what the method declares it reads or + // what an earlier step of the same method wrote. Otherwise the order + // of the recipe is not what it says. + let mut available = m.reads.clone(); + for (effect, e) in m.effects.iter().enumerate() { + for a in &e.args { + if let Arg::Field(position) = *a { + if !available.has(position) { + return Err(Refusal::UndeclaredRead { + class, + method: m.id, + effect, + position, + }); + } + } + } + available = available.with(e.target); + } + } + Ok(()) +} + +/// The set positions of a mask, ascending. +fn mask_positions(mask: &WideFieldMask) -> impl Iterator + '_ { + (0..=u8::MAX).filter(move |&p| mask.has(p)) +} diff --git a/crates/lance-graph-dt64/tests/bind_store.rs b/crates/lance-graph-dt64/tests/bind_store.rs new file mode 100644 index 000000000..42b94e66e --- /dev/null +++ b/crates/lance-graph-dt64/tests/bind_store.rs @@ -0,0 +1,810 @@ +//! The DT64 bind store against the decisions it implements: D-DT64-6 §1 (D4, +//! D5, D6) and §3, D-DRDT §4 (the four gaps) and §5 (the DR #4 falsifier). + +use lance_graph_contract::class_view::{ClassId, ClassView, WideFieldMask}; +use lance_graph_contract::ontology::{DisplayTemplate, FieldRef}; +use lance_graph_contract::set_algebra::{Cmp, Logic, SetExpr}; +use lance_graph_contract::set_rules::{Population, Truth}; +use lance_graph_dt64::{ + view_class, AlgebraEntry, AlgebraId, AlgebraRef, Arg, BindStore, ClassSchema, Effect, Guard, + MethodBinding, MethodId, MethodRef, Refusal, RouteVersion, SchemaRev, SigRev, ValueDemand, +}; + +/// A ClassView with one class of `n` fields; `n` can be changed to model a +/// schema that moved under the binding. +struct View { + class: ClassId, + fields: Vec, +} + +impl View { + fn with_fields(class: ClassId, n: usize) -> View { + View { + class, + fields: (0..n) + .map(|i| FieldRef::new(format!("urn:f{i}"), format!("f{i}"))) + .collect(), + } + } +} + +impl ClassView for View { + fn fields(&self, class: ClassId) -> &[FieldRef] { + if class == self.class { + &self.fields + } else { + &[] + } + } + fn template(&self, _class: ClassId) -> DisplayTemplate { + DisplayTemplate::Detail + } + fn dolce_category_id(&self, _class: ClassId) -> u8 { + 0 + } +} + +const ORDER: u32 = 0x0A1E_0001; +const ORDER_V2: u32 = 0x0A1E_0002; +/// The lens ORDER is read through: its concept half (`view_class`). +const VIEW_CLASS: ClassId = 0x0A1E; + +fn schema(rev: u32, fields: usize) -> ClassSchema { + ClassSchema { + schema_rev: SchemaRev(rev), + field_count: fields, + } +} + +/// The classid-free algebra catalogue the tests bind against (lab ids). +const ASSIGN: AlgebraId = AlgebraId(1); +const ACCUMULATE: AlgebraId = AlgebraId(2); +const GROUP_COUNT: AlgebraId = AlgebraId(10); +const MUL_I64: AlgebraId = AlgebraId(11); + +fn entry(id: AlgebraId, polyfill: &str, inputs: u8) -> AlgebraEntry { + AlgebraEntry { + id, + sig_rev: SigRev(1), + polyfill: polyfill.to_string(), + inputs, + } +} + +/// A store holding only the algebra catalogue: no classid anywhere. +fn catalogue() -> BindStore { + let mut s = BindStore::new(); + for e in [ + entry(ASSIGN, "assign", 0), + entry(ACCUMULATE, "accumulate", 0), + // (relation field, key column) + entry(GROUP_COUNT, "ndarray::simd::masked_group_count_u32", 2), + entry( + MUL_I64, + "ndarray::hpc::checked_i64::checked_binary_i64(Mul)", + 2, + ), + ] { + s.declare_algebra(e).unwrap(); + } + s +} + +fn uses(id: AlgebraId) -> AlgebraRef { + AlgebraRef { + id, + sig_rev: SigRev(1), + } +} + +fn effect(target: u8, algebra: &str) -> Effect { + let id = match algebra { + "assign" => ASSIGN, + "accumulate" => ACCUMULATE, + other => panic!("no lab entry {other}"), + }; + Effect { + target, + algebra: uses(id), + args: vec![], + } +} + +/// A method fired by a change of field 0, guarded by `state == 1`, writing +/// field 2 then field 3 in the given order. +fn method(id: u32, effects: Vec) -> MethodBinding { + MethodBinding { + id: MethodId(id), + sig_rev: SigRev(1), + name: format!("m{id}"), + on: WideFieldMask::from_positions(&[0]), + when: Some(Guard::new(SetExpr::eq_u32(1, 1), Logic::Kleene)), + reads: WideFieldMask::from_positions(&[0, 1]), + effects, + population: Population::Distinct, + truth: Truth::ThreeValued, + demand: ValueDemand::Values, + } +} + +fn store_with_order() -> BindStore { + let mut s = catalogue(); + s.register( + ORDER, + schema(1, 4), + vec![method( + 10, + vec![effect(2, "assign"), effect(3, "accumulate")], + )], + ) + .unwrap(); + s +} + +/// One lookup: class schema from the ClassView, method schema from the store, +/// for one classid at one schema revision (D-DRDT §4 gap 4). +#[test] +fn one_lookup_joins_class_schema_and_method_schema() { + let s = store_with_order(); + let view = View::with_fields(VIEW_CLASS, 4); + let r = s.resolve(&view, ORDER, SchemaRev(1)).unwrap(); + assert_eq!(r.fields.len(), 4, "fields come from the ClassView"); + assert_eq!(r.fields[2].label, "f2"); + assert_eq!(r.methods.len(), 1); + assert_eq!(r.methods[0].id, MethodId(10)); + assert_eq!(r.derived_from, None); +} + +/// An unregistered classid is UNKNOWN, never "no behaviour" (D-SAG-6 F06). +#[test] +fn an_unregistered_class_is_unknown_not_empty() { + let s = store_with_order(); + let view = View::with_fields(VIEW_CLASS, 4); + assert_eq!( + s.resolve(&view, 0xDEAD_0001, SchemaRev(1)).unwrap_err(), + Refusal::UnknownClass(0xDEAD_0001) + ); + // The registered one still resolves, so the refusal is about the id. + assert!(s.resolve(&view, ORDER, SchemaRev(1)).is_ok()); +} + +/// D5: a classid's behaviour is permanently immutable. Re-registering the same +/// binding is idempotent; any change is refused, and the old binding stands. +#[test] +fn class_behaviour_is_immutable() { + let mut s = store_with_order(); + let same = vec![method( + 10, + vec![effect(2, "assign"), effect(3, "accumulate")], + )]; + assert_eq!(s.register(ORDER, schema(1, 4), same), Ok(())); + + let reordered = vec![method( + 10, + vec![effect(3, "accumulate"), effect(2, "assign")], + )]; + assert_eq!( + s.register(ORDER, schema(1, 4), reordered), + Err(Refusal::BehaviourImmutable(ORDER)) + ); + let view = View::with_fields(VIEW_CLASS, 4); + let r = s.resolve(&view, ORDER, SchemaRev(1)).unwrap(); + assert_eq!( + r.methods[0].effects[0].target, 2, + "the first binding stands" + ); +} + +/// An override is a new derived classid plus a re-route (D-DT64-6 §3). Only +/// routing is versioned: replay as of a version reads the classid routed +/// then, and that classid's behaviour is the same as today. +#[test] +fn override_is_a_derived_class_and_a_reroute_and_replay_reads_the_old_route() { + let mut s = store_with_order(); + let v1 = s.route(7, ORDER).unwrap(); + s.register_derived( + ORDER, + ORDER_V2, + schema(1, 4), + vec![method( + 11, + vec![effect(3, "accumulate"), effect(2, "assign")], + )], + ) + .unwrap(); + let v2 = s.route(7, ORDER_V2).unwrap(); + let v3 = s.retract(7); + + assert_eq!(s.routed_at(RouteVersion(0), 7), Ok(None)); + assert_eq!(s.routed_at(v1, 7), Ok(Some(ORDER))); + assert_eq!(s.routed_at(v2, 7), Ok(Some(ORDER_V2))); + assert_eq!(s.routed_at(v3, 7), Ok(None)); + assert_eq!( + s.routed_at(v2, 8), + Ok(None), + "another position never routed" + ); + assert_eq!( + s.routed_at(RouteVersion(v3.0 + 1), 7), + Err(Refusal::FutureVersion { + asked: RouteVersion(v3.0 + 1), + current: v3 + }) + ); + + let view = View::with_fields(VIEW_CLASS, 4); + let old = s.resolve_at(&view, v1, 7, SchemaRev(1)).unwrap(); + assert_eq!(old.class, ORDER); + assert_eq!(old.methods[0].id, MethodId(10), "old behaviour unchanged"); + let new = s.resolve_at(&view, v2, 7, SchemaRev(1)).unwrap(); + assert_eq!(new.class, ORDER_V2); + assert_eq!(new.derived_from, Some(ORDER)); + assert_eq!( + s.resolve_at(&view, v3, 7, SchemaRev(1)).unwrap_err(), + Refusal::Unrouted { + position: 7, + version: v3 + } + ); +} + +/// Routing refuses a classid the store does not bind, and a derived class +/// needs a bound base. +#[test] +fn routes_and_derivations_need_bound_classes() { + let mut s = store_with_order(); + assert_eq!( + s.route(1, 0xBEEF_0001), + Err(Refusal::UnknownClass(0xBEEF_0001)) + ); + assert_eq!( + s.register_derived(0xBEEF_0001, ORDER_V2, schema(1, 4), vec![]), + Err(Refusal::UnknownClass(0xBEEF_0001)) + ); + assert_eq!( + s.register_derived(ORDER, ORDER, schema(1, 4), vec![]), + Err(Refusal::BehaviourImmutable(ORDER)) + ); + assert_eq!( + s.current_version(), + RouteVersion(0), + "no refused call moved the log" + ); +} + +/// D-DRDT §5, the first DR #4 falsifier: two methods on one cell with the same +/// read and write sets and different effect order stay distinct, and a dropped +/// guard refuses. +#[test] +fn same_read_write_sets_different_effect_order_stay_distinct_and_a_dropped_guard_refuses() { + let mut s = catalogue(); + let a = method(20, vec![effect(2, "assign"), effect(2, "accumulate")]); + let b = method(21, vec![effect(2, "accumulate"), effect(2, "assign")]); + // The negative mold: equal unordered read and write sets. + assert_eq!(a.reads, b.reads); + assert_eq!(a.writes(), b.writes()); + assert_ne!(a, b, "effect order is part of the binding"); + s.register(ORDER, schema(1, 4), vec![a.clone(), b.clone()]) + .unwrap(); + + let view = View::with_fields(VIEW_CLASS, 4); + let r = s.resolve(&view, ORDER, SchemaRev(1)).unwrap(); + let on_cell: Vec = r.methods_on(0).map(|m| m.id).collect(); + assert_eq!( + on_cell, + vec![MethodId(20), MethodId(21)], + "both kept, none collapsed" + ); + assert_eq!(r.methods_on(1).count(), 0, "field 1 triggers nothing"); + + // Each faithful reference admits its own binding with its own order. + let ra = MethodRef::of(ORDER, &a); + let rb = MethodRef::of(ORDER, &b); + assert_eq!(s.admit(&ra).unwrap().effects[0].algebra.id, ASSIGN); + assert_eq!(s.admit(&rb).unwrap().effects[0].algebra.id, ACCUMULATE); + + // A route that dropped the guard witness refuses. + let dropped = MethodRef { + guard: None, + ..ra.clone() + }; + assert_eq!( + s.admit(&dropped).unwrap_err(), + Refusal::GuardDropped { + class: ORDER, + method: MethodId(20) + } + ); + // A guard that is not the bound one refuses. + let other = MethodRef { + guard: Some(Guard::new(SetExpr::eq_u32(1, 2), Logic::Kleene).witness()), + ..ra.clone() + }; + assert_eq!( + s.admit(&other).unwrap_err(), + Refusal::GuardMismatch { + class: ORDER, + method: MethodId(20) + } + ); + // A stale signature revision refuses. + let stale = MethodRef { + sig_rev: SigRev(0), + ..ra + }; + assert_eq!( + s.admit(&stale).unwrap_err(), + Refusal::SignatureRevisionMismatch { + class: ORDER, + method: MethodId(20), + bound: SigRev(1), + asked: SigRev(0) + } + ); +} + +/// A guard on an unguarded method is not a witness either. +#[test] +fn a_guard_added_to_an_unguarded_method_refuses() { + let mut s = catalogue(); + let free = MethodBinding { + when: None, + ..method(30, vec![effect(2, "assign")]) + }; + s.register(ORDER, schema(1, 4), vec![free.clone()]).unwrap(); + assert!(s.admit(&MethodRef::of(ORDER, &free)).is_ok()); + let added = MethodRef { + guard: Some(Guard::new(SetExpr::All, Logic::TwoValued).witness()), + ..MethodRef::of(ORDER, &free) + }; + assert_eq!( + s.admit(&added).unwrap_err(), + Refusal::GuardAdded { + class: ORDER, + method: MethodId(30) + } + ); +} + +/// WHEN is three-valued: under Kleene logic `x < 5 OR x >= 5` is not the whole +/// population (a NULL row is UNKNOWN, and UNKNOWN never fires), while the +/// two-valued reading of the same expression is. Two-sided, so the witness is +/// neither always-equal nor always-different. +#[test] +fn a_kleene_guard_never_treats_unknown_as_true() { + let covering = SetExpr::Or(vec![ + SetExpr::i32(4, Cmp::Lt, 5), + SetExpr::i32(4, Cmp::Ge, 5), + ]); + let all = |logic| Guard::new(SetExpr::All, logic).witness(); + assert_eq!( + Guard::new(covering.clone(), Logic::TwoValued).witness(), + all(Logic::TwoValued) + ); + assert_ne!( + Guard::new(covering, Logic::Kleene).witness(), + all(Logic::Kleene) + ); + // The logic is part of the witness: the same expression under the other + // logic is a different guard. + assert_ne!(all(Logic::TwoValued), all(Logic::Kleene)); +} + +/// A method reference resolved at another schema revision is refused +/// (D-DRDT §5), and so is a ClassView whose field basis moved under the +/// binding. +#[test] +fn schema_revision_and_field_basis_are_checked() { + let s = store_with_order(); + let view = View::with_fields(VIEW_CLASS, 4); + assert_eq!( + s.resolve(&view, ORDER, SchemaRev(2)).unwrap_err(), + Refusal::SchemaRevisionMismatch { + class: ORDER, + bound: SchemaRev(1), + asked: SchemaRev(2) + } + ); + let grown = View::with_fields(VIEW_CLASS, 5); + assert_eq!( + s.resolve(&grown, ORDER, SchemaRev(1)).unwrap_err(), + Refusal::FieldBasisChanged { + class: ORDER, + bound: 4, + view: 5 + } + ); +} + +/// Registration refuses what would make a binding ambiguous or point outside +/// its class: a repeated MethodId, a field position past the schema, and a +/// basis wider than a `WideFieldMask` addresses. +#[test] +fn registration_refuses_ambiguity_and_out_of_basis_positions() { + let mut s = catalogue(); + assert_eq!( + s.register( + ORDER, + schema(1, 4), + vec![method(1, vec![]), method(1, vec![effect(2, "assign")])] + ), + Err(Refusal::DuplicateMethodId { + class: ORDER, + method: MethodId(1) + }) + ); + assert_eq!( + s.register( + ORDER, + schema(1, 4), + vec![method(1, vec![effect(4, "assign")])] + ), + Err(Refusal::FieldOutOfBasis { + class: ORDER, + method: MethodId(1), + position: 4 + }) + ); + let reads_past = MethodBinding { + reads: WideFieldMask::from_positions(&[9]), + ..method(1, vec![]) + }; + assert_eq!( + s.register(ORDER, schema(1, 4), vec![reads_past]), + Err(Refusal::FieldOutOfBasis { + class: ORDER, + method: MethodId(1), + position: 9 + }) + ); + assert_eq!( + s.register(ORDER, schema(1, 257), vec![]), + Err(Refusal::BasisTooWide { + class: ORDER, + field_count: 257 + }) + ); + // None of the refused registrations bound the class. + assert_eq!(s.class_count(), 0); + // The largest legal basis is accepted, with a write on its last position. + assert_eq!( + s.register( + ORDER, + schema(1, 256), + vec![method(1, vec![effect(255, "assign")])] + ), + Ok(()) + ); +} + +/// The store records what a method needs (D-DT64-6 A.1): population, truth and +/// value demand ride the binding unchanged; the store never refuses a Bag, +/// because admitting a counted population is the binder's call, not DT's. +#[test] +fn population_truth_and_demand_ride_the_binding() { + let mut s = catalogue(); + let count = MethodBinding { + population: Population::Bag, + truth: Truth::TwoValued, + demand: ValueDemand::MapOnly, + when: None, + ..method(40, vec![]) + }; + s.register(ORDER, schema(1, 4), vec![count.clone()]) + .unwrap(); + let got = s.admit(&MethodRef::of(ORDER, &count)).unwrap(); + assert_eq!(got.population, Population::Bag); + assert_eq!(got.truth, Truth::TwoValued); + assert_eq!(got.demand, ValueDemand::MapOnly); +} + +/// A classid is a lens: its field basis is the ClassView of its concept half, +/// derived by the codebook's split, never declared. A view that carries the +/// fields under another class does not satisfy it. +#[test] +fn the_lens_is_the_classids_concept_half() { + assert_eq!(view_class(ORDER), VIEW_CLASS); + assert_eq!( + view_class(ORDER_V2), + VIEW_CLASS, + "app half does not move the lens" + ); + let s = store_with_order(); + assert!(s + .resolve(&View::with_fields(VIEW_CLASS, 4), ORDER, SchemaRev(1)) + .is_ok()); + assert_eq!( + s.resolve(&View::with_fields(0x0A1F, 4), ORDER, SchemaRev(1)) + .unwrap_err(), + Refusal::FieldBasisChanged { + class: ORDER, + bound: 4, + view: 0 + } + ); +} + +/// Lab consumer classids. Each switches labels (its own ClassView) and +/// polyfill (its bindings) over the shared, classid-free algebra. +const XLOG: u32 = 0x7F01_0001; +const ONNX: u32 = 0x7F03_0001; + +/// XLOG's count `q(Y) :- r(X,Y), s(Y,Z)`: fields r=0, s=1, the counts 2 and 3, +/// the product 4. +fn xlog_count() -> MethodBinding { + MethodBinding { + id: MethodId(1), + sig_rev: SigRev(1), + name: "count q(Y)".to_string(), + on: WideFieldMask::from_positions(&[0, 1]), + when: None, + reads: WideFieldMask::from_positions(&[0, 1]), + effects: vec![ + // R keyed by its y column (1) + Effect { + target: 2, + algebra: uses(GROUP_COUNT), + args: vec![Arg::Field(0), Arg::Const(1)], + }, + // S keyed by its y column (0) + Effect { + target: 3, + algebra: uses(GROUP_COUNT), + args: vec![Arg::Field(1), Arg::Const(0)], + }, + Effect { + target: 4, + algebra: uses(MUL_I64), + args: vec![Arg::Field(2), Arg::Field(3)], + }, + ], + population: Population::Bag, + truth: Truth::TwoValued, + demand: ValueDemand::Values, + } +} + +/// An ONNX `Mul(int64)` node: fields a=0, b=1, y=2. +fn onnx_mul() -> MethodBinding { + MethodBinding { + name: "Mul(int64)".to_string(), + effects: vec![Effect { + target: 2, + algebra: uses(MUL_I64), + args: vec![Arg::Field(0), Arg::Field(1)], + }], + population: Population::Distinct, + ..xlog_count() + } +} + +#[test] +fn consumers_switch_over_shared_classid_free_algebra() { + let mut s = catalogue(); + s.register(XLOG, schema(1, 5), vec![xlog_count()]).unwrap(); + s.register(ONNX, schema(1, 3), vec![onnx_mul()]).unwrap(); + + // One algebra entry, reused by both classids; nothing minted for it. + assert_eq!( + s.users_of(MUL_I64), + vec![(XLOG, MethodId(1)), (ONNX, MethodId(1))] + ); + assert_eq!(s.users_of(GROUP_COUNT), vec![(XLOG, MethodId(1))]); + assert!(s.users_of(ASSIGN).is_empty()); + assert_eq!(s.class_count(), 2, "only the consumers carry classids"); + assert_eq!( + s.algebra(MUL_I64).unwrap().polyfill, + "ndarray::hpc::checked_i64::checked_binary_i64(Mul)" + ); + + // The classid switches the labels: each reads its own lens. + let xv = View::with_fields(view_class(XLOG), 5); + let ov = View::with_fields(view_class(ONNX), 3); + assert_eq!(s.resolve(&xv, XLOG, SchemaRev(1)).unwrap().fields.len(), 5); + assert_eq!(s.resolve(&ov, ONNX, SchemaRev(1)).unwrap().fields.len(), 3); + assert!( + s.resolve(&ov, XLOG, SchemaRev(1)).is_err(), + "not XLOG's lens" + ); +} + +/// An algebra entry is immutable once declared (D5 applied to D6). +#[test] +fn an_algebra_entry_is_immutable() { + let mut s = catalogue(); + let same = entry( + MUL_I64, + "ndarray::hpc::checked_i64::checked_binary_i64(Mul)", + 2, + ); + assert_eq!(s.declare_algebra(same), Ok(())); + assert_eq!( + s.declare_algebra(entry(MUL_I64, "wrapping_mul", 2)), + Err(Refusal::AlgebraImmutable(MUL_I64)) + ); +} + +/// The operands are part of the binding (XLOG's correction), and every effect +/// is checked against the catalogue when its class registers. +#[test] +fn effect_operands_and_entries_are_checked() { + let count = xlog_count(); + let mut twice_r = count.clone(); + twice_r.effects[1].args = vec![Arg::Field(0), Arg::Const(1)]; + assert_ne!(count, twice_r, "operands tell the two counts apart"); + let mut swapped = count.clone(); + swapped.effects[2].args = vec![Arg::Field(3), Arg::Field(2)]; + assert_ne!(count, swapped, "operand order is part of the step"); + + let mut s = catalogue(); + let refused = |s: &mut BindStore, f: &dyn Fn(&mut Effect)| { + let mut m = xlog_count(); + f(&mut m.effects[2]); + s.register(XLOG, schema(1, 5), vec![m]).unwrap_err() + }; + assert_eq!( + refused(&mut s, &|e| e.args = vec![Arg::Field(2)]), + Refusal::ArityMismatch { + class: XLOG, + method: MethodId(1), + algebra: MUL_I64, + expected: 2, + got: 1 + } + ); + assert_eq!( + refused(&mut s, &|e| e.args = vec![Arg::Field(2), Arg::Field(7)]), + Refusal::FieldOutOfBasis { + class: XLOG, + method: MethodId(1), + position: 7 + } + ); + assert_eq!( + refused(&mut s, &|e| e.algebra = uses(AlgebraId(99))), + Refusal::UnknownAlgebra { + class: XLOG, + method: MethodId(1), + algebra: AlgebraId(99) + } + ); + assert_eq!( + refused(&mut s, &|e| e.algebra.sig_rev = SigRev(0)), + Refusal::AlgebraRevisionMismatch { + class: XLOG, + method: MethodId(1), + algebra: MUL_I64, + bound: SigRev(1), + asked: SigRev(0) + } + ); + assert_eq!(s.class_count(), 0, "no refused class was bound"); + assert_eq!(s.register(XLOG, schema(1, 5), vec![count]), Ok(())); +} + +/// The classid × WideFieldMask ergonomics are the fieldview (the Redmine ERB +/// pattern, OGAR `field_view`): the classid picks the lens, the mask selects +/// positions, and each row carries its position as its layout address, with +/// labels from the ClassView. Behaviour is exposed by ordinal only. +#[test] +fn the_fieldview_projects_mask_positions_with_late_labels() { + let mut s = catalogue(); + s.register(XLOG, schema(1, 5), vec![xlog_count()]).unwrap(); + let view = View::with_fields(view_class(XLOG), 5); + let r = s.resolve(&view, XLOG, SchemaRev(1)).unwrap(); + + let mask = WideFieldMask::from_positions(&[4, 0, 9]); + let rows: Vec<(u8, &str, &str)> = r + .field_view(&mask) + .map(|f| (f.position, f.label, f.predicate)) + .collect(); + // Ascending, labels from the lens; 9 is past the basis and selects nothing. + assert_eq!(rows, vec![(0, "f0", "urn:f0"), (4, "f4", "urn:f4")]); + assert_eq!(r.field_view(&WideFieldMask::from_positions(&[])).count(), 0); + assert_eq!(r.action_ordinals().collect::>(), vec![MethodId(1)]); +} + +/// Field meaning through the lens: the fieldview row joined by predicate to +/// the contract's `PropertySpec` (obligation, semantic type, marking). The +/// same pattern yields masks from meaning: a risk mask, an obligation mask. +/// A field with no spec is UNKNOWN and never defaults into a mask. +#[test] +fn meaning_masks_come_from_the_same_fieldview() { + use lance_graph_contract::cam::CodecRoute; + use lance_graph_contract::property::{ + Marking, PropertyKind, PropertySchema, PropertySpec, SemanticType, + }; + static SPECS: [PropertySpec; 3] = [ + PropertySpec::required("urn:f0") + .with_semantic_type(SemanticType::Iban) + .with_marking(Marking::Pii), + PropertySpec::optional("urn:f1", CodecRoute::Passthrough), + PropertySpec::required("urn:f2").with_marking(Marking::Financial), + // urn:f3 is deliberately absent: UNKNOWN meaning. + ]; + let schema = PropertySchema { + entity_type: "order", + properties: &SPECS, + }; + let s = store_with_order(); + let view = View::with_fields(VIEW_CLASS, 4); + let r = s.resolve(&view, ORDER, SchemaRev(1)).unwrap(); + + let rows: Vec<(u8, Option)> = r + .field_meaning(&WideFieldMask::from_positions(&[0, 3]), &schema) + .map(|m| (m.row.position, m.spec.map(|s| s.marking))) + .collect(); + assert_eq!(rows, vec![(0, Some(Marking::Pii)), (3, None)]); + + let risk = r.mask_where(&schema, |s| s.marking >= Marking::Pii); + assert_eq!(risk, WideFieldMask::from_positions(&[0, 2])); + let required = r.mask_where(&schema, |s| s.kind == PropertyKind::Required); + assert_eq!(required, WideFieldMask::from_positions(&[0, 2])); + // Falsifier: a default spec would be Internal; the unspecified field 3 + // must still not join a mask that Internal satisfies. + let low = r.mask_where(&schema, |s| s.marking <= Marking::Internal); + assert_eq!(low, WideFieldMask::from_positions(&[1])); + assert_eq!(r.unspecified(&schema), WideFieldMask::from_positions(&[3])); +} + +/// The recipe's order must be what it says: a step reads only declared reads +/// or what an earlier step wrote. XLOG's count with the multiply moved first +/// reads the counts before they exist and is refused. +#[test] +fn a_step_cannot_read_what_no_earlier_step_wrote() { + let mut s = catalogue(); + let mut mul_first = xlog_count(); + mul_first.effects.rotate_right(1); + assert_eq!( + s.register(XLOG, schema(1, 5), vec![mul_first]), + Err(Refusal::UndeclaredRead { + class: XLOG, + method: MethodId(1), + effect: 0, + position: 2 + }) + ); + // A read of a field outside `reads` and never written is refused too. + let mut stray = xlog_count(); + stray.effects[0].args = vec![Arg::Field(4), Arg::Const(1)]; + assert_eq!( + s.register(XLOG, schema(1, 5), vec![stray]), + Err(Refusal::UndeclaredRead { + class: XLOG, + method: MethodId(1), + effect: 0, + position: 4 + }) + ); + // The faithful order binds. + assert_eq!(s.register(XLOG, schema(1, 5), vec![xlog_count()]), Ok(())); +} + +/// Meaning reaches behaviour: a method's footprint (ON, reads, writes) +/// intersected with a risk mask says which actions touch risk. +#[test] +fn a_risk_mask_reaches_the_methods_that_touch_it() { + let mut s = catalogue(); + s.register(XLOG, schema(1, 5), vec![xlog_count()]).unwrap(); + let view = View::with_fields(view_class(XLOG), 5); + let r = s.resolve(&view, XLOG, SchemaRev(1)).unwrap(); + + assert_eq!( + r.methods[0].footprint(), + WideFieldMask::from_positions(&[0, 1, 2, 3, 4]) + ); + // A mask over the product (written) and one over the inputs (read) both + // reach the count; a mask over nothing it touches reaches nothing. + for m in [[4u8], [0u8]] { + let ids: Vec = r + .methods_touching(&WideFieldMask::from_positions(&m)) + .map(|m| m.id) + .collect(); + assert_eq!(ids, vec![MethodId(1)]); + } + assert_eq!( + r.methods_touching(&WideFieldMask::from_positions(&[200])) + .count(), + 0 + ); +}