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/.claude/board/EPIPHANIES.md b/.claude/board/EPIPHANIES.md index 56088745..70677bcc 100644 --- a/.claude/board/EPIPHANIES.md +++ b/.claude/board/EPIPHANIES.md @@ -1,3 +1,14 @@ +## 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-27 — E-RUBICON-RACTOR — the Σ10 Rubicon commit IS the Heckhausen action-phase crossing; ractor start/stop = crossing/closing the Rubicon; Libet "free won't" = the pre-commit veto; the kanban is a SurrealDB VIEW over leading LanceDB storage **Status:** CONJECTURE / design-grounding. Names the psychological origin of the *already-shipped* Σ10 Rubicon doctrine. **Provenance note:** "Σ10 Rubicon" is canonical and implemented (`SigmaTierRouter` Rubicon-resonance dispatch, `D-CSV-10` shipped PR #388; origin `linguistic-epiphanies-2026-04-19.md` E21), but **"Libet" and "Heckhausen" appear nowhere in the board/code/transcripts** — that grounding was a different session or verbal, recorded here now. diff --git a/.claude/board/INTEGRATION_PLANS.md b/.claude/board/INTEGRATION_PLANS.md index 10227b4f..c3e960e1 100644 --- a/.claude/board/INTEGRATION_PLANS.md +++ b/.claude/board/INTEGRATION_PLANS.md @@ -1,3 +1,13 @@ +## 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-27 — bindspace-singleton-to-mailbox-soa-v1 (dissolve the shared `Arc` into per-mailbox `MailboxSoA` ephemeral thoughtspace) **Status:** PROPOSAL / design (migration spec; NOT yet implemented). **Plan file:** `.claude/plans/bindspace-singleton-to-mailbox-soa-v1.md`. **Epiphany:** `E-MAILBOX-IS-BINDSPACE`. diff --git a/.claude/board/PR_ARC_INVENTORY.md b/.claude/board/PR_ARC_INVENTORY.md index f1e938b6..6dfc72e8 100644 --- a/.claude/board/PR_ARC_INVENTORY.md +++ b/.claude/board/PR_ARC_INVENTORY.md @@ -35,6 +35,15 @@ --- +## 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. ## PR #411 — Cognitive substrate: locked 33-TSV atom layer + 34-tactic recipes + escalation loop (MERGED 2026-05-27 → main) **Status:** MERGED. Branch `claude/splat3d-cpu-simd-renderer-MAOO0` → `main`, 39 commits. diff --git a/.claude/board/TECH_DEBT.md b/.claude/board/TECH_DEBT.md index cf222ac0..d506ad23 100644 --- a/.claude/board/TECH_DEBT.md +++ b/.claude/board/TECH_DEBT.md @@ -253,6 +253,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 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.* diff --git a/crates/lance-graph-callcenter/src/lib.rs b/crates/lance-graph-callcenter/src/lib.rs index 903c42d5..96e518d2 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 @@ -186,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, SavantSuggestion, +}; + // 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..f007e5e6 --- /dev/null +++ b/crates/lance-graph-callcenter/src/savant_reasoners.rs @@ -0,0 +1,494 @@ +//! 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 {} + +/// 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 +/// 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 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. + 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, + suggestion: suggestion_for(savant), + 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); + } + + #[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); + } + } +}