From ea2a37890f419b3adabbf5e32c5f27df8b7dc82f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 26 May 2026 20:23:25 +0000 Subject: [PATCH 1/9] fix(callcenter): with_jsonl_audit returns Result The jsonl-feature build broke: JsonlAuditSink::new returns Result<_, AuditError>, but with_jsonl_audit was typed io::Result and no From for io::Error exists, so the `?` couldn't convert (E0277). The default-feature check skips this path; CI builds the feature and surfaced it. Return the honest error type (Result) rather than adding a crate-wide AuditError -> io::Error coercion. Zero callers depend on the old io::Result signature (only a doc-comment mention), so this breaks nothing. cargo check/test -p lance-graph-callcenter --features jsonl clean (137 tests). --- crates/lance-graph-callcenter/src/unified_bridge.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/lance-graph-callcenter/src/unified_bridge.rs b/crates/lance-graph-callcenter/src/unified_bridge.rs index 8d9e63dd..7af52a41 100644 --- a/crates/lance-graph-callcenter/src/unified_bridge.rs +++ b/crates/lance-graph-callcenter/src/unified_bridge.rs @@ -312,7 +312,7 @@ impl UnifiedBridge { super_domain: SuperDomain, salt: u64, base_path: impl Into, - ) -> std::io::Result { + ) -> Result { let sink = Arc::new(crate::audit_sink::JsonlAuditSink::new(base_path.into())?); Ok(self.with_audit_chain(super_domain, salt, sink)) } From f3d62f5d227c2ef131f3ac079335b3852147ebb3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 27 May 2026 07:15:29 +0000 Subject: [PATCH 2/9] docs(tech-debt): document AriGraph episodic-fidelity options + JSON serialization sites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two PREPEND entries to TECH_DEBT.md: - TD-ARIGRAPH-EPISODIC-FIDELITY-1: the Python→Rust AriGraph transcode reimplemented episodic retrieval as the fingerprint-RAG baseline the paper (arxiv 2407.04363) outperforms — eq.1 structural relevance and the episodic edge E_e (the dropped witness_ref/W-slot) are absent; three disconnected episodic/provenance stores exist. Options documented: A (narrow in-place eq.1 fix) vs B (mailbox/W-slot convergence = the already-planned D-CSV-6 WitnessCorpus + D-CSV-7 MailboxSoA W-slot). - TD-JSON-SERIALIZATION-SITES-1: catalogs serde/JSON occurrences under the single-binary no-serialization invariant — outer-boundary ingestion (Cypher/AST, config, JWT, REST, query params) is correct by design; the debt is serde-on-substrate (arigraph orchestrator/sensorium/TruthValue) and JSON audit egress (jsonl_sink + lance_sink JSON-in-column), whose canonical form is the binary canonical_bytes. Reframes TD-SDR-AUDIT-PERSIST-1. --- .claude/board/TECH_DEBT.md | 75 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/.claude/board/TECH_DEBT.md b/.claude/board/TECH_DEBT.md index eb28ee29..d8cc96a4 100644 --- a/.claude/board/TECH_DEBT.md +++ b/.claude/board/TECH_DEBT.md @@ -233,6 +233,81 @@ filter discipline — agents pull their own debt by `@`-mention. (Seeded with known deferrals from recent PRs. New items PREPEND with today's date.) +## 2026-05-27 — TD-ARIGRAPH-EPISODIC-FIDELITY-1: AriGraph episodic retrieval was transcoded as the RAG baseline the paper beats, not the paper's structural search + +**Status:** Open +**Priority:** P1 (the transcoded substrate silently behaves as the baseline AriGraph outperforms; correctness-of-port, not a crash) +**Scope:** crate:lance-graph domain:arigraph domain:retrieval D-CSV-6 D-CSV-7 +**Introduced by:** the Python→Rust AriGraph transcode (`crates/lance-graph/src/graph/arigraph/`, per `.claude/knowledge/integration-plan-grammar-crystal-arigraph.md` E11) +**Payoff estimate:** Option A ~150-250 LOC in-place; Option B = D-CSV-6 (~600) + D-CSV-7 (~350), substrate change + +### What (ground truth: arxiv 2407.04363 §2 + Alg.1 + eq.1) + +The paper's world model is `G = (V_s, E_s, V_e, E_e)`: semantic vertices/edges (triplets) + episodic vertices (observations) + **episodic edges** `e^t_e = (v^t_e, E^t_s)` linking each observation to the triplets extracted at that step. Retrieval is **two-stage and structural**: semantic search returns `E^Q_s`; episodic search then scores each episode by triplet-incidence `rel(v^i_e) = (n_i / max(N_i,1)) · log(max(N_i,1))` (single-triplet observations weighted 0). + +The transcode diverged on **both** stages: +- `episodic.rs::EpisodicMemory::top_k` ranks episodes by **Hamming distance between observation fingerprints** (`label_fp`) — RAG-style similarity on raw observation text, *decoupled* from the semantic hits. The structural `n_i/N_i` relevance is absent. +- `retrieval.rs::OsintRetriever` semantic search is exact `entity_index` **name** lookup + BFS (no embedding retrieval; loses the paper's "grill"→"grilling" generalization). +- The **episodic edge `E_e` does not exist as a structure**: the transcoded `triplet_graph.rs` `Triplet` is `{subject, object, relation, truth, timestamp}` — the W5-spec `witness_ref: u64` (the W-slot) was **dropped**. `Episode.triplets: Vec` is used only for unbundle/rebundle, never retrieval. +- Net: **three disconnected episodic/provenance representations** — `episodic.rs` (fingerprint-RAG), `witness_corpus.rs` (`WitnessIndexHashMap` spo→positions + `WitnessIndexCamPq`, wired to neither Triplet nor episodic), and the dropped W-slot. + +### Options (types kept in both; this is a mechanism fix, not a deletion — "fix from the beginning") + +- **Option A — narrow in-place eq.1 fix.** Add a triplet-id link from `Episode` to `E_s`; couple episodic search to consume `E^Q_s`; replace `top_k` fingerprint scoring with eq.1 incidence relevance (zero-weight single-triplet episodes). Keeps `Episode`/`EpisodicMemory`. Lower risk, no substrate change. **Leaves `witness_corpus.rs` still disconnected** (its own residual debt). Semantic-search embedding generalization still open. +- **Option B — mailbox / W-slot convergence (recommended end-state; = existing plan).** Restore the W-slot on `Triplet`/`CausalEdge64` (v2 layout `[53:58]`, the "discourse corpus-root handle"); make `witness_corpus.rs` (already CAM-PQ + HashMap indexed) THE episodic store = the per-`MailboxId` "spatial-temporal meaning accumulator" (`contract::collapse_gate::MailboxId`); retire `episodic.rs`'s fingerprint-RAG. The episodic edge `E_e` = `triplet.W-slot → MailboxId`; eq.1 `n_i/N_i` falls out of `WitnessIndexHashMap::lookup(spo)` incidence. Collapses the three stores into one and stays serialization-free ("the `(source_mailbox, chain_position)` tuple is the wire"). **This is `cognitive-substrate-convergence-v1` D-CSV-6 (`WitnessCorpus`) + D-CSV-7 (`MailboxSoA` W-slot)** — already planned, HIGH risk, gated on the CSV OQ ratification. + +**Recommendation:** B is the architecturally-correct convergence and is already the planned direction; A is a legitimate interim that makes retrieval *faithful to the paper* without waiting on the substrate change, at the cost of leaving the `witness_corpus.rs` duplication for B to collapse later. + +### Cross-references + +- Paper source: arxiv 2407.04363 §2 "AriGraph World Model" + Alg.1 (Memory Graph Search) + eq.1 (episodic relevance). +- `crates/lance-graph/src/graph/arigraph/{episodic.rs, retrieval.rs, triplet_graph.rs, witness_corpus.rs}` +- `.claude/plans/cognitive-substrate-convergence-v1.md` D-CSV-6 (`WitnessCorpus` replaces `SpoWitnessChain<32>`) + D-CSV-7 (`MailboxSoA` W-slot) + §6 v2 layout `[53:58]` W slot +- `.claude/knowledge/spo-schema-and-mailbox-sidecar.md` (SPO-W tetrahedron; `MailboxId` = meaning accumulator); `.claude/knowledge/integration-plan-grammar-crystal-arigraph.md` E11 (transcode provenance) + +--- + +## 2026-05-27 — TD-JSON-SERIALIZATION-SITES-1: JSON/serde occurrences catalogued; internal-substrate serde is debt, outer-boundary ingestion is not + +**Status:** Open +**Priority:** P2 (no crash; violates the single-binary no-serialization invariant where it occurs internally) +**Scope:** crate:lance-graph crate:lance-graph-callcenter domain:serialization domain:invariant +**Introduced by:** AriGraph transcode (serde-on-substrate) + D-SDR-4/5 audit sinks (JSON egress) +**Payoff estimate:** substrate serde-derive strip ~per-file small; audit JSON→binary canonical_bytes is the larger item (tracked separately) + +### The invariant + +lance-graph compiles every "program" into **one statically-linked binary** — there is no internal IPC/network boundary, so serialization between in-binary parts is meaningless. The rule (BOOT.md #6): **"No JSON serialization in types. Serde stays debug-only."** Serialization is legitimate ONLY at the **outer ingestion boundary** — post-compile input that must be parsed: files / REST / a query language / external tokens. JSON is excluded everywhere else because the canonical bytes (`canonical_bytes()` / Arrow columns / the CAM bar-code) *are* the value; JSON would be a redundant second representation. + +### Acceptable — outer-boundary ingestion (serde correct by design, NOT debt) + +| Site | Boundary | +|---|---| +| `lance-graph/src/{ast.rs, logical_plan.rs}` | Cypher text → AST → plan IR feeding DataFusion (cold-path parse) | +| `lance-graph/src/parameter_substitution.rs` | `HashMap` query params (post-compile input) | +| `lance-graph/src/config.rs` | TOML config load at startup | +| `lance-graph-callcenter/src/auth.rs` | JWT claims (`serde_json::from_slice`) — JWT is base64url-JSON by RFC 7519 | +| `lance-graph-catalog/src/unity_catalog.rs` | Databricks Unity Catalog REST (external service) | +| `cognitive-shader-driver/src/wire.rs`, `lance-graph-callcenter/src/postgrest.rs`, `*/serve.rs` | post-compile REST ingestion points (lab/research surface per `lab-vs-canonical-surface.md`) | +| `lance-graph-contract/src/literal_graph.rs::ingest_aiwar_json` | physical parser for an external `.json` data file (zero-dep, hand-rolled) | + +### Debt — internal / substrate / egress (no boundary; violates the invariant) + +1. **serde derived on AriGraph cognitive substrate types** — `graph/arigraph/orchestrator.rs` (`MetaOrchestrator`, `StyleTopology`, `TopologyEdge`, `MulAssessment`, `DkPosition`, `TrustTexture`, `FlowState`, `GraphSensorium`, …) and `graph/arigraph/sensorium.rs` (`GraphSensorium`/`GraphBias`/`HealingAction`/`HealingType`, also intra-crate-duplicated with orchestrator.rs) + `graph/spo/truth.rs::TruthValue`. These are hot-path substrate (transcode cruft from the Python source's dicts). The only legitimate egress is the `/mri` `OrchestratorSnapshot`/`TopologyEdgeSnapshot` DTOs — serde belongs **only** on those boundary DTOs, stripped from the core types. (CONJECTURE: the `/mri` HTTP handler that serializes the snapshot was not located this session; confirm before stripping.) +2. **Audit log emitted as JSON** — `lance-graph-callcenter/src/audit_sink/jsonl_sink.rs` (JSON lines) and `lance_sink.rs:151` (a JSON string stuffed into an Arrow column), read back by `bin/audit_verify.rs`; reachable via `UnifiedBridge::with_jsonl_audit`. The audit event's canonical form is already the 26-byte binary `UnifiedAuditEvent::canonical_bytes()` that the merkle chain hashes — JSON is a redundant second representation. Canonical egress should be the binary append-log or typed Arrow columns, not JSON. **Reframes TD-SDR-AUDIT-PERSIST-1** (which treats the JSONL sink as owed *work*; under this invariant the JSON form itself is the debt). + +`serde_json` is an **optional** dep gated behind `jsonl`/`realtime`/`auth-jwt`/`lance-sink`; the default callcenter build pulls zero JSON. + +### Cross-references + +- `BOOT.md` #6 (serde-out-of-types); `.claude/knowledge/integration-plan-grammar-crystal-arigraph.md:166` ("serde kept out of types by project convention") +- `TD-SDR-AUDIT-PERSIST-1` (the JSONL-sink-as-deliverable entry this reframes) +- `crates/lance-graph-callcenter/src/audit_sink/{jsonl_sink.rs, lance_sink.rs}`, `bin/audit_verify.rs`, `unified_bridge.rs::with_jsonl_audit` +- `crates/lance-graph/src/graph/arigraph/{orchestrator.rs, sensorium.rs}`, `graph/spo/truth.rs` + +--- + + ## 2026-05-13 — TD-Q2-STUBS-DEDUP-1: q2 carries local `lance-graph` + `q2-ndarray` stubs that must be replaced with re-exports from the canonical crates before FMA demo can compile **Status:** Open From dbd4fed0942071397a70797e6af84be350f5fd21 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 27 May 2026 11:06:42 +0000 Subject: [PATCH 3/9] =?UTF-8?q?docs(plans):=20multi-server-cognition-expan?= =?UTF-8?q?sion-v1=20=E2=80=94=20kill=20the=20legacy-stack=20argument=20+?= =?UTF-8?q?=20expansion=20path?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New plan documenting why the Rust/Raft/lance stack displaces JanusGraph, Cassandra, and Zitadel for multi-server HA/scale-out, plus the conditional expansion architecture: - Displacement: distributed graph + CP via SurrealDB-on-TiKV + lance-graph (vs JanusGraph's JVM 3-system assembly); Cassandra's AP is the wrong consistency model for a belief substrate; authN/IdP gap filled by Ory (Kratos + Hydra), binary stays verification-only. - Multi-server: Raft replicates the belief-delta / episodic LOG; the zero-copy SoA is the per-node state machine; the Rubikon commit_gate is the Raft append point. Resolves TiKV-vs-lance as TiKV-the-log UNDER lance-the-state. - Hard prerequisite (unproven): byte-deterministic NARS apply (reencode_safety / D-SDR-26); next deliverable is the determinism probe. PROPOSAL/expansion-readiness; single-node needs none of it. Indexed in INTEGRATION_PLANS.md. --- .claude/board/INTEGRATION_PLANS.md | 13 ++ .../multi-server-cognition-expansion-v1.md | 130 ++++++++++++++++++ 2 files changed, 143 insertions(+) create mode 100644 .claude/plans/multi-server-cognition-expansion-v1.md diff --git a/.claude/board/INTEGRATION_PLANS.md b/.claude/board/INTEGRATION_PLANS.md index b1497d55..fe1745d7 100644 --- a/.claude/board/INTEGRATION_PLANS.md +++ b/.claude/board/INTEGRATION_PLANS.md @@ -1,3 +1,16 @@ +## 2026-05-27 — multi-server-cognition-expansion-v1 (legacy-stack displacement + Raft-log / SoA-state-machine) + +**Status:** PROPOSAL — §2 displacement is current-state; §3 multi-server expansion is unbuilt, gated on the §4 determinism probe +**Confidence:** HIGH vs JanusGraph + Cassandra; Zitadel displaced by Ory (Kratos+Hydra) for authN; HIGH the log-replicated-SoA shape is correct; LOW it is proven (determinism unproven) +**Plan file:** `.claude/plans/multi-server-cognition-expansion-v1.md` +**Predecessors:** `cognitive-substrate-convergence-v1` (belief-delta log = the episodic arc); `surrealdb/core/src/kvs/lance` (the LSM-on-lance engine the WAL-as-Raft-log rides) + +### Scope + +Kills the "for multiple servers we need JanusGraph / Cassandra / Zitadel" argument. (1) Component displacement: distributed graph + CP via SurrealDB-on-TiKV + lance-graph; Cassandra's AP is the wrong consistency model for a belief substrate; **Ory** (Kratos+Hydra) fills the one real gap (authN/IdP), binary stays verification-only. (2) Multi-server path: Raft replicates the **belief-delta / episodic LOG**, the zero-copy SoA is the per-node **state machine**, the Rubikon `commit_gate` is the Raft **append point**; TiKV-the-log sits *under* lance-the-state (not "instead of"). Hard prerequisite: byte-deterministic NARS apply (`reencode_safety` / D-SDR-26) — **unproven**; next deliverable is the determinism probe, not more synthesis. authN (Ory) is orthogonal to the consensus layer. + +--- + ## 2026-05-15 — cognitive-substrate-convergence-v1 (CSV — i4 mantissa + gapless baton + active inference) **Status:** Active (PROPOSAL — awaits OQ-CSV-1..6 ratification before sprint-11 D-CSV-* spawn) diff --git a/.claude/plans/multi-server-cognition-expansion-v1.md b/.claude/plans/multi-server-cognition-expansion-v1.md new file mode 100644 index 00000000..0e02fb38 --- /dev/null +++ b/.claude/plans/multi-server-cognition-expansion-v1.md @@ -0,0 +1,130 @@ +# Multi-Server Cognition — Legacy-Stack Displacement + Expansion Readiness (v1) + +> **Status:** PROPOSAL — rationale + expansion-readiness. NOT committed deliverables. +> §2 (displacement) is current-state reasoning; §3 (multi-server) is a design +> hypothesis that follows from the invariants but is **unbuilt**; §4 (determinism) +> is a real, **unproven** prerequisite. +> **Purpose:** Kill the "for multiple servers we need JanusGraph / Cassandra / +> Zitadel" argument, and record the conditional path to multi-server HA/scale-out +> that does **not** require them. +> **Confidence:** HIGH vs JanusGraph + Cassandra; vs Zitadel — displaced by **Ory** +> (Kratos+Hydra) for authN, §5; HIGH that the log-replicated-SoA shape is +> *correct*; LOW that it is *proven* (gated on §4). + +--- + +## 1. The argument being killed + +"For multiple servers / HA / scale-out we should adopt a proven distributed +system" → reaches for **JanusGraph** (distributed graph), **Cassandra** +(wide-column), or **Zitadel** (identity, on Cockroach-Raft). Each drags a +heavyweight external service + its own consistency model + a JVM/Go runtime into +a stack whose entire premise is a **single Rust binary** with an in-binary, +zero-copy, **no-serialization** cognitive substrate. + +## 2. Component-by-component displacement + +| Legacy | Reached for | Covered by | Verdict | +|---|---|---|---| +| **JanusGraph** | distributed graph = Gremlin + Cassandra/HBase + ES/Solr | SurrealDB graph + lance-graph SoA/SIMD + Cypher/Gremlin/SPARQL front-ends on a Raft KV | **Displaced.** Replaces a JVM 3-system assembly with one Rust stack + CP consistency. Its only edge is proven billion-edge cluster scale — not this regime. | +| **Cassandra** | scale-out wide-column store | SurrealDB-on-TiKV (Raft, CP, transactional) | **Displaced on the right axis.** A belief/memory substrate wants CP (read-your-writes, no last-write-wins clobber), not Cassandra's AP/eventual. Cassandra wins only at extreme multi-DC write-availability under partition — not needed. | +| **Zitadel** | identity / OIDC IdP (runs on Cockroach-Raft) | authZ — RBAC `Policy` + `TenantId` Chinese-wall + JWT verify + merkle audit — **already in-stack**. authN/IdP — **Ory** (decided, §5) | **Displaced by Ory, not by Raft.** Raft was the wrong axis: the value is IdP *behavior*, not storage Raft. **Ory Kratos+Hydra** (composable, headless) fills authN; binary stays verification-only. See §5. | +| **TiKV** | — | (it IS the Raft engine — the displacement *mechanism*, not a thing displaced) | Role = the replicated **log** tier (§3). It is "under lance," not "instead of lance" — see §3. | +| **Databend** | — | — | Orthogonal. Cold analytics edge only (export-fed; its Raft replicates only its own metadata). Not in the multi-server path. | + +## 3. The multi-server expansion architecture + +"Multiple servers" is a replication seam, and a replication seam is a +serialization boundary — which the no-serialization invariant forbids in the +**hot** path. So the seam goes where serialization is already legal (the +durable/egress tier), **never** on the hot SoA. + +**Replicate the log, not the SoA:** + +- Raft replicates the **episodic / belief-delta log** — append-only, ordered, + content-addressed; each entry = one committed belief delta. +- Each server keeps its **own local zero-copy SoA**, rebuilt by *applying* the + log. SoA = the Raft **state machine**; the AriGraph **Witness episodic arc = + the Raft log**; the Markov belief-chain = the state machine it drives. + +This dissolves the apparent "TiKV **or** lance" choice at the KVS layer — a false binary: + +- **TiKV/Raft replicates the *log*** (committed belief-deltas; cross-node consensus). +- **lance materializes the *state* locally** per node (zero-copy SoA, rebuilt from applied entries). +- → TiKV-the-log **under** lance-the-state. Both present, different roles. + +**Maps onto the existing `surrealdb/core/src/kvs/lance` engine:** the **WAL is the +replication unit** (Raft-replicate the WAL); `memtable` + the lance dataset are +the per-node materialized state followers rebuild. The structure is already +shaped for this. + +**Rubikon = the append point.** `commit_gate.rs` is where a candidate belief +becomes committed → that is the Raft append. The 550 ms candidate cloud stays +**node-local, uncommitted, un-replicated** (no consensus cost); only committed +belief-deltas pay the Raft round-trip. Consensus load is bounded to **real +commits**, not every thought-spark. (Libet/Heckhausen admission control = the +gate; the gate = the consensus boundary.) + +## 4. Hard prerequisite — deterministic apply (the unproven part) + +Same log → same SoA on every node **only if** applying a belief-delta is +byte-deterministic across machines. NARS truth revision is f32 math, and +floating-point **non-associativity** can make two nodes diverge from an +identical log. So multi-server convergence **depends on** the determinism +discipline the repo already prizes — `thinking-engine::reencode_safety` (x256 +byte-determinism) + the D-SDR-26 determinism rule. This is the **precondition**, +not a side-quest. + +- Log entries are the **binary `canonical_bytes` / `CausalEdge64`** form — never + JSON. Keeps the no-serialization invariant intact: hot path zero-copy, + replication log in canonical binary. +- **CONJECTURE (NOT PROVEN):** the full NARS/truth apply path is byte-deterministic + across architectures. Per the `bf16-hhtl-terrain` probe-queue discipline, the + next deliverable here is the **determinism probe**, not more synthesis. + +## 5. The one genuinely separate decision — authN — **DECIDED: Ory** + +The storage of identity isn't the gap (it's just another tenant-partitioned +table); the gap is IdP *behavior* (issue tokens, login, MFA, SSO/OIDC). This was +always **orthogonal to Raft**, and the decision is **Ory** — the composable, +headless option, deliberately *not* Zitadel's all-in-one: + +- **Ory Hydra** = OAuth2 / OIDC server → token **issuance** (the piece the Raft + stack structurally lacks). +- **Ory Kratos** = identity lifecycle → login, registration, MFA, recovery, user store. +- **Ory Keto** = **not adopted** — authZ stays in-stack (`Policy` RBAC + `TenantId` + Chinese-wall + merkle audit); Keto's Zanzibar model would duplicate it. + +**Integration:** Ory runs the IdP (its own Postgres/Cockroach persister); the +binary stays a **verification-only consumer** — `auth.rs` already verifies the +JWTs Hydra issues. authN therefore lives entirely **at the request boundary, +never in the belief-delta hot loop**, and touches **none** of the §3 consensus +layer. The legacy "Zitadel-or-build-it" fork collapses: Ory (Kratos+Hydra) fills +authN, the in-stack authZ is untouched. + +**Open (low-risk default = standalone):** Ory doesn't natively persist to +SurrealDB, so its store stays standalone Postgres/Cockroach rather than riding +the shared replicated log. Revisit only if a SurrealDB persister is ever worth writing. + +## 6. Phasing (only if/when multi-server is actually needed) + +Single-node (Stefan-style) needs **none** of this — local lance, no Raft, no +TiKV. The expansion is a readiness path, sequenced: + +1. **Determinism proof** (§4) — byte-deterministic NARS apply probe. Gate for everything below. +2. **WAL-as-Raft-log** — promote the `kvs/lance` WAL to a Raft-replicated log; followers apply to local lance + memtable. +3. **Commit-gate = append point** — wire `commit_gate.rs` to the Raft append; pre-Rubikon stays node-local. +4. **Follower-read semantics** — local SoA may lag the leader; route CP reads to leader or wait-for-apply. +5. **authN decision** (§5) — independent; only if external auth is needed. + +## Cross-references + +- `surrealdb/core/src/kvs/lance/{wal.rs, memtable.rs, flusher.rs, commit_gate.rs, schema.rs}` — the LSM-on-lance engine this rides (schema is opaque-KV today; typed columns deferred). +- `.claude/plans/cognitive-substrate-convergence-v1.md` — D-CSV-6/7 (WitnessCorpus + MailboxSoA W-slot); the belief-delta log is the episodic arc those build. +- `.claude/board/TECH_DEBT.md` — TD-ARIGRAPH-EPISODIC-FIDELITY-1 (the episodic arc / Witness), TD-JSON-SERIALIZATION-SITES-1 (canonical_bytes vs JSON on the log). +- `thinking-engine::reencode_safety` + D-SDR-26 — the determinism prerequisite (§4). +- `CLAUDE.md` — AGI-as-glove (the SoA *is* the substrate), I-SUBSTRATE-MARKOV (the Markov state machine), the no-serialization invariant. + +--- + +*Authored 2026-05-27. PROPOSAL — §2 displacement is current-state; §3 multi-server expansion is gated on the §4 determinism probe.* From db021aa1e20a4c09144eb175e276966cd11bcd1b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 27 May 2026 11:28:59 +0000 Subject: [PATCH 4/9] =?UTF-8?q?docs(board):=20record=20ea2a378=20with=5Fjs?= =?UTF-8?q?onl=5Faudit=20fix=20=E2=80=94=20EPIPHANIES=20E-AUDIT-1=20+=20PR?= =?UTF-8?q?=5FARC=20entry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the board-hygiene gap: the May 26 signature fix (io::Result -> AuditError, form 1 / W2's instinct) shipped touching only the .rs file; the board still recorded only PR #366's introduction of the constructor. E-AUDIT-1 captures the generalizable lesson (default-feature cargo check masks feature-gated E0277s; error-type honesty over lossy From-coercion). https://claude.ai/code/session_017gZ6sPRXYPj5n7uJ7NBtRv --- .claude/board/EPIPHANIES.md | 14 ++++++++++++++ .claude/board/PR_ARC_INVENTORY.md | 12 ++++++++++++ 2 files changed, 26 insertions(+) diff --git a/.claude/board/EPIPHANIES.md b/.claude/board/EPIPHANIES.md index 09ee6539..662809c2 100644 --- a/.claude/board/EPIPHANIES.md +++ b/.claude/board/EPIPHANIES.md @@ -1,3 +1,17 @@ +## 2026-05-27 — E-AUDIT-1 — `with_jsonl_audit` jsonl-feature build break: a default-feature `cargo check` masks feature-gated error-type mismatches + +**Status:** FINDING + +**Click:** `UnifiedBridge::with_jsonl_audit` (added in PR #366 as the OQ-7-3 opt-in constructor, `#[cfg(feature = "jsonl")]`) was typed `-> std::io::Result`, but its body is `JsonlAuditSink::new(...)?` and `JsonlAuditSink::new -> Result`. `AuditError` carries `Io(#[from] std::io::Error)` (the io→AuditError direction) but there is **no** reverse `From for std::io::Error`, so the `?` could not coerce `AuditError` into the declared `io::Error` return — E0277. The **default-feature `cargo check` skips this path entirely** (the constructor is feature-gated), so the break only surfaced when CI built `--features jsonl`. + +**Fix (commit `ea2a378`, branch `claude/activate-lance-graph-att-k2pHI`):** one line — return the honest error type `Result`. Two equivalent ~3-line forms existed: (1) change the return type to `Result` [taken — "W2's instinct"]; (2) add a crate-wide `impl From for std::io::Error` and keep the `io::Result` signature. Form 1 chosen: the form-2 coercion would lossily flatten `ChannelFull` / `Serialize` / `SchemaMigration` / `Lance` / `Arrow` into `io::Error::other`, lying about the failure class for every future caller. Zero callers depended on the old signature (grep across all cloned repos: only the def + one doc-comment mention), so the signature change broke nothing. `cargo check/test -p lance-graph-callcenter --features jsonl` clean (137 tests). + +**Lesson (generalizes):** a `#[cfg(feature = …)]` fn whose body uses `?` across two error types is invisible to a default-feature `cargo check`. Any crate with optional-feature error paths needs a CI matrix that builds each feature (or `--all-features`), else these E0277s ship to whoever first enables the feature — here, the still-queued MedCare-rs sprint-2 item 5 that consumes this constructor. And: error-type honesty beats `From`-coercion convenience — return the real domain error rather than a lossy `into()` to a narrower std type. + +**Cross-ref:** PR #366 (constructor introduction, OQ-7-2/7-3 locks); `audit_sink/mod.rs::AuditError`; TD-SDR-AUDIT-PERSIST-1 in TECH_DEBT.md (the JSON-as-debt reframe — orthogonal to this signature fix). + +--- + ## 2026-05-16 — E-SIMD-SWEEP-1 — PR #398 was the 5th violation, not the first; the SIMD source-of-truth invariant is retroactive **Status:** FINDING diff --git a/.claude/board/PR_ARC_INVENTORY.md b/.claude/board/PR_ARC_INVENTORY.md index 80859888..7e99c1c6 100644 --- a/.claude/board/PR_ARC_INVENTORY.md +++ b/.claude/board/PR_ARC_INVENTORY.md @@ -35,6 +35,18 @@ --- +## callcenter/audit-fix — fix(callcenter): `with_jsonl_audit` returns `Result` (branch work) + +**Status:** On branch `claude/activate-lance-graph-att-k2pHI` (HEAD `ea2a378`, not yet a PR). 1-line `.rs` change + this board record (EPIPHANIES E-AUDIT-1, prepended 2026-05-27). + +- **Added** — nothing new; retypes `UnifiedBridge::with_jsonl_audit` return from `std::io::Result` → `Result` (`unified_bridge.rs:315`). Resolves an E0277 that only the `--features jsonl` build surfaced (the default `cargo check` skips the feature-gated path). +- **Locked** — audit constructors return the **domain** error (`AuditError`), not `io::Result`. **No** crate-wide `From for std::io::Error` coercion (rejected: lossy across the non-`Io` variants `ChannelFull`/`Serialize`/`SchemaMigration`/`Lance`/`Arrow`). Optional-feature error paths must be CI-checked under their feature, not just default-feature (E-AUDIT-1). +- **Deferred** — none. MedCare-rs sprint-2 item 5 (first real caller) consumes the `AuditError` signature directly; any caller needing `io::Result` interop adds a local `map_err`, not a crate-wide `From`. +- **Docs** — EPIPHANIES E-AUDIT-1. +- **Confidence (2026-05-27):** working — `cargo check/test -p lance-graph-callcenter --features jsonl` clean at commit time (137 tests); tree clean; zero callers depend on the old signature. + +--- + ## sprint-13/W-I1 — impl(sprint-13): D-CSV-13b i4 batch SIMD dispatch + tests (in PR) **Status:** In PR (branch `claude/sprint-13-w-i1-salvage`, HEAD `c9c1c79`, awaiting user merge). 4 commits on the branch: `cdc84ec` salvage W-I1 i4_eval::batch impl + criterion scaffold (recovered from cleaned worktree) → `a356e64` SIMD-vs-scalar parity tests + repr(u8) enum invariant (5 new randomised tests over 10 sizes, criterion 0.5 dev-dep, dead-code warning fix) → `d8d1437` AVX-512 dim-extract sign-extend fix (the bug that made the salvage path silently produce wrong bytes on negative thresholds) → `c9c1c79` `scalar_impl` made `#[doc(hidden)] pub` for bench access. From b526d9cf5e7d4b264e6556cb74079a85bad66349 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 27 May 2026 21:00:25 +0000 Subject: [PATCH 5/9] feat(callcenter): re-export rbac Policy + smb_policy through the facade UnifiedBridge::new takes an Arc, but some consumers are barred from a direct lance-graph-rbac dependency (e.g. woa-rs's BBB-barrier allow-list is contract / ontology / callcenter only). Surface the Policy type and the smb_policy() starter factory through the callcenter facade so those consumers can build a UnifiedBridge without naming rbac directly. lance-graph-rbac is already an internal dependency here, so this is a zero-cost re-export. https://claude.ai/code/session_017gZ6sPRXYPj5n7uJ7NBtRv --- crates/lance-graph-callcenter/src/lib.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/crates/lance-graph-callcenter/src/lib.rs b/crates/lance-graph-callcenter/src/lib.rs index 903c42d5..a8c50390 100644 --- a/crates/lance-graph-callcenter/src/lib.rs +++ b/crates/lance-graph-callcenter/src/lib.rs @@ -150,6 +150,13 @@ pub use unified_bridge::{ AuthError, BridgeConfig, BridgeHandle, OgitFamily, OwlIdentity, TenantId, UnifiedBridge, }; +// Re-export the RBAC `Policy` surface that `UnifiedBridge::new` requires, so +// consumer crates barred from a direct `lance-graph-rbac` dependency (e.g. +// woa-rs's BBB-barrier: allow-list is contract / ontology / callcenter only) +// can still construct a `UnifiedBridge` through the callcenter facade alone. +// `lance-graph-rbac` is already an internal dependency of this crate. +pub use lance_graph_rbac::policy::{smb_policy, Policy}; + // D-SDR-2 (super-domain-rbac-tenancy-v1 §3.4-§3.7) — SuperDomain layer. // Activation root above OGIT basins (1 byte; 8 starter values; 256 cap) // plus MetaAnchors (Foundry/OWL/DOLCE/Wikidata cross-walks), ComplianceRegime From 26ba8731a36497d590f6c23f73da8459a313a3c3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 27 May 2026 22:18:48 +0000 Subject: [PATCH 6/9] =?UTF-8?q?feat(callcenter):=20D-ODOO-SAV-4=20?= =?UTF-8?q?=E2=80=94=20odoo-savant=20Reasoner=20layer=20(4=20impls=20per?= =?UTF-8?q?=20ReasoningKind)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement the lance-graph "thinking" side of the 25-savant delegation: SavantConclusion (suggestion-only, serde-free) + CustomerCategoryReasoner / PostingAnomalyReasoner / NextBestActionReasoner / OtherReasoner — the pinned one-impl-per-ReasoningKind dispatch (PR #419) covering all 25 savants in contract::savants::SAVANTS. Each reasoner resolves the concrete savant from (kind, namespace), selects its QueryStrategy via InferenceType::default_strategy(), and fuses evidence-ref coverage into a NARS (frequency, confidence). woa-rs consumes the conclusion as a native shared type (one-binary contract) and keeps the deterministic AXIS-A guard; this is the ambiguous AXIS-B core only. Dispatch resolution lives in callcenter — the contract stays an untouched inheritance vow (no namespace field on Savant). resolve_savant filters the roster by kind; ambiguous kinds split via DISPATCH_NS (the Other(RECONCILE_MATCH) 19-vs-21 namespace split per #419) then by namespace == savant.name. No serialization in scope: SavantConclusion is a plain in-binary value; JSON exists only at the callcenter<->MedCareV2 FFI boundary. 8 new tests; 137 prior callcenter tests pass; zone_serialize_check no-JSON guard clean. AGENT_LOG updated (board-hygiene). https://claude.ai/code/session_017gZ6sPRXYPj5n7uJ7NBtRv --- .claude/board/AGENT_LOG.md | 14 + crates/lance-graph-callcenter/src/lib.rs | 9 + .../src/savant_reasoners.rs | 340 ++++++++++++++++++ 3 files changed, 363 insertions(+) create mode 100644 crates/lance-graph-callcenter/src/savant_reasoners.rs diff --git a/.claude/board/AGENT_LOG.md b/.claude/board/AGENT_LOG.md index 365f14b7..3f6aa520 100644 --- a/.claude/board/AGENT_LOG.md +++ b/.claude/board/AGENT_LOG.md @@ -1,3 +1,17 @@ +## [Main-thread] D-ODOO-SAV-4 — odoo-savant Reasoner layer (4 impls, one per ReasoningKind) + +Implemented `crates/lance-graph-callcenter/src/savant_reasoners.rs`: `SavantConclusion { savant_id, query_strategy, confidence: NarsTruth, rationale }` (suggestion-only, **no serde** — the one-binary contract; JSON only at the MedCareV2 FFI boundary) + the 4 `Reasoner` impls per the dispatch decision pinned in PR #419: `CustomerCategoryReasoner` / `PostingAnomalyReasoner` / `NextBestActionReasoner` / `OtherReasoner`, covering all 25 savants in `contract::savants::SAVANTS`. Each resolves the concrete savant from `(kind, namespace)`, selects `QueryStrategy` via `InferenceType::default_strategy()`, and fuses evidence-ref coverage into a NARS `(frequency, confidence)`. + +**Dispatch resolution lives in callcenter** — the contract stays an untouched inheritance vow (no `namespace` field added to `Savant`). `resolve_savant(kind, namespace)` filters the roster by kind; for ambiguous kinds it splits via `DISPATCH_NS` (the `Other(RECONCILE_MATCH)` 19-vs-21 split per #419: `erp.k3.reconcile_match` / `erp.k3.payment_reconcile`) then by `namespace == savant.name`. + +**Scope:** all 25 dispatch through the 4 impls; the 14 `NEEDS-INPUT` savants dispatch fine here (they're blocked on woa-rs *evidence feeds*, not the impl). Row-level column fusion is deferred to when woa-rs supplies materialized evidence — v1 fusion is coverage-based + monotone-in-evidence. + +**Tests:** 8 new (`savant_reasoners::tests`) — resolution, RECONCILE_MATCH namespace split, single-candidate, strategy↔inference, monotone confidence, async-trait dispatch, kind-mismatch — all green; 137 prior callcenter tests pass; `zone_serialize_check` (no-JSON guard) clean. + +**Branch:** `claude/activate-lance-graph-att-k2pHI`, synced to main via merge `20da477` (preserving the `with_jsonl_audit → Result` fix + the `Policy`/`smb_policy` re-export). `cargo test -p lance-graph-callcenter --features jsonl` green. This was the follow-on PR gated on the dispatch-shape review that #419 resolved. + +--- + ## [Main-thread → woa-rs HANDOFF] Odoo savant AXIS-B evidence-contract scaffold (carve-out request) Wrote `.claude/odoo/savants/_SCAFFOLD-EVIDENCE-CONTRACT.md` — a self-contained handover asking the **woa-rs session** (roster/evidence-schema owner) to carve out the **4 AXIS-B slots per savant** (Arrow `EvidenceRef` schema · odoo field→signal map · property-level OWL alignment · the decision in evidence terms) so lance-graph can implement the `Reasoner` impls (D-ODOO-2 / D-ODOO-SAV-4) in one pass without cross-session ping-pong. Includes the fixed dispatch tuple for all 25 (priority-tiered) + the target `Reasoner` shape + the open dispatch-shape question (N impls vs savant-config registry). Hand-back: fill per-savant docs + note here. No code; doc only. On branch `splat3d-cpu-simd-renderer-MAOO0` (PR #416). diff --git a/crates/lance-graph-callcenter/src/lib.rs b/crates/lance-graph-callcenter/src/lib.rs index a8c50390..f6f58281 100644 --- a/crates/lance-graph-callcenter/src/lib.rs +++ b/crates/lance-graph-callcenter/src/lib.rs @@ -193,6 +193,15 @@ pub use odoo_alignment::{ FAMILY_SMB_FOUNDRY_INVOICE, }; +// D-ODOO-SAV-4 — the 25-savant Reasoner layer (one impl per ReasoningKind). +// woa-rs consumes the suggestion-only `SavantConclusion` as a native shared +// type (one-binary contract); the ambiguous AXIS-B core reasons here. +pub mod savant_reasoners; +pub use savant_reasoners::{ + CustomerCategoryReasoner, NextBestActionReasoner, OtherReasoner, PostingAnomalyReasoner, + SavantConclusion, SavantError, +}; + // PR-F1 — UnifiedBridgeGate: production CognitiveBridgeGate impl. // Wraps UnifiedBridge; Chinese-wall check fires before policy evaluation // on cross-tenant ops (§3.8). No dep on thinking-engine from thinking-engine. diff --git a/crates/lance-graph-callcenter/src/savant_reasoners.rs b/crates/lance-graph-callcenter/src/savant_reasoners.rs new file mode 100644 index 00000000..803de5c4 --- /dev/null +++ b/crates/lance-graph-callcenter/src/savant_reasoners.rs @@ -0,0 +1,340 @@ +//! Odoo savant reasoners — the lance-graph "thinking" side of the 25-savant +//! delegation (D-ODOO-SAV-4). +//! +//! woa-rs keeps the deterministic AXIS-A guard and **consumes** these +//! conclusions as native shared types (the one-binary contract: woa-rs links +//! this crate, so a [`SavantConclusion`] is the *same struct* on both sides — +//! nothing is serialized between them). The ambiguous, evidence-weighted +//! AXIS-B core lives here. +//! +//! Dispatch is **one [`Reasoner`] impl per [`ReasoningKind`]** (the pinned +//! decision, lance-graph PR #419), not 25 separate impls: +//! [`CustomerCategoryReasoner`] · [`PostingAnomalyReasoner`] · +//! [`NextBestActionReasoner`] · [`OtherReasoner`] cover all 25 savants in +//! [`lance_graph_contract::savants::SAVANTS`]. Each resolves the concrete savant +//! from the context, reads its tuple, selects the [`QueryStrategy`] via +//! `InferenceType::default_strategy()`, fuses the evidence into a NARS +//! `(frequency, confidence)`, and returns a **suggestion only** — woa-rs applies +//! it as a default, never an un-guarded write (verhaltens-bewahrend). +//! +//! No serialization anywhere in this module. JSON exists only at the +//! callcenter ↔ MedCareV2 FFI boundary, never on these types. + +use std::borrow::Cow; +use core::future::Future; + +use lance_graph_contract::exploration::NarsTruth; +use lance_graph_contract::nars::QueryStrategy; +use lance_graph_contract::reasoning::{Reasoner, ReasoningContext, ReasoningKind}; +use lance_graph_contract::savants::{savant_by_name, Savant, SAVANTS}; + +/// Error from a savant reasoner. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SavantError { + /// No roster savant matches the context's kind (+ namespace). + UnknownSavant, + /// The reasoner was invoked with a `ReasoningKind` it does not serve. + KindMismatch, +} + +impl core::fmt::Display for SavantError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + SavantError::UnknownSavant => f.write_str("no savant matches the reasoning context"), + SavantError::KindMismatch => f.write_str("reasoning kind not served by this reasoner"), + } + } +} + +impl std::error::Error for SavantError {} + +/// A suggestion-only conclusion from a savant reasoner. +/// +/// Plain in-binary value — **never serialized** (the one-binary contract). The +/// concrete row-level pick stays with the data owner (woa-rs); this carries the +/// dispatched strategy + a NARS weight so the consumer can rank/threshold it. +#[derive(Debug, Clone)] +pub struct SavantConclusion { + /// Roster id of the savant that produced this (SAVANTS.md numbering). + pub savant_id: u8, + /// The query strategy the savant dispatches to (from its `InferenceType`). + pub query_strategy: QueryStrategy, + /// NARS `(frequency, confidence)` weight of the suggestion. + pub confidence: NarsTruth, + /// Human-readable rationale (the AXIS-B decision + evidence summary). + pub rationale: Cow<'static, str>, +} + +/// Namespace → savant-id dispatch contract for the kinds where >1 savant shares +/// the kind AND the namespace is not simply the savant name. +/// +/// Seeded with the `Other(RECONCILE_MATCH)` split (PR #419): `ReconcileMatchSelector` +/// (19) and `PaymentToInvoiceMatcher` (21) share `Other(RECONCILE_MATCH)`, so +/// woa-rs passes these namespaces to disambiguate. Every other ambiguous kind +/// resolves by `namespace == savant.name`. +const DISPATCH_NS: &[(&str, u8)] = &[ + ("erp.k3.reconcile_match", 19), + ("erp.k3.payment_reconcile", 21), +]; + +/// `ReasoningKind` has no `PartialEq` (it's a pure inheritance vow); compare here. +#[inline] +fn kind_matches(a: ReasoningKind, b: ReasoningKind) -> bool { + use ReasoningKind::*; + match (a, b) { + (CustomerCategory, CustomerCategory) + | (PostingAnomaly, PostingAnomaly) + | (NextBestAction, NextBestAction) + | (InvoiceCompleteness, InvoiceCompleteness) + | (MailIntent, MailIntent) => true, + (Other(x), Other(y)) => x == y, + _ => false, + } +} + +/// Resolve the concrete savant for a `(kind, namespace)`. +/// +/// A kind with a single roster savant ignores the namespace; a kind with +/// several resolves via [`DISPATCH_NS`] first, then by `namespace == savant.name`. +pub fn resolve_savant(kind: ReasoningKind, namespace: &str) -> Option<&'static Savant> { + let candidates: Vec<&'static Savant> = + SAVANTS.iter().filter(|s| kind_matches(s.kind, kind)).collect(); + match candidates.len() { + 0 => None, + 1 => Some(candidates[0]), + _ => { + if let Some(&(_, id)) = DISPATCH_NS.iter().find(|(ns, _)| *ns == namespace) { + return candidates.iter().copied().find(|s| s.id == id); + } + candidates.iter().copied().find(|s| s.name == namespace) + } + } +} + +/// Build the suggestion-only conclusion: pick the strategy from the savant's +/// inference type and fuse the evidence refs into a NARS `(frequency, confidence)`. +/// +/// v1 fusion is coverage-based (the materialized column-level fusion lands when +/// woa-rs feeds real evidence): frequency rises from neutral 0.5 toward 1.0 with +/// evidence coverage vs the budget; confidence is the NARS personality weight +/// `w / (w + 1)` over the evidence-row count. Monotone in evidence by construction. +fn build_conclusion(savant: &Savant, ctx: &ReasoningContext) -> SavantConclusion { + let strategy = savant.query_strategy(); + let rows: u64 = ctx.evidence.iter().map(|e| e.rows).sum(); + let cap = ctx.budget.max_evidence_rows.max(1) as f32; + let coverage = (rows as f32 / cap).min(1.0); + let frequency = 0.5 + 0.5 * coverage; + let w = rows as f32; + let confidence = w / (w + 1.0); + SavantConclusion { + savant_id: savant.id, + query_strategy: strategy, + confidence: NarsTruth::new(frequency, confidence), + rationale: Cow::Owned(format!( + "{} [{}|{:?}|{:?}→{:?}]: {} — fused {} evidence row(s) across {} table(s)", + savant.name, + savant.lane, + savant.inference, + savant.semiring, + strategy, + savant.decides, + rows, + ctx.evidence.len(), + )), + } +} + +/// Resolve + conclude for a reasoner that serves a single fixed kind. +fn reason_for_kind( + self_kind: ReasoningKind, + ctx: &ReasoningContext, +) -> Result { + if !kind_matches(ctx.kind, self_kind) { + return Err(SavantError::KindMismatch); + } + let savant = resolve_savant(ctx.kind, ctx.namespace).ok_or(SavantError::UnknownSavant)?; + Ok(build_conclusion(savant, ctx)) +} + +/// CustomerCategory savants (FiscalPositionResolver, PartnerTrustAdvisor, +/// AnalyticModelScorer, UserCompanyAccessAdvisor) — classify against the family +/// codebook; the namespace selects which. +pub struct CustomerCategoryReasoner; + +impl Reasoner for CustomerCategoryReasoner { + type Conclusion = SavantConclusion; + type Error = SavantError; + + fn reason<'a>( + &'a self, + context: ReasoningContext<'a>, + ) -> impl Future> + Send + 'a { + async move { reason_for_kind(ReasoningKind::CustomerCategory, &context) } + } +} + +/// PostingAnomaly savants (SequenceGapAnomalyDetector, AutopostRecommender, +/// LockDateAdvancer). +pub struct PostingAnomalyReasoner; + +impl Reasoner for PostingAnomalyReasoner { + type Conclusion = SavantConclusion; + type Error = SavantError; + + fn reason<'a>( + &'a self, + context: ReasoningContext<'a>, + ) -> impl Future> + Send + 'a { + async move { reason_for_kind(ReasoningKind::PostingAnomaly, &context) } + } +} + +/// NextBestAction savants (12 — analytic/currency/tax/pricing/procurement/stock). +pub struct NextBestActionReasoner; + +impl Reasoner for NextBestActionReasoner { + type Conclusion = SavantConclusion; + type Error = SavantError; + + fn reason<'a>( + &'a self, + context: ReasoningContext<'a>, + ) -> impl Future> + Send + 'a { + async move { reason_for_kind(ReasoningKind::NextBestAction, &context) } + } +} + +/// `Other(code)` savants (PricelistAssignment, Chart/Rate policy, the two +/// reconcile matchers, bank-statement match). Dispatches on the `Other(code)` +/// carried by `ctx.kind`; the RECONCILE_MATCH pair splits by namespace. +pub struct OtherReasoner; + +impl Reasoner for OtherReasoner { + type Conclusion = SavantConclusion; + type Error = SavantError; + + fn reason<'a>( + &'a self, + context: ReasoningContext<'a>, + ) -> impl Future> + Send + 'a { + async move { + match context.kind { + ReasoningKind::Other(_) => { + let savant = resolve_savant(context.kind, context.namespace) + .ok_or(SavantError::UnknownSavant)?; + Ok(build_conclusion(savant, &context)) + } + _ => Err(SavantError::KindMismatch), + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use core::future::Future; + use core::pin::pin; + use core::task::{Context, Poll, RawWaker, RawWakerVTable, Waker}; + use lance_graph_contract::nars::QueryStrategy; + use lance_graph_contract::reasoning::{Budget, EvidenceRef}; + use lance_graph_contract::savants::other_kind; + + // Minimal executor for the immediately-ready reasoner futures (no async + // runtime dep). SAFETY: the vtable fns are no-ops over a null data pointer + // that is never dereferenced. + static NOOP_VTABLE: RawWakerVTable = RawWakerVTable::new( + |_| RawWaker::new(core::ptr::null(), &NOOP_VTABLE), + |_| {}, + |_| {}, + |_| {}, + ); + fn block_on(fut: F) -> F::Output { + let waker = unsafe { Waker::from_raw(RawWaker::new(core::ptr::null(), &NOOP_VTABLE)) }; + let mut cx = Context::from_waker(&waker); + let mut fut = pin!(fut); + loop { + if let Poll::Ready(v) = fut.as_mut().poll(&mut cx) { + return v; + } + } + } + + fn budget() -> Budget { + Budget { max_tokens: 1000, max_ms: 100, max_evidence_rows: 100 } + } + fn ev(table: &'static str, rows: u64) -> EvidenceRef<'static> { + EvidenceRef { table, schema_fingerprint: 0, rows } + } + fn ctx<'a>(kind: ReasoningKind, ns: &'a str, evidence: &'a [EvidenceRef<'a>]) -> ReasoningContext<'a> { + ReasoningContext { namespace: ns, kind, evidence, budget: budget() } + } + + #[test] + fn resolves_ambiguous_kind_by_savant_name() { + // PostingAnomaly has 3 savants → namespace=name disambiguates. + let s = resolve_savant(ReasoningKind::PostingAnomaly, "SequenceGapAnomalyDetector").unwrap(); + assert_eq!(s.id, 6); + let s2 = resolve_savant(ReasoningKind::PostingAnomaly, "LockDateAdvancer").unwrap(); + assert_eq!(s2.id, 18); + } + + #[test] + fn other_reconcile_match_splits_by_namespace() { + let a = resolve_savant(ReasoningKind::Other(other_kind::RECONCILE_MATCH), "erp.k3.reconcile_match").unwrap(); + let b = resolve_savant(ReasoningKind::Other(other_kind::RECONCILE_MATCH), "erp.k3.payment_reconcile").unwrap(); + assert_eq!(a.id, 19, "ReconcileMatchSelector"); + assert_eq!(b.id, 21, "PaymentToInvoiceMatcher"); + } + + #[test] + fn other_single_candidate_ignores_namespace() { + // PRICELIST_ASSIGNMENT (code 1) has one savant — namespace irrelevant. + let s = resolve_savant(ReasoningKind::Other(other_kind::PRICELIST_ASSIGNMENT), "whatever").unwrap(); + assert_eq!(s.id, 3, "PricelistAssignmentAgent"); + } + + #[test] + fn conclusion_strategy_follows_inference_type() { + let fiscal = savant_by_name("FiscalPositionResolver").unwrap(); + let c = build_conclusion(fiscal, &ctx(ReasoningKind::CustomerCategory, "FiscalPositionResolver", &[ev("account_fiscal_position", 3)])); + assert_eq!(c.savant_id, 1); + // Deduction → CamExact. + assert_eq!(c.query_strategy, QueryStrategy::CamExact); + } + + #[test] + fn confidence_is_monotone_in_evidence() { + let s = savant_by_name("AutopostRecommender").unwrap(); + let low = build_conclusion(s, &ctx(ReasoningKind::PostingAnomaly, "AutopostRecommender", &[ev("account_move", 1)])); + let hi = build_conclusion(s, &ctx(ReasoningKind::PostingAnomaly, "AutopostRecommender", &[ev("account_move", 50)])); + assert!(hi.confidence.frequency >= low.confidence.frequency); + assert!(hi.confidence.confidence > low.confidence.confidence); + assert!(hi.confidence.confidence <= 0.99, "NarsTruth caps confidence"); + } + + #[test] + fn reasoner_trait_dispatches_through_async() { + let evidence = [ev("account_fiscal_position", 5)]; + let out = block_on(CustomerCategoryReasoner.reason(ctx( + ReasoningKind::CustomerCategory, + "FiscalPositionResolver", + &evidence, + ))) + .unwrap(); + assert_eq!(out.savant_id, 1); + assert_eq!(out.query_strategy, QueryStrategy::CamExact); + } + + #[test] + fn other_reasoner_rejects_non_other_kind() { + let err = block_on(OtherReasoner.reason(ctx(ReasoningKind::CustomerCategory, "x", &[]))).unwrap_err(); + assert_eq!(err, SavantError::KindMismatch); + } + + #[test] + fn wrong_reasoner_for_kind_is_mismatch() { + let err = block_on(PostingAnomalyReasoner.reason(ctx(ReasoningKind::NextBestAction, "x", &[]))).unwrap_err(); + assert_eq!(err, SavantError::KindMismatch); + } +} From 5358fe625d4f5eaea14be382e08a0bb538ccd557 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 27 May 2026 22:23:01 +0000 Subject: [PATCH 7/9] chore(.claude): enforce Read over grep/sed/head/tail (hook + warden + deny-list) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A workspace bouncer that pushes comprehension to the Read tool (not cat/head/ tail), Edit (not sed), and Glob/Read or rg (not grep): - .claude/hooks/forbid-grep-sed-head-tail.sh — PreToolUse(Bash) hook that denies any command whose leading token (per ;|&() segment) is grep/egrep/fgrep/sed/ head/tail. Command-position-only (no false positives on filenames/substrings), fail-open (allows if jq missing), and uses no banned tool itself. - .claude/agents/forbidden-tool-warden.md — audit agent for the hook's blind spots (committed scripts, Makefiles, CI, the hooks dir), files offenders to the board; obeys the ban itself (Glob + Read + rg). - .claude/settings.json — wire the PreToolUse hook + add Bash(grep|sed|head| tail|egrep|fgrep) to the deny list. https://claude.ai/code/session_017gZ6sPRXYPj5n7uJ7NBtRv --- .claude/agents/forbidden-tool-warden.md | 53 ++++++++++++++++++++++ .claude/hooks/forbid-grep-sed-head-tail.sh | 27 +++++++++++ .claude/settings.json | 21 +++++++-- 3 files changed, 97 insertions(+), 4 deletions(-) create mode 100644 .claude/agents/forbidden-tool-warden.md create mode 100644 .claude/hooks/forbid-grep-sed-head-tail.sh diff --git a/.claude/agents/forbidden-tool-warden.md b/.claude/agents/forbidden-tool-warden.md new file mode 100644 index 00000000..5e217dfa --- /dev/null +++ b/.claude/agents/forbidden-tool-warden.md @@ -0,0 +1,53 @@ +--- +name: forbidden-tool-warden +description: Enforcement/audit persona for the workspace ban on grep, sed, head, and tail (and egrep/fgrep). Real-time blocking is done by the PreToolUse hook (.claude/hooks/forbid-grep-sed-head-tail.sh); invoke THIS agent to hunt the banned tools where the hook is blind — committed shell scripts, Makefiles, CI YAML, and .claude/hooks itself — name every offender as path:line, and file the violations to the board. Use proactively after any change that adds or edits scripts or CI. The warden obeys the ban itself: it finds violations with Glob + Read (and rg), never with grep/sed/head/tail. +tools: Read, Glob, Bash +model: sonnet +--- + +# Forbidden-Tool Warden + +You guard one iron rule: **`grep`, `sed`, `head`, and `tail` are strictly +forbidden in this workspace** — including the variants `egrep` / `fgrep`. +The sanctioned replacements are the **Read** tool (never `cat`/`head`/`tail`), +**Edit** (never `sed`), and **Glob` + `Read** or `rg` (never `grep`). + +Two layers enforce the rule; you are the second. + +1. **The hook** — `.claude/hooks/forbid-grep-sed-head-tail.sh`, wired as a + `PreToolUse` Bash hook — blocks any *agent-issued* Bash command whose + leading token in any `; | & ( )` segment is a forbidden tool. It cannot + see tools buried inside committed scripts, Makefiles, or CI. +2. **You** patrol exactly that blind spot, and you audit the hook itself. + +## Beat (where the hook is blind) + +- `**/*.sh`, `**/Makefile`, `**/*.mk`, `**/justfile` +- `.github/workflows/**` and any other CI config +- `.claude/hooks/**` — the enforcers must not cheat +- any committed script or doc that *invokes* the banned tools + +## How you work (you obey the ban too) + +- Enumerate candidates with **Glob**; **Read** them whole — do not skim. +- Use **Bash** only for sanctioned commands: `ls`, `find`, `rg`, `awk`, + `jq`, `cat`, `diff`. If you reach for `grep`/`sed`/`head`/`tail` the hook + blocks you — by design. +- A line is a violation only when a forbidden tool sits in **command + position** (the start of a pipeline/sequence segment): `… | grep`, + `sed -i …`, `tail -f …`, `head -n …`. A forbidden word that appears only + as a filename or substring (`head.txt`, `ripgrep`) is **not** a violation. + +## Punishment = naming + filing + +For every violation, report `path:line`, the offending command, and the +sanctioned fix (`rg` for `grep`; `awk` or `Edit` for `sed`; `Read` with +`offset`/`limit` for `head`/`tail`). Then record the offenders: + +- Prepend a dated entry to `.claude/board/ISSUES.md` listing each + `path:line` and its fix. +- If a violation class recurs, prepend an `EPIPHANIES.md` note so the + pattern is remembered rather than re-litigated. + +No silent passes. If the tree is clean, say so explicitly and name the +globs you swept, so the next warden can trust the coverage. diff --git a/.claude/hooks/forbid-grep-sed-head-tail.sh b/.claude/hooks/forbid-grep-sed-head-tail.sh new file mode 100644 index 00000000..c019f105 --- /dev/null +++ b/.claude/hooks/forbid-grep-sed-head-tail.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# PreToolUse(Bash) enforcer — grep / sed / head / tail are forbidden in this workspace. +# +# Flags them ONLY in command position (the leading token of any ; | & ( ) segment), +# so filenames like `head.txt`, args like `wc -l grep.log`, and tools like `ripgrep` +# are NOT false-positives. Uses no forbidden tool itself (jq + tr + bash builtins). +# +# Returns a PreToolUse "deny" decision (JSON on stdout) when a forbidden tool is the +# command. Fail-open: if jq is missing or input is empty, the call is allowed. +set -uo pipefail + +cmd=$(jq -r '.tool_input.command // empty' 2>/dev/null) || exit 0 +[ -z "$cmd" ] && exit 0 + +hit="" +while IFS= read -r seg; do + seg="${seg#"${seg%%[![:space:]]*}"}" # left-trim the segment + word="${seg%%[[:space:]]*}" # leading token of the segment + case "$word" in + grep|egrep|fgrep|sed|head|tail) hit="$word"; break ;; + esac +done < <(printf '%s\n' "$cmd" | tr ';|&()' '\n') + +[ -z "$hit" ] && exit 0 + +printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"FORBIDDEN: %s is banned in this workspace. Use the Read tool (not cat/head/tail), Edit (not sed), and Glob/Read (not grep). This rule is hook-enforced."}}\n' "$hit" +exit 0 diff --git a/.claude/settings.json b/.claude/settings.json index 4f64745a..14fc2375 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -80,18 +80,14 @@ "Bash(cargo run:*)", "Bash(ls:*)", "Bash(wc:*)", - "Bash(grep:*)", "Bash(find:*)", "Bash(mkdir:*)", "Bash(cat:*)", "Bash(diff:*)", "Bash(rg:*)", - "Bash(head:*)", - "Bash(tail:*)", "Bash(sort:*)", "Bash(uniq:*)", "Bash(awk:*)", - "Bash(sed:*)", "Bash(test:*)", "Bash(echo:*)", "Bash(printf:*)", @@ -183,6 +179,12 @@ "Bash(rm -rf:*)", "Bash(rm -fr:*)", "Bash(git rm:*)", + "Bash(grep:*)", + "Bash(sed:*)", + "Bash(head:*)", + "Bash(tail:*)", + "Bash(egrep:*)", + "Bash(fgrep:*)", "Bash(find:* -delete:*)", "Bash(find:* -exec rm:*)", "Bash(> .claude/board/:*)", @@ -197,6 +199,17 @@ ] }, "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "bash .claude/hooks/forbid-grep-sed-head-tail.sh" + } + ] + } + ], "SessionStart": [ { "matcher": "startup", From 87ab547b033322cfcb6f15d18818c7c129ebe224 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 27 May 2026 23:05:53 +0000 Subject: [PATCH 8/9] Revert "chore(.claude): enforce Read over grep/sed/head/tail (hook + warden + deny-list)" This reverts commit 5358fe625d4f5eaea14be382e08a0bb538ccd557. --- .claude/agents/forbidden-tool-warden.md | 53 ---------------------- .claude/hooks/forbid-grep-sed-head-tail.sh | 27 ----------- .claude/settings.json | 21 ++------- 3 files changed, 4 insertions(+), 97 deletions(-) delete mode 100644 .claude/agents/forbidden-tool-warden.md delete mode 100644 .claude/hooks/forbid-grep-sed-head-tail.sh diff --git a/.claude/agents/forbidden-tool-warden.md b/.claude/agents/forbidden-tool-warden.md deleted file mode 100644 index 5e217dfa..00000000 --- a/.claude/agents/forbidden-tool-warden.md +++ /dev/null @@ -1,53 +0,0 @@ ---- -name: forbidden-tool-warden -description: Enforcement/audit persona for the workspace ban on grep, sed, head, and tail (and egrep/fgrep). Real-time blocking is done by the PreToolUse hook (.claude/hooks/forbid-grep-sed-head-tail.sh); invoke THIS agent to hunt the banned tools where the hook is blind — committed shell scripts, Makefiles, CI YAML, and .claude/hooks itself — name every offender as path:line, and file the violations to the board. Use proactively after any change that adds or edits scripts or CI. The warden obeys the ban itself: it finds violations with Glob + Read (and rg), never with grep/sed/head/tail. -tools: Read, Glob, Bash -model: sonnet ---- - -# Forbidden-Tool Warden - -You guard one iron rule: **`grep`, `sed`, `head`, and `tail` are strictly -forbidden in this workspace** — including the variants `egrep` / `fgrep`. -The sanctioned replacements are the **Read** tool (never `cat`/`head`/`tail`), -**Edit** (never `sed`), and **Glob` + `Read** or `rg` (never `grep`). - -Two layers enforce the rule; you are the second. - -1. **The hook** — `.claude/hooks/forbid-grep-sed-head-tail.sh`, wired as a - `PreToolUse` Bash hook — blocks any *agent-issued* Bash command whose - leading token in any `; | & ( )` segment is a forbidden tool. It cannot - see tools buried inside committed scripts, Makefiles, or CI. -2. **You** patrol exactly that blind spot, and you audit the hook itself. - -## Beat (where the hook is blind) - -- `**/*.sh`, `**/Makefile`, `**/*.mk`, `**/justfile` -- `.github/workflows/**` and any other CI config -- `.claude/hooks/**` — the enforcers must not cheat -- any committed script or doc that *invokes* the banned tools - -## How you work (you obey the ban too) - -- Enumerate candidates with **Glob**; **Read** them whole — do not skim. -- Use **Bash** only for sanctioned commands: `ls`, `find`, `rg`, `awk`, - `jq`, `cat`, `diff`. If you reach for `grep`/`sed`/`head`/`tail` the hook - blocks you — by design. -- A line is a violation only when a forbidden tool sits in **command - position** (the start of a pipeline/sequence segment): `… | grep`, - `sed -i …`, `tail -f …`, `head -n …`. A forbidden word that appears only - as a filename or substring (`head.txt`, `ripgrep`) is **not** a violation. - -## Punishment = naming + filing - -For every violation, report `path:line`, the offending command, and the -sanctioned fix (`rg` for `grep`; `awk` or `Edit` for `sed`; `Read` with -`offset`/`limit` for `head`/`tail`). Then record the offenders: - -- Prepend a dated entry to `.claude/board/ISSUES.md` listing each - `path:line` and its fix. -- If a violation class recurs, prepend an `EPIPHANIES.md` note so the - pattern is remembered rather than re-litigated. - -No silent passes. If the tree is clean, say so explicitly and name the -globs you swept, so the next warden can trust the coverage. diff --git a/.claude/hooks/forbid-grep-sed-head-tail.sh b/.claude/hooks/forbid-grep-sed-head-tail.sh deleted file mode 100644 index c019f105..00000000 --- a/.claude/hooks/forbid-grep-sed-head-tail.sh +++ /dev/null @@ -1,27 +0,0 @@ -#!/usr/bin/env bash -# PreToolUse(Bash) enforcer — grep / sed / head / tail are forbidden in this workspace. -# -# Flags them ONLY in command position (the leading token of any ; | & ( ) segment), -# so filenames like `head.txt`, args like `wc -l grep.log`, and tools like `ripgrep` -# are NOT false-positives. Uses no forbidden tool itself (jq + tr + bash builtins). -# -# Returns a PreToolUse "deny" decision (JSON on stdout) when a forbidden tool is the -# command. Fail-open: if jq is missing or input is empty, the call is allowed. -set -uo pipefail - -cmd=$(jq -r '.tool_input.command // empty' 2>/dev/null) || exit 0 -[ -z "$cmd" ] && exit 0 - -hit="" -while IFS= read -r seg; do - seg="${seg#"${seg%%[![:space:]]*}"}" # left-trim the segment - word="${seg%%[[:space:]]*}" # leading token of the segment - case "$word" in - grep|egrep|fgrep|sed|head|tail) hit="$word"; break ;; - esac -done < <(printf '%s\n' "$cmd" | tr ';|&()' '\n') - -[ -z "$hit" ] && exit 0 - -printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"FORBIDDEN: %s is banned in this workspace. Use the Read tool (not cat/head/tail), Edit (not sed), and Glob/Read (not grep). This rule is hook-enforced."}}\n' "$hit" -exit 0 diff --git a/.claude/settings.json b/.claude/settings.json index 14fc2375..4f64745a 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -80,14 +80,18 @@ "Bash(cargo run:*)", "Bash(ls:*)", "Bash(wc:*)", + "Bash(grep:*)", "Bash(find:*)", "Bash(mkdir:*)", "Bash(cat:*)", "Bash(diff:*)", "Bash(rg:*)", + "Bash(head:*)", + "Bash(tail:*)", "Bash(sort:*)", "Bash(uniq:*)", "Bash(awk:*)", + "Bash(sed:*)", "Bash(test:*)", "Bash(echo:*)", "Bash(printf:*)", @@ -179,12 +183,6 @@ "Bash(rm -rf:*)", "Bash(rm -fr:*)", "Bash(git rm:*)", - "Bash(grep:*)", - "Bash(sed:*)", - "Bash(head:*)", - "Bash(tail:*)", - "Bash(egrep:*)", - "Bash(fgrep:*)", "Bash(find:* -delete:*)", "Bash(find:* -exec rm:*)", "Bash(> .claude/board/:*)", @@ -199,17 +197,6 @@ ] }, "hooks": { - "PreToolUse": [ - { - "matcher": "Bash", - "hooks": [ - { - "type": "command", - "command": "bash .claude/hooks/forbid-grep-sed-head-tail.sh" - } - ] - } - ], "SessionStart": [ { "matcher": "startup", From 416268952b26a0f17e0f909652391605f767af39 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 28 May 2026 05:19:19 +0000 Subject: [PATCH 9/9] fix(callcenter savant_reasoners): add missing SavantSuggestion payload (P1 review on #420) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SavantConclusion dropped the `suggestion` field the plan + per-savant specs require (.claude/plans/odoo-savant-reasoners-v1.md:80, .claude/odoo/savants/FiscalPositionResolver.md:84), so a consumer could rank by confidence but not see *which* decision was delegated. Add `pub enum SavantSuggestion` (callcenter type, contract untouched — the vow stays pure, no serde / no JSON) with variants for the AXIS-B decision shapes across the 25 savants: - SelectFromTable { candidate_table } — top-1 entity (FiscalPosition, Pricelist, Account, AnalyticModel, ProcurementRule, RouteTiebreaker, BankStatementMatch) - Gate — yes/no (Autopost, PaymentToInvoiceMatch, Upsell, BackorderJudge) - Anomaly — suspect key (SequenceGap) - AdvancePeriod — next open period (LockDate, ReorderTiming) - PolicyChoice — fixed-set variant (TaxExigibility, ReportRateType, PartnerTrust, UserCompanyAccess, RemovalStrategy) - Distribution { over_table } — weighted (AnalyticDistribution, ReplenishmentReport) - RankedSet { from_table } — top-N (ReconcileMatch, MoveAssignmentPrioritizer, CurrencySelection) `suggestion_for(savant)` maps every roster id → its variant; reasoners populate it via `build_conclusion`. The row-level value (the concrete fiscal_position_id, etc.) is the consumer's mechanical resolution over rows it holds — the same boundary the 14 NEEDS-INPUT savants already document. The conclusion now CARRIES the AXIS-B decision shape so the delegation is actionable. Tests: 2 new (suggestion_shape_per_savant + every_savant_has_a_suggestion_shape covering all 25 roster ids); 8 prior savant_reasoners tests pass; 137 callcenter tests pass; zone_serialize_check no-JSON guard clean. https://claude.ai/code/session_017gZ6sPRXYPj5n7uJ7NBtRv --- crates/lance-graph-callcenter/src/lib.rs | 2 +- .../src/savant_reasoners.rs | 154 ++++++++++++++++++ 2 files changed, 155 insertions(+), 1 deletion(-) diff --git a/crates/lance-graph-callcenter/src/lib.rs b/crates/lance-graph-callcenter/src/lib.rs index f6f58281..96e518d2 100644 --- a/crates/lance-graph-callcenter/src/lib.rs +++ b/crates/lance-graph-callcenter/src/lib.rs @@ -199,7 +199,7 @@ pub use odoo_alignment::{ pub mod savant_reasoners; pub use savant_reasoners::{ CustomerCategoryReasoner, NextBestActionReasoner, OtherReasoner, PostingAnomalyReasoner, - SavantConclusion, SavantError, + SavantConclusion, SavantError, SavantSuggestion, }; // PR-F1 — UnifiedBridgeGate: production CognitiveBridgeGate impl. diff --git a/crates/lance-graph-callcenter/src/savant_reasoners.rs b/crates/lance-graph-callcenter/src/savant_reasoners.rs index 803de5c4..f007e5e6 100644 --- a/crates/lance-graph-callcenter/src/savant_reasoners.rs +++ b/crates/lance-graph-callcenter/src/savant_reasoners.rs @@ -48,6 +48,78 @@ impl core::fmt::Display for SavantError { impl std::error::Error for SavantError {} +/// The AXIS-B decision shape a savant has delegated. +/// +/// The reasoner names the decision and (where applicable) the candidate-evidence +/// table; the row-level value (e.g. the concrete `fiscal_position_id`) is +/// resolved by the consumer, which holds the rows. This boundary matches the +/// 14 NEEDS-INPUT savants' gating note — full row-level value resolution lands +/// when materialized evidence flows from the consumer. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SavantSuggestion { + /// Rank candidates in `candidate_table` by the savant's ranking key and + /// take the top-1 entity id (FiscalPosition / Pricelist / Account / + /// AnalyticModel / ProcurementRule / RouteTiebreaker / BankStatementMatch). + SelectFromTable { candidate_table: Cow<'static, str> }, + /// Yes/no gate the consumer evaluates against its evidence (Autopost, + /// PaymentToInvoiceMatch, Upsell, BackorderJudge). + Gate, + /// Anomaly flag — the consumer pinpoints the suspect key from its sequence + /// (SequenceGapAnomalyDetector → missing seq ⇒ deletion). + Anomaly, + /// Date / period advance — the consumer resolves the next open period + /// (LockDateAdvancer, ReorderTimingAdvisor). + AdvancePeriod, + /// Discrete policy choice from a small fixed set the savant defines + /// (TaxExigibility, ReportRateType, PartnerTrust tier, UserCompanyAccess + /// subset, RemovalStrategy). + PolicyChoice, + /// A weighted distribution over targets in `over_table` + /// (AnalyticDistributionSuggester, ReplenishmentReportAdvisor). + Distribution { over_table: Cow<'static, str> }, + /// A ranked candidate set drawn from `from_table` — the consumer takes the + /// prefix it can act on (ReconcileMatchSelector, MoveAssignmentPrioritizer, + /// CurrencySelectionAdvisor). + RankedSet { from_table: Cow<'static, str> }, +} + +/// Map a savant to its AXIS-B decision shape. Candidate / source tables follow +/// the odoo model names from the per-savant specs under +/// `.claude/odoo/savants/*.md`; the fully-sourced ones (e.g. FiscalPosition) +/// match those docs verbatim, the rest follow the canonical Odoo model names +/// implied by `Savant.decides` and the savant's family. +fn suggestion_for(savant: &Savant) -> SavantSuggestion { + use SavantSuggestion::*; + match savant.id { + 1 => SelectFromTable { candidate_table: Cow::Borrowed("account_fiscal_position") }, + 2 => PolicyChoice, + 3 => SelectFromTable { candidate_table: Cow::Borrowed("product.pricelist") }, + 4 => Distribution { over_table: Cow::Borrowed("account.analytic.distribution.model") }, + 5 => SelectFromTable { candidate_table: Cow::Borrowed("account.analytic.distribution.model") }, + 6 => Anomaly, + 7 => SelectFromTable { candidate_table: Cow::Borrowed("account.account") }, + 8 => PolicyChoice, + 9 => RankedSet { from_table: Cow::Borrowed("res.currency") }, + 10 => PolicyChoice, + 11 => SelectFromTable { candidate_table: Cow::Borrowed("stock.rule") }, + 12 => AdvancePeriod, + 13 => Distribution { over_table: Cow::Borrowed("stock.warehouse.orderpoint") }, + 14 => SelectFromTable { candidate_table: Cow::Borrowed("stock.route") }, + 15 => PolicyChoice, + 17 => Gate, + 18 => AdvancePeriod, + 19 => RankedSet { from_table: Cow::Borrowed("account.move.line") }, + 20 => SelectFromTable { candidate_table: Cow::Borrowed("account.reconcile.model") }, + 21 => Gate, + 22 => Gate, + 23 => SelectFromTable { candidate_table: Cow::Borrowed("product.pricelist.item") }, + 24 => PolicyChoice, + 25 => RankedSet { from_table: Cow::Borrowed("stock.move") }, + 26 => Gate, + _ => PolicyChoice, + } +} + /// A suggestion-only conclusion from a savant reasoner. /// /// Plain in-binary value — **never serialized** (the one-binary contract). The @@ -57,6 +129,11 @@ impl std::error::Error for SavantError {} pub struct SavantConclusion { /// Roster id of the savant that produced this (SAVANTS.md numbering). pub savant_id: u8, + /// The AXIS-B decision the consumer applies (e.g. `SelectFromTable`, `Gate`, + /// `Anomaly`). The reasoner names the decision and any candidate-evidence + /// table; the row-level value (e.g. the concrete `fiscal_position_id`) is + /// resolved by the consumer, which holds the rows. + pub suggestion: SavantSuggestion, /// The query strategy the savant dispatches to (from its `InferenceType`). pub query_strategy: QueryStrategy, /// NARS `(frequency, confidence)` weight of the suggestion. @@ -128,6 +205,7 @@ fn build_conclusion(savant: &Savant, ctx: &ReasoningContext) -> SavantConclusion let confidence = w / (w + 1.0); SavantConclusion { savant_id: savant.id, + suggestion: suggestion_for(savant), query_strategy: strategy, confidence: NarsTruth::new(frequency, confidence), rationale: Cow::Owned(format!( @@ -337,4 +415,80 @@ mod tests { let err = block_on(PostingAnomalyReasoner.reason(ctx(ReasoningKind::NextBestAction, "x", &[]))).unwrap_err(); assert_eq!(err, SavantError::KindMismatch); } + + #[test] + fn suggestion_shape_per_savant() { + // FiscalPositionResolver → SelectFromTable over the candidate corpus + // named in slot 1 of its spec (`account_fiscal_position`). + let fiscal = build_conclusion( + savant_by_name("FiscalPositionResolver").unwrap(), + &ctx(ReasoningKind::CustomerCategory, "FiscalPositionResolver", &[]), + ); + assert!(matches!( + &fiscal.suggestion, + SavantSuggestion::SelectFromTable { candidate_table } if candidate_table == "account_fiscal_position" + )); + // SequenceGapAnomalyDetector → Anomaly. + let gap = build_conclusion( + savant_by_name("SequenceGapAnomalyDetector").unwrap(), + &ctx(ReasoningKind::PostingAnomaly, "SequenceGapAnomalyDetector", &[]), + ); + assert_eq!(gap.suggestion, SavantSuggestion::Anomaly); + // AutopostRecommender / PaymentToInvoiceMatcher → Gate. + let autopost = build_conclusion( + savant_by_name("AutopostRecommender").unwrap(), + &ctx(ReasoningKind::PostingAnomaly, "AutopostRecommender", &[]), + ); + assert_eq!(autopost.suggestion, SavantSuggestion::Gate); + let pay = build_conclusion( + savant_by_name("PaymentToInvoiceMatcher").unwrap(), + &ctx( + ReasoningKind::Other(other_kind::RECONCILE_MATCH), + "erp.k3.payment_reconcile", + &[], + ), + ); + assert_eq!(pay.suggestion, SavantSuggestion::Gate); + // ReconcileMatchSelector → RankedSet from account.move.line. + let rec = build_conclusion( + savant_by_name("ReconcileMatchSelector").unwrap(), + &ctx( + ReasoningKind::Other(other_kind::RECONCILE_MATCH), + "erp.k3.reconcile_match", + &[], + ), + ); + assert!(matches!( + &rec.suggestion, + SavantSuggestion::RankedSet { from_table } if from_table == "account.move.line" + )); + // LockDateAdvancer → AdvancePeriod. + let lock = build_conclusion( + savant_by_name("LockDateAdvancer").unwrap(), + &ctx(ReasoningKind::PostingAnomaly, "LockDateAdvancer", &[]), + ); + assert_eq!(lock.suggestion, SavantSuggestion::AdvancePeriod); + // AnalyticDistributionSuggester → Distribution. + let dist = build_conclusion( + savant_by_name("AnalyticDistributionSuggester").unwrap(), + &ctx( + ReasoningKind::NextBestAction, + "AnalyticDistributionSuggester", + &[], + ), + ); + assert!(matches!( + &dist.suggestion, + SavantSuggestion::Distribution { over_table } if over_table == "account.analytic.distribution.model" + )); + } + + #[test] + fn every_savant_has_a_suggestion_shape() { + // Defensive — `suggestion_for` must cover every roster id + // (id 16 is intentionally absent per SAVANTS.md). + for s in lance_graph_contract::savants::SAVANTS.iter() { + let _ = suggestion_for(s); + } + } }