From 69a708060753761e42426de7c2f1746c21eada44 Mon Sep 17 00:00:00 2001 From: hhff Date: Sat, 15 Aug 2026 17:32:07 -0700 Subject: [PATCH 01/28] add clog spec, build design, fold capability answers, repo rules Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 20 + docs/clog-spec-v1.md | 590 ++++++++++++++++++ docs/fold-answers.md | 24 + .../specs/2026-08-15-clog-build-design.md | 183 ++++++ 4 files changed, 817 insertions(+) create mode 100644 CLAUDE.md create mode 100644 docs/clog-spec-v1.md create mode 100644 docs/fold-answers.md create mode 100644 docs/superpowers/specs/2026-08-15-clog-build-design.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..f5e627a --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,20 @@ +# Working in this repo + +This is a fork of BogKit (fold / ese / anny + examples). The main work here is +**clog**, an orientation engine library being built at `examples/clog/` against +the spec in `docs/clog-spec-v1.md` and the build design in +`docs/superpowers/specs/2026-08-15-clog-build-design.md`. Resolved fold +capability questions live in `docs/fold-answers.md`. + +## Standing rules + +- Any change to clog's public API or module map must update + `examples/clog/README.md` and the relevant rustdoc **in the same change**. +- Spec invariants (INV-1..13 in `docs/clog-spec-v1.md`) are enforced by named + tests; changing behavior a golden test freezes requires a spec edit first. +- All time comes from the Clock port; never read wall-clock outside it. No + HashMap iteration order at any output boundary. +- Iterate with `--no-default-features` for fast compiles (ese's embedded map is + slow to build); run full-feature tests before claiming a milestone done. +- Do not commit the garden3d observations corpus; only the human-reviewed, + pseudonymized sample in `examples/clog/tests/fixtures/` may land in git. diff --git a/docs/clog-spec-v1.md b/docs/clog-spec-v1.md new file mode 100644 index 0000000..60a7aac --- /dev/null +++ b/docs/clog-spec-v1.md @@ -0,0 +1,590 @@ +# Clog v1 — Implementation Specification + +**Status:** ready for implementation +**Deliverable:** a Rust library crate (`clog`), no binary, no network surface +**Out of scope for this repo:** MCP server, language bindings, connectors, any LLM calls +**Intended engine:** Bog / Fold (Flower Computer). A naive reference engine ships alongside it as the semantic oracle and interim runtime (see §7). + +--- + +## 1. Purpose + +Clog is an orientation engine for agentic systems. Hosts write structured **claims** (pre-interpreted observations about the world); Clog maintains **materialized views** over them incrementally and renders a token-budgeted **situation** document per **scope** (lens). Agents read the situation as context at turn time with zero retrieval work, subscribe to **wakes** when the world changes in ways they declared relevant, and close the loop with **corrections** that improve Clog's cheap local classifier. + +One sentence: connectors sense and interpret, Clog believes and ranks, agents read and act. + +Clog is generic across organizations. A design agency, a household device, and an open-source project all reduce to the same primitives: entities (people, projects, agreements, assets), claims about them (facts, risks, decisions, questions, commitments), and lenses that rank what matters right now. + +What Clog is not: a memory store queried at turn time (Zep, Mem0), a document/episode store, an extraction pipeline, or an agent framework. It never calls a model. It owns algebra, not judgment. + +--- + +## 2. Vocabulary + +| Term | Meaning | +|---|---| +| Claim | The unit of input. A structured, pre-interpreted assertion about the world, carrying identity, provenance, bitemporal timestamps, and graded trust. | +| claim_key | Stable identity of a claim. Writing a claim with an existing key supersedes the prior version (upsert = retract old + assert new). | +| subject_key | Optional grouping key naming *what the claim is about* (e.g. `halcyon:inv-1042:status`). Claims sharing a subject_key compete in belief resolution. | +| Entity | A typed reference (`etype`, `id`, optional display name) a claim concerns. Free-form types; suggested defaults exist. | +| Kind | The claim's classification (risk, decision, question, commitment, fact, ...). Derived, never stored on the claim. Taxonomy is config-declared. | +| Scope | A named lens. Each scope has a Focus and its own materialized ranking and situation document. Scopes are concurrent; all are warm simultaneously. | +| Focus | The ranking parameters of a scope: kind weights, entity boosts, half-life. Data, not code. | +| Situation | The rendered, budgeted orientation document for one scope. A read of it is a snapshot copy, never a computation. | +| rev | Monotonic u64. Global rev bumps per committed write batch. Each scope's situation carries the global rev at which its text last changed. | +| Wake | A push notification that a watched view changed materially, carrying the diff and the rev. | +| Exemplar | A labeled example (body, kind) used by the built-in kNN classifier. Stored as a claim in the reserved `clog:` namespace. | +| Merge | An entity-alias assertion. Stored as a claim in the reserved namespace; retracting it un-merges. | + +--- + +## 3. Design invariants + +These are numbered because tests reference them. Every invariant must have at least one test. + +- **INV-1 Write-time work, read-time zero.** `situation()` returns a pre-rendered string (an `Arc` clone of the current snapshot). No ranking, searching, or rendering occurs on the read path. Same for `select` (snapshot iteration only). +- **INV-2 Judgments are views.** The stored claim carries no kind, salience, status, or ttl. All such properties are derived and live in views. Changing derivation logic plus replaying the log re-derives the world. +- **INV-3 Retraction heals everything.** For any claim c: `observe([c]); retract(c.key)` leaves every view and every situation text identical to never having observed c. (rev counters may differ; text may not.) +- **INV-4 Upsert is supersession.** `observe([c1]); observe([c2])` with equal keys is view-equivalent to `observe([c2])` alone. +- **INV-5 Idempotence.** Re-observing a byte-identical claim (same content hash) produces zero view diffs and no situation re-render. +- **INV-6 Provenance is revocable.** `revoke_observer(o)` is view-equivalent to retracting every live claim whose observer == o. +- **INV-7 Beliefs are global, rankings are scoped.** Belief resolution (entity_state) is identical in every scope. Only ordering/membership of `urgent` and the rendered situation differ per scope. +- **INV-8 Reserved namespace is invisible.** Claims whose claim_key starts with `clog:` never appear in live, urgent, open_loops, entity_state, recall results, or any rendered situation. External `observe` of a `clog:`-prefixed key is rejected. +- **INV-9 rev monotonicity.** Global rev strictly increases per committed batch and survives restart. A situation's rev never decreases. Wakes are delivered in rev order per watch. +- **INV-10 Determinism.** Given the same event log and the same manual clock, all views, situations, and revs are byte-identical across runs and across engines (naive vs fold). No wall-clock reads outside the Clock port. No HashMap iteration order may leak into any output (use ordered structures or explicit sorts at every output boundary). +- **INV-11 Crash safety.** Clog owns an append-only event WAL as the source of truth. Engine state (including the Fold db and semantic index) is a rebuildable cache. After a crash at any point, reopen restores a state equal to replaying the WAL prefix that was durably committed. +- **INV-12 Serializability of the surface.** Every public API type derives `serde::{Serialize, Deserialize}`. No closures, trait objects, or lifetimes in the public surface (the MCP project binds on top of this). +- **INV-13 Anonymous lenses are impossible.** Ranking parameters enter only via `set_focus(scope, focus)` or `Config.scopes`. `situation()` accepts only a scope name. Lenses are cheap and creatable at runtime, but they have names, stable revs, and watchability. + +--- + +## 4. Public API + +Thirteen functions. This is the complete v1 surface; anything not listed here is internal. + +```rust +pub struct Clog; // cheap-clone handle: Clone + Send + Sync + +impl Clog { + pub fn open(cfg: Config) -> Result; + + // ---- WRITE (one committed batch per call, one rev bump) ---- + pub fn observe(&self, claims: Vec, opts: ObserveOpts) -> Result; + pub fn retract(&self, claim_key: &str) -> Result; + pub fn revoke_observer(&self, observer: &ObserverId) -> Result; + + // ---- READ (snapshot only; INV-1) ---- + pub fn situation(&self, scope: Option<&str>, template: Option<&str>) + -> Result; + pub fn select(&self, view: View, filter: Filter) -> Result, ClogError>; + pub fn recall(&self, query: &str, k: usize) -> Result, ClogError>; + + // ---- ATTENTION ---- + pub fn set_focus(&self, scope: &str, focus: Focus) -> Result; + // unknown scope name = declare-and-materialize; known = re-rank that lens + + // ---- WAKE ---- + pub fn watch(&self, spec: WatchSpec) -> Result; + pub fn wakes(&self) -> crossbeam_channel::Receiver; + pub fn unwatch(&self, id: WatchId) -> Result<(), ClogError>; + + // ---- LEARN ---- + pub fn correct(&self, claim_key: &str, judgment: Judgment) -> Result; + // fixes the kind for that claim AND appends an exemplar claim (clog: namespace) + pub fn merge_entities(&self, alias: &EntityRef, canonical: &EntityRef) + -> Result; + // writes a merge claim clog:merge:{alias}->{canonical}; retract() of that key un-merges +} +``` + +### 4.1 Data types + +```rust +#[derive(Clone, Serialize, Deserialize)] +pub struct Claim { + pub claim_key: String, // identity; upsert key; <= 256 bytes; not clog:* + pub subject_key: Option, // belief-competition group; <= 256 bytes + pub source_ref: String, // provenance URI/id, links out; <= 1024 bytes + pub observer: ObserverId, // "gmail-v3", "twist-v1", ... + pub schema_v: u16, // stored, not interpreted in v1 + pub occurred_at: u64, // unix ms, when true in the world + pub observed_at: u64, // unix ms, when sensed + // recorded_at is NOT caller-supplied; Clog assigns it at commit + pub reliability: Reliability, // A..F (Admiralty source grade) + pub credibility: Credibility, // One..Six (1 = confirmed, 6 = cannot judge) + pub entities: Vec, // <= 32 + pub body: String, // <= 16 KiB, the human-readable assertion +} + +#[derive(Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct EntityRef { + pub etype: String, // free-form: "person", "project", "agreement", ... + pub id: String, + pub name: Option, // display; latest-seen wins in the registry +} + +pub struct ObserveOpts { pub return_situation: Option } // scope name + +pub struct Ack { pub rev: u64, pub situation: Option } + +pub struct Situation { pub scope: String, pub text: String, pub rev: u64, pub as_of: u64 } + +#[derive(Serialize, Deserialize)] +pub enum View { Live, EntityState, OpenLoops, Urgent { scope: String }, Unclassified } + +#[derive(Default, Serialize, Deserialize)] +pub struct Filter { + pub kinds: Option>, + pub entities: Option>, // matches on (etype,id), post-alias + pub observer: Option, + pub subject_prefix: Option, + pub occurred_after: Option, + pub min_score: Option, // valid only with View::Urgent + pub limit: Option, // default 50, max 500 +} + +pub struct Row { + pub claim: Claim, pub recorded_at: u64, + pub kind: Option, // KindLabel { kind, confidence, source } + pub score: Option, // present for Urgent + pub believed: Option, // present when subject_key set +} + +pub struct Hit { pub claim_key: String, pub distance: f32, pub headline: String } + +#[derive(Serialize, Deserialize)] +pub struct Focus { + pub weights: BTreeMap, // kind -> weight; missing kind = 1.0 + pub boosts: Vec<(EntityRef, f32)>, // multiplicative per matched entity + pub half_life_days: f32, // default 7.0 + pub top_k: Option, // overrides Config.top_k for this scope +} + +#[derive(Serialize, Deserialize)] +pub struct WatchSpec { + pub scope: String, + pub view: View, + pub filter: Filter, // declarative predicate (INV-12) + pub debounce_ms: u64, // default 500 +} + +pub struct Wake { + pub watch_id: WatchId, pub scope: String, pub rev: u64, + pub added: Vec, pub removed: Vec, +} +pub struct WakeItem { pub claim_key: String, pub kind: Option, pub headline: String } +// headline = first 120 chars of body, whitespace-collapsed + +pub struct Judgment { pub kind: String } // must be in the configured taxonomy +``` + +Notes: +- `observe` takes a `Vec` so connector bursts commit as one batch, one rev, one re-render pass. A batch of one is the common case and is fine. +- Watches are ephemeral: not persisted, hosts re-register after `open`. Document this in rustdoc. +- There is no `unmerge`, `unlearn`, or scope-delete call. Merges and exemplars are reserved-namespace claims, so `retract` covers the first two. Scope GC is a non-goal in v1 (§13); warn in rustdoc against unbounded dynamic scope creation. + +--- + +## 5. Semantics + +### 5.1 Write path + +Every write call becomes one `Batch` of internal `Event`s, appended to the WAL, then applied to the engine, then post-processed (classify, re-render, wake evaluation), atomically from the reader's point of view (readers see the pre-batch snapshot until the post-batch snapshot is swapped in). + +```rust +enum Event { + Observe(Claim /* recorded_at filled */), + Retract { claim_key: String }, + Revoke { observer: ObserverId }, + SetFocus { scope: String, focus: Focus }, + Judge { claim_key: String, kind: String, confidence: f32, source: JudgeSource }, + Tick { epoch: u64 }, // decay-bucket epoch, see 5.5 +} +enum JudgeSource { Rule, Knn, External } // correct() emits External +``` + +`observe` internals, per claim, in order: +1. Validate (§10). Reject the whole batch on any invalid claim (atomic batches; no partial commit). +2. Assign `recorded_at` from the Clock port. +3. Compute content hash over all fields except recorded_at. If a live claim with the same claim_key has an equal hash, skip entirely (INV-5). +4. If a live claim with the same claim_key exists with a different hash, emit retraction of the old version then assertion of the new (INV-4). +5. After engine apply, run the classifier cascade (5.6) for claims lacking a confident kind; confident results append `Judge` events to the same batch before render. + +`retract` on an unknown or already-retracted key returns `ClogError::UnknownClaim` (idempotent hosts can ignore it). + +`revoke_observer` expands, at apply time, to retractions of every live claim with that observer, including reserved-namespace claims it wrote. Requires an observer-to-live-keys index view (or engine scan; see F8). + +### 5.2 Alias resolution (merge_entities) + +A merge writes claim `clog:merge:{alias.etype}:{alias.id}->{canonical.etype}:{canonical.id}` with the two refs in the body (JSON). Semantics: +- Stored alias edges are always depth-1. If `canonical` is itself currently aliased to c2, the edge written is alias -> c2 (write-time flattening). This keeps the canonical map non-recursive: no transitive closure is ever computed in a view. +- Merging in the other direction later (b -> a after a -> b) is rejected with `ClogError::AliasCycle`. +- The canonical map is a view: alias(etype,id) -> canonical(etype,id). Every view that groups or filters by entity resolves through this map. Retracting the merge claim removes the edge; grouped views re-key accordingly (this is the expensive retraction; it is proportional to claims touching the alias, which is acceptable). +- Entity display names: registry keeps the latest-seen non-null `name` per canonical entity (ordered by recorded_at). + +### 5.3 Belief resolution (entity_state) + +Applies only among live claims sharing a `subject_key` (post-alias). The believed claim is selected by this total order: + +1. later `occurred_at` wins; +2. tie: better `reliability` (A > B > ... > F); +3. tie: better `credibility` (1 > 2 > ... > 6); +4. tie: later `recorded_at`; +5. tie: lexicographically larger claim_key (final deterministic tiebreak). + +Claims with `credibility` worse than `Config.belief.min_credibility` (default: Six allowed, i.e. no floor) are excluded from winning unless they are the only live claim for the subject. Losing claims remain live (they appear in Live and can be selected/recalled) but `believed == false`. + +Known limitation, accepted for v1: a fresh low-trust claim can override an older high-trust one. Mitigations are host-side (`retract`, `correct`) plus the config floor. Document prominently. + +`EntityState` view rows: for each canonical entity, the believed claim per subject_key plus (for rendering) up to N recent believed/standalone claims (N = 8, internal constant). + +### 5.4 Scoring and urgency + +For scope s with focus f, for each live, non-reserved claim c with kind k (unclassified claims score with kind weight 1.0): + +``` +score(c, s) = weight(f, k) * trust(c) * recency(c) * boost(f, c) + +weight(f,k) = f.weights.get(k).unwrap_or(1.0) +trust(c) = REL[c.reliability] * CRED[c.credibility] + REL: A=1.00 B=0.90 C=0.75 D=0.50 E=0.25 F=0.10 + CRED: 1=1.00 2=0.90 3=0.75 4=0.50 5=0.25 6=0.10 +recency(c) = 0.5 ^ (bucket_age(c) / f.half_life_days) // bucketed, see 5.5 +boost(f,c) = product of factor for every (entity, factor) in f.boosts + whose entity matches any of c.entities post-alias (else 1.0) +``` + +`Urgent{scope}` = top K live claims by score, K = focus.top_k or Config.top_k (default 12). Ordering ties broken by claim_key. Scores are recomputed only on diffs: claim changes, focus changes, alias changes, kind changes, and bucket-crossing ticks. + +### 5.5 Clock, decay buckets, ticks + +All time comes from the Clock port (`System` or `Manual`; Manual is mandatory in tests, INV-10). Recency is quantized: `bucket_width_days = half_life_days / buckets_per_half_life` (config, default 4). `bucket_age(c) = floor(age_days / bucket_width) * bucket_width + bucket_width/2` (bucket midpoint). + +The tick driver (interval = Config.tick.interval, default 60s; or manual `advance()` on the test handle) emits a `Tick{epoch}` event only when at least one live claim crosses a bucket boundary in at least one scope, and the engine re-scores only the crossing claims. A tick that changes no bucket is a no-op and must not bump rev or re-render (bench B4 asserts this). + +### 5.6 Kinds: the classifier cascade + +Taxonomy is config-declared. Default: + +``` +fact, decision, risk, question, commitment, agreement, opportunity, fyi +``` + +Each kind may declare seed rules and seed exemplars in config. Cascade per unclassified claim: + +1. **Rules tier** (free): first matching rule wins with confidence 1.0. Rule = `{ any_of: Vec }`, Matcher = `BodyContains(str, case_insensitive)` | `BodyRegex(str)` | `ObserverIs(str)` | `EntityType(str)`. Evaluated in config order. +2. **kNN tier** (cheap, requires `semantic` feature): ESE-embed the body, query the exemplar index for k=Config.knn.k (5) nearest with cosine similarity >= min_sim (0.80). If >= min_votes (4) agree on one kind, assign it with confidence = votes/k. +3. **No confident result:** the claim lands in the `Unclassified` view with any partial vote attached. That view is the escalation queue: hosts watch it, classify upstream (their LLM), and resolve via `correct()`. Clog itself never escalates and never calls a model. + +`correct(claim_key, judgment)` emits `Judge{source: External, confidence: 1.0}` (overriding any prior kind) and appends an exemplar claim `clog:exemplar:{hash(body)}` whose body is the corrected claim's body and whose judgment is stored in a `kind` field of its JSON body. Exemplar claims feed only the kNN index (INV-8 keeps them out of everything else). Retracting an exemplar claim unlearns it. + +### 5.7 Views (complete v1 list) + +| View | Definition | Keyed by | +|---|---|---| +| live | current versions of non-retracted, non-reserved claims | claim_key | +| kinds | claim_key -> KindLabel (kind, confidence, source) | claim_key | +| unclassified | live minus confidently-kinded | claim_key | +| entity_state | believed claim per (canonical entity, subject_key) + registry (names) | entity | +| open_loops | live claims whose kind is in Config.loop_kinds (default: question, risk, commitment) | claim_key | +| urgent[scope] | top-K by score(claim, scope) | scope, rank | +| semantic index | ESE vector per live claim body + exemplar store (ANNy) | claim_key | +| changes[scope] | membership delta of (urgent ∪ open_loops) between the last two rendered revs of the scope | scope | + +`recall(query, k)`: embed query with ESE, ANNy search over live-claim vectors (exemplars excluded), return up to k Hits ordered by ascending distance. `recall` is the one deliberate pull in the design (warm tier); everything else is push. + +### 5.8 Rendering and templates + +The renderer is deterministic. A template is a UTF-8 string passed by value per call (hosts own and version their templates; Clog stores none). Grammar: + +``` +template := ( text | slot )* +slot := "%{" name ( WS+ key "=" value )* "}" +name := "header" | "urgent" | "open_loops" | "entities" | "changes" +key := "limit" (usize; per-slot cap) +``` + +Unknown slot names or malformed slots are a `TemplateError` at call time. The default template (used when `template == None`, and baked into the crate): + +``` +# situation · scope: %{header} + +## urgent +%{urgent limit=8} + +## open loops +%{open_loops limit=10} + +## entities +%{entities limit=10} + +## changes since last brief +%{changes limit=6} +``` + +Slot renderings (exact formats are frozen by golden tests, §11.4): +- header: `{scope} · rev {rev} · {as_of RFC3339}` +- urgent item: `{rank}. ({score:.1}) {headline} [{reliability}/{credibility}] ({claim_key})` +- open_loops item: `- {KIND} {headline} ({claim_key})` +- entities item: `{display or etype:id}: {believed subject summaries, "; "-joined, newest first}` +- changes item: `+ {headline}` / `- {headline}` + +Budgeting: render all slots, then if total chars > Config.budget_chars (default 6000), truncate whole items from the end of slots in reverse priority order (changes, entities, open_loops, urgent) until under budget, appending `… ({n} more)` per truncated slot. Never truncate mid-item. + +Re-render policy per scope: after each batch (and material tick), re-render only if the scope's slot inputs changed (membership, order, or any rendered field). If the produced text differs from current, swap it in and set situation.rev = batch's global rev. Immaterial batches must not change any scope's rev (INV-5, B4). + +### 5.9 Watches and wakes + +After each committed batch, for each watch: compute the filtered membership of its view at pre- and post-snapshots; if the delta is non-empty, buffer it. Deltas buffer per watch and flush after `debounce_ms` of quiet (coalescing intermediate adds/removes; an item added then removed within the window cancels out). Wake.rev = the rev of the last batch in the coalesced window. Delivery: one unbounded crossbeam channel per Clog instance (`wakes()` returns a clone of the receiver); per-watch ordering by rev is guaranteed (INV-9); cross-watch ordering is not. + +### 5.10 rev model + +- `global_rev`: u64, +1 per committed batch (including SetFocus, Judge-carrying, and material Tick batches). Persisted in the WAL; restored on open. +- `Ack.rev` = global_rev of the caller's batch. +- `Situation.rev` = global_rev at which that scope's text last changed. +- Skew is expected and meaningful: `ack.rev > situation(s).rev` means the write did not affect s. + +--- + +## 6. Architecture + +### 6.1 Actor model and concurrency + +- One writer thread owns the engine and the WAL. All write calls send a command over a bounded channel (`Config.write_queue`, default 1024) and block on a oneshot reply. Backpressure = blocking send. +- Readers never touch the engine. After each batch, the writer publishes an immutable `WorldSnapshot` via `arc_swap::ArcSwap`. `situation`, `select`, and `recall` read the current snapshot only (INV-1). `recall`'s ANNy index is part of the snapshot publication (copy-on-write handle or epoch-guarded read; the index must never be searched mid-mutation). +- The tick driver is a thread (System clock) or absent (Manual clock; the test handle exposes `advance(ms)` which injects Tick batches through the same writer channel). +- `Clog` handle: `Clone + Send + Sync`. Drop of the last handle shuts down threads cleanly (join, fsync WAL). + +``` +WorldSnapshot { + rev, as_of, + live: OrdMap, + kinds, unclassified, entity_state, open_loops, + urgent: BTreeMap>, + situations: BTreeMap, + aliases: canonical map, + semantic: Arc, +} +``` + +### 6.2 Engine port (ports and adapters) + +The IVM engine is behind a trait so Fold is a backend, not a foundation. This is the single most important architectural decision in the spec: + +```rust +pub(crate) trait Engine: Send { + /// Apply one batch of events; return per-view diffs sufficient for + /// snapshot construction, wake evaluation, and re-render decisions. + fn apply(&mut self, batch: &[Event], now_epoch: u64) -> ApplyResult; + /// Full state for snapshot (re)construction, e.g. on open. + fn dump(&self) -> EngineDump; +} +``` + +Backends: +- `engine::naive` (always compiled): in-memory ordered maps, full but *targeted* recomputation (only structures reachable from the batch's touched keys/scopes). This is the **semantic oracle**: its behavior *is* the spec. It must be simple enough to audit by eye. +- `engine::fold` (feature = "fold"): the Bog/Fold adapter. Correctness is defined as byte-equality of snapshots with `naive` under differential testing (§11.3). Ships only when the F-checklist (§8) is confirmed or fallbacks are implemented. + +Because Clog owns the WAL (INV-11), engine state is disposable: `Config.rebuild_on_open = true` drops engine state and replays the WAL. This is also the mechanism for "re-orientation by replay" when classification config changes. + +### 6.3 Persistence + +- WAL: append-only file of length-prefixed bincode `Batch` records, each with a CRC32 and the assigned rev. fsync policy = `Config.wal.fsync` (default OnCommit). Torn tail records are truncated on open (R2). +- Snapshot file (naive engine only): periodically (every `Config.snapshot_every_batches`, default 512) the writer serializes `EngineDump` + rev; open = load latest valid snapshot, replay WAL tail. +- The Fold db (fold feature): durable per wtx (F7 confirmed). Each batch's wtx also writes the global rev to a meta record; reopen reads it and replays only the WAL tail beyond it. If the db fails to open, is corrupt, or its rev exceeds the WAL's (impossible under the write-ahead ordering below, hence treated as corruption), rebuild from WAL. Write-ahead ordering is mandatory: WAL append + fsync completes before engine apply; `wal_fsync = OnCommit` is required when the fold feature is enabled. +- Semantic index: rebuilt from live claims on open in v1 (embedding is fast; measure in B5). Persisting ANNy is a v2 optimization. + +### 6.4 Module map + +``` +clog/ + src/lib.rs public API, handle, actor wiring + src/types.rs Claim, EntityRef, Focus, Filter, ... (all serde) + src/validate.rs §10 + src/score.rs pure: trust tables, recency buckets, score() + src/belief.rs pure: total order, floor + src/alias.rs canonical map + flattening + cycle check + src/kinds.rs taxonomy, rules tier, cascade driver + src/render/ template parser + slot renderers + budgeter + src/engine/mod.rs Engine trait, Event, ApplyResult, EngineDump + src/engine/naive.rs + src/engine/fold.rs feature "fold" + src/semantic.rs feature "semantic": ESE embed + ANNy wrapper + tombstones + src/wake.rs watch registry, delta buffering, debounce + src/wal.rs append, replay, snapshotting, CRC + src/clock.rs Clock port (System | Manual) + src/actor.rs writer loop, snapshot publication + tests/ §11 + benches/ §11.6 +``` + +Dependency policy: std + serde + thiserror + crossbeam-channel + arc-swap + bincode + crc32fast + regex (rules tier). Fold/ESE/ANNy as git dependencies behind features. Nothing else without a spec change. rustc stable, edition 2024. + +--- + +## 7. Build order note for the implementing agent + +Semantics land on the naive engine first. Do not begin `engine/fold.rs` until the golden simulation (§11.4) passes on naive. F4/F7/F11 answers are recorded below; log any further Flower answers in `docs/fold-answers.md` as they arrive. If a Fold capability is missing, implement the listed fallback *inside the adapter*, never by weakening the Engine trait or the invariants. The naive engine is not throwaway: it ships permanently as the differential oracle and the no-fold fallback runtime. + +--- + +## 8. Bog / Fold dependency checklist (for the Flower Computer conversation) + +The public docs demonstrate only: `Stream::new(path, graph)`, `FlatMap`, the `Bag` terminal, `wtx { insert / remove }`, `rtx` reads, and signed-diff semantics. **Status update:** the Flower Computer team has confirmed F4 (terminal trait implementable), F7 (durable on write), and F11 (ANNy deletion). Items below are marked accordingly; unconfirmed items retain fallbacks. + +- **F1. Fan-out.** One input stream feeding many view branches (a `Tee`-like combinator). *Why:* every view hangs off one claim stream. *Fallback (now trivial given F4):* a single composite terminal that routes each incoming diff to all view-maintaining sub-structures internally. Prefer native fan-out if it exists; the composite terminal is architecturally equivalent. +- **F2. Keyed lookup inside wtx.** Fetch current record by key to implement upsert (retract old + assert new). *Why:* INV-4. *Fallback:* Clog maintains its own claim_key -> content-hash/version map outside Fold (it already must, for INV-5 hashing) and issues explicit remove(old)+insert(new); Fold never needs lookups. +- **F3. Stateful keyed reduces.** GroupBy + custom reduce (BestBelief, latest-name, TopK). *Why:* entity_state and urgent. *Fallback:* implement as custom terminals (F4) holding ordered state, consuming ±diffs. +- **F4. Public, implementable terminal trait. CONFIRMED by Flower Computer.** Custom terminals are the implementation vehicle for: SemanticIndex, SituationRender trigger, changes tracker, and every stateful reduce in F3/F5. This was the make-or-break item; it passed. Build all stateful views as terminals from the start rather than waiting on native GroupBy/TopK combinators. +- **F5. Joins, or parameterized re-scoring.** urgent = claims x focus. *Why:* set_focus re-ranks one lens. *Fallback:* focus lives in the urgent terminal's state; a SetFocus event triggers full re-score of that scope inside the terminal (small: it is one scope's live set). +- **F6. Multiple input types on one stream.** An `Event` enum as the stream item, or multiple Streams over one db file. *Why:* claims, ticks, judges, focus in one ordered log. *Assumption in this spec:* single stream of `Event`. Confirm enum items and per-variant routing are idiomatic. +- **F7. Crash and reopen semantics. CONFIRMED: durable on write.** Every committed wtx persists. Consequences for the adapter: (a) the fold db must store the last-applied global rev (a small meta record written in the same wtx as each batch); reopen = read that rev, replay only the WAL tail beyond it; (b) the snapshot-file machinery of §6.3 becomes naive-engine-only — the fold db *is* the snapshot; (c) **ordering constraint:** engine apply must never precede WAL durability for the same batch, otherwise a crash leaves the engine ahead of the source of truth. Therefore `wal_fsync = OnCommit` is mandatory when the fold feature is enabled (naive may use Interval; its tail loss is consistent because nothing else persisted those events). +- **F8. Predicate scan over stored items.** *Why:* revoke_observer expansion. *Fallback:* observer -> live-claim-keys index maintained as its own view (do this anyway; scanning is O(n)). +- **F9. Read/write concurrency.** Does rtx block wtx? Snapshot isolation? *Why:* actor design; we copy out to ArcSwap regardless, but need to know if copy-out must happen inside the write critical section. +- **F10. Cost model of remove+insert vs update.** *Why:* every upsert is a remove+insert; confirm no pathological amplification in downstream terminals. +- **F11. ANNy deletion. CONFIRMED: native remove exists.** Use it as the primary path; drop the tombstone-at-search-time machinery. Keep bench B5's churn assertion regardless — HNSW deletion can silently degrade graph connectivity and recall even where the API exists, and B5 is the only thing that would catch it. `semantic_rebuild_tombstone_ratio` survives as a contingency (default 1.0 = disabled): if B5 shows recall decay under churn, ratio-triggered rebuild is the escape hatch without an API change. +- **F12. ESE provenance.** License, training source of the embedding map, fixed vocabulary behavior on OOV tokens, stability of DIMENSIONS across versions (persisted vectors), quantization features. *Why:* shipping in a commercial device later; index compatibility. +- **F13. Batch ergonomics and backpressure.** Cost of a 1k-insert wtx; any size limits. +- **F14. Recursion.** Confirm *not needed* is acceptable long-term: v1 avoids it by design (depth-1 alias flattening, no graph closure). Ask what their roadmap is for iterative computation anyway (v2 relations). + +--- + +## 9. Configuration reference + +```rust +pub struct Config { + pub path: PathBuf, // directory; Clog creates wal/, snap/, fold.db + pub scopes: BTreeMap, // "default" injected if absent (uniform Focus) + pub kinds: KindTaxonomy, // default taxonomy of §5.6 if empty + pub loop_kinds: Vec, // default ["question","risk","commitment"] + pub top_k: usize, // 12 + pub budget_chars: usize, // 6000 + pub tick: TickConfig, // { mode: System|Manual, interval: 60s } + pub decay_buckets_per_half_life: u32, // 4 + pub belief_min_credibility: Credibility, // Six (no floor) + pub knn: KnnConfig, // { k:5, min_votes:4, min_sim:0.80 } + pub semantic_enabled: bool, // true (feature-gated) + pub semantic_rebuild_tombstone_ratio: f32, // 1.0 = disabled (contingency; native delete is primary) + pub wal_fsync: FsyncPolicy, // OnCommit + pub snapshot_every_batches: u64, // 512 + pub write_queue: usize, // 1024 + pub rebuild_on_open: bool, // false +} + +pub struct KindTaxonomy { pub kinds: Vec } +pub struct KindDef { + pub name: String, + pub rules: Vec, // §5.6 matchers + pub seed_exemplars: Vec, // bodies labeled with this kind +} +``` + +`Config::default_for(path)` gives a working single-scope instance. The crate includes two example configs proving genericity: `examples/agency.rs` (kinds and scopes for a client-services studio) and `examples/household.rs` (a home device: chores as commitments, appliances as entities, per-member scopes). + +--- + +## 10. Validation rules (observe-time; whole batch rejected on first failure) + +- claim_key: non-empty after trim, <= 256 bytes, must not start with `clog:` (INV-8), no control chars. +- subject_key/source_ref: same char rules; <= 256 / <= 1024 bytes. source_ref must be non-empty. +- observer: non-empty, <= 128 bytes. +- body: non-empty after trim, <= 16 KiB. +- entities: <= 32; each etype and id non-empty, <= 128 bytes. +- timestamps: occurred_at and observed_at > 0. Values beyond now + 24h are clamped to now for scoring but stored verbatim (log truth; warn via tracing). occurred_at > observed_at is allowed (predictions/backdated corrections) and not warned. +- Judgment.kind and Focus.weights keys must exist in the taxonomy; Focus values must be finite and > 0; half_life_days in (0.01, 3650). +- Template: parse errors reject the call only (never poison state). + +Error taxonomy (`thiserror`): `InvalidClaim { index, reason }`, `ReservedNamespace`, `UnknownClaim`, `UnknownScope`, `UnknownKind`, `AliasCycle`, `TemplateError`, `Storage(io)`, `Corrupt { detail }`, `ShuttingDown`. + +--- + +## 11. Testing plan + +Testing is the spec's enforcement mechanism. Every INV maps to at least one named test. Framework: `cargo test` + `proptest` + `insta` (golden) + `criterion` (bench) + `cargo-fuzz` (parser). All tests run with `Clock::Manual` (INV-10). CI gates: all of §11.1–11.5 green, benches within budgets on the reference machine, `cargo doc` clean, no `unwrap` outside tests. + +### 11.1 Unit (pure functions; table-driven) + +- U-SCORE-1: score() against a fixed table of (kind, rel, cred, age, focus) -> expected value, incl. bucket midpoints and boost stacking. +- U-BELIEF-1: total order of §5.3 across permuted inputs; U-BELIEF-2: credibility floor incl. only-claim exception. +- U-ALIAS-1: write-time flattening; U-ALIAS-2: cycle rejection; U-ALIAS-3: retraction of a merge re-keys grouped views. +- U-TMPL-1: grammar accept/reject table; U-TMPL-2: budget truncation order and `… (n more)` markers; U-TMPL-3: default template byte-stability. +- U-VAL-1: every rule of §10, positive and negative. +- U-KIND-1: rule tier ordering; U-KIND-2: kNN vote thresholds incl. tie at min_votes-1 -> Unclassified. + +### 11.2 Property-based (proptest; naive engine; each property also asserts INV-9/10) + +Claim generator: arbitrary valid claims over a small alphabet of entities/observers/subjects so collisions occur. + +- P1 (INV-3): random interleave of observes then retract-all == empty world (situation text per scope equals empty-world render). +- P2 (INV-4): for random claim sequences with shared keys, final views depend only on the last version per key. +- P3 (INV-5): duplicate any prefix of a sequence; snapshots byte-equal; scope revs unchanged by duplicates. +- P4 (INV-6): revoke(o) == retract every live claim of o, snapshot-equal. +- P5 (INV-7): for random multi-scope configs, entity_state identical across scopes while urgent orderings differ only per focus. +- P6 (order-insensitivity of belief): shuffling arrival order of claims sharing a subject_key never changes the winner (occurred_at et al. fixed). +- P7 (merge round-trip): observe C with entity a; merge a->b; retract merge == never merged, snapshot-equal. +- P8 (wake soundness/completeness): for random watch filters, the set of coalesced wake items equals the membership delta between the watch's first and last snapshot in the window; no wake from immaterial batches. + +### 11.3 Differential (the fold gate) + +- D1: generate 10k-event random sequences (all Event variants, manual ticks); apply to naive and fold engines; assert byte-equal WorldSnapshots after every batch. Run in CI with fixed seeds + nightly with random seeds. **fold feature cannot merge while D1 fails.** +- D2: crash-point differential — replay the same WAL prefix into a fresh naive engine vs a reopened fold-backed instance; snapshots equal (pairs with R-tests). + +### 11.4 Golden simulation (insta snapshots) + +- G1: the "client-services studio" fixture: 10 claims across three clients (a slipping deliverable, an overdue invoice observed by two sources then superseded by a bank-feed payment claim with earlier occurred_at, an inbound lead, a PTO fact colliding with a moved kickoff, a client question later self-resolved via retraction, an ambiguous upsell that lands in Unclassified and is resolved via correct()), two focus changes (`delivery-health` -> `cash-and-collections`). Snapshot the situation text at four checkpoints (A–D) plus the changes slot and Unclassified contents. This fixture doubles as `examples/agency.rs`. +- G2: household fixture on a second scope set (proves genericity; one shared worldview, two lenses with disjoint boosts). +- Golden files are the frozen rendering contract of §5.8; changing them requires a spec edit. + +### 11.5 Recovery and concurrency + +- R1: kill the process (abort) between WAL append and snapshot publication at randomized points across G1; reopen; state equals WAL replay (INV-11). +- R2: torn final WAL record (truncate mid-record, corrupt CRC); reopen succeeds at prior rev; corrupted tail quarantined to `wal.corrupt`. +- R3: rebuild_on_open=true equals normal open, snapshot-equal. +- C1: N writer threads x M reader threads hammering observe/situation/select for 10s under `--cfg loom` for the ArcSwap publication path (readers never observe a partial snapshot); plus a plain stress test asserting rev monotonicity and no deadlock at write_queue saturation. + +### 11.6 Benchmarks (criterion; budgets on an M-series laptop, release, semantic on) + +- B1: observe batch=1 p99 < 2 ms at 100k live claims (excluding fsync; report both fsync policies). +- B2: observe batch=1000 completes < 250 ms at 100k live. +- B3: situation()/select() p99 < 50 µs regardless of world size (they are snapshot reads; this bench exists to catch INV-1 regressions). +- B4: no-crossing tick cost < 100 µs and zero rev bumps over 1h of simulated ticks on a quiet world. +- B5: semantic — index rebuild of 100k claims < 20 s; recall@10 vs brute-force cosine >= 0.95 on a synthetic corpus, re-measured after 50% churn using native ANNy deletion (guards the F11 confirmation; recall decay here re-activates the ratio-rebuild contingency). +- B6: reopen (snapshot + 511-batch WAL tail) < 1 s at 100k live. + +### 11.7 Fuzz + +- Z1: template parser (cargo-fuzz, arbitrary bytes; must never panic). +- Z2: WAL reader against arbitrary file corruption (must never panic; must never apply a record failing CRC). + +--- + +## 12. Milestones and exit criteria + +- **M0 — types and pure core.** types.rs, validate.rs, score.rs, belief.rs, alias.rs, template parser. Exit: §11.1 green. +- **M1 — naive engine, renderer, WAL.** Views of §5.7 (minus semantic), rendering, batching, rev model, persistence. Exit: P1–P7, G1, R1–R3 green. +- **M2 — scopes, focus, ticks, wakes.** set_focus declare-or-steer, bucketed decay, watch/debounce/coalesce. Exit: P5, P8, B3, B4 green; G1 extended with the focus-shift checkpoints. +- **M3 — semantic + learn.** ESE/ANNy wrapper, tombstones, recall, kNN tier, correct()/exemplars, Unclassified queue. Exit: U-KIND-*, B5 green; G1 exercises the correct() path. +- **M4 — fold adapter.** Behind `feature = "fold"`. F4/F7/F11 are confirmed (build terminals, rev-meta record, native deletion directly); F1/F2/F6/F9 fallbacks apply only if surprises surface. Exit: D1, D2 green; B1/B2 re-run on fold and recorded (note the double-fsync cost under OnCommit). +- **M5 — hardening and freeze.** Fuzz targets, loom pass, rustdoc on every public item with examples, examples/ compile as doctests, CHANGELOG, API freeze tag `v0.1.0`. + +Definition of done for the crate: a host can, in under 30 lines, open Clog, declare two scopes, observe the G1 fixture, read two different situations, receive a wake on an invoice risk, correct a misclassification, and watch the situation heal after a retraction — with the fold feature off. + +--- + +## 13. Non-goals (v1) and parking lot (v2) + +Explicit non-goals: episode/raw-artifact storage and extraction (upstream), any model invocation, `Claim::Relation` / typed edges / k-hop neighborhoods, entity-resolution *proposals* (only explicit merge_entities; auto-suggest is v2), scope deletion/GC, retention policies, multi-process or networked access, authn/z, token-exact budgeting (chars only), template storage, persisted watches, ANNy persistence. + +Parking lot, in likely order: relations + neighborhood view (needs F14 answer), auto-suggested merges from ESE similarity, per-scope claim visibility filters (the privacy lens), WAL compaction/claim TTL at the log level, replay-with-new-taxonomy tooling, Feldera adapter as a second Engine impl. + +--- + +## 14. Open questions with chosen defaults (implementation never blocks on these) + +1. Trust/decay constants (§5.4) are unvalidated guesses -> ship as specified, mark `#[doc = "tunable"]`, revisit with first-tenant data. +2. Belief order is time-first (§5.3 limitation) -> ship with config floor; revisit if a tenant hits it. +3. Wake channel is process-global -> revisit per-watch channels if the MCP layer wants isolation. +4. Char budgeting vs tokens -> chars in v1; the binding layer may pass model-aware budgets later. +5. `Unclassified` claims score with weight 1.0 -> alternative (configurable default weight) if unclassified spam drowns urgent. + +— end of spec — diff --git a/docs/fold-answers.md b/docs/fold-answers.md new file mode 100644 index 0000000..b3937be --- /dev/null +++ b/docs/fold-answers.md @@ -0,0 +1,24 @@ +# Fold capability answers (spec §8 F-checklist) + +Resolved 2026-08-15 by reading the fold/ese/anny source in this workspace, extending +the three answers (F4, F7, F11) previously confirmed by the Flower Computer team. +Citations are to files in this repo. + +| # | Question | Answer | Evidence | +|---|---|---|---| +| F1 | Fan-out | **Yes, native.** Tuples of `Push` nodes (up to 16) broadcast every delta to each element; the reader mirrors the tuple. | `fold/src/pipeline/mod.rs` (module docs, `tuple` mod) | +| F2 | Keyed lookup inside wtx | **Yes.** `WriteTx::get` reads a key seeing the transaction's own uncommitted writes; `KeyedStream::upsert` is exactly retract-old + assert-new, and short-circuits byte-identical records (free INV-5 assist). Clog still keeps its own key→hash map, so the adapter can also expand upserts itself. | `fold/src/stream/mod.rs:143`, `fold/src/stream/keyed.rs:167` | +| F3 | Stateful keyed reduces | **Partly native, rest via F4.** `Aggregate` (invertible step fn), `TopK`, `Ranked`, `KeyedRanked` exist. BestBelief / latest-name / focus-parameterized TopK are custom terminals. | `fold/src/pipeline/ops/keyed.rs`, `ops/scored.rs`, `terminal/ranked.rs` | +| F4 | Implementable terminal trait | **CONFIRMED (team + code).** `Push` is public: `init`/`push`/`commit`/`abort`/`reader`, with commit re-entrant mid-transaction. All stateful clog views are built as custom terminals. | `fold/src/pipeline/mod.rs:78` | +| F5 | Joins / parameterized re-scoring | **No joins; fallback applies.** `Map`/`ScoreBy` fns must be deterministic so retractions cancel — time- and focus-dependent scores cannot be a `ScoreBy`. Focus + bucket state live inside the per-scope urgent terminal; `SetFocus`/`Tick` events trigger internal re-score of that scope. | `fold/src/pipeline/ops/mod.rs` (Map docs) | +| F6 | Multiple input types on one stream | **Workable as assumed.** Single `Stream` with per-variant `FilterMap` routing into the fan-out tuple. | `fold/src/pipeline/ops/mod.rs` (`FilterMap`) | +| F7 | Crash/reopen | **CONFIRMED, with nuance.** Commits are durable against *process* crash when `wtx` returns; `checkpoint()` (fsync) additionally hardens against OS/power failure. Adapter writes the global rev as a meta record in each wtx; reopen reads it and replays only the WAL tail. Write-ahead ordering (WAL fsync before engine apply) mandatory; `wal_fsync = OnCommit` required with the fold feature. | `fold/src/stream/unkeyed.rs:84` | +| F8 | Predicate scan | **Build the index anyway.** observer→live-keys maintained as its own view (`Multimap`-shaped) in both engines; no scans. | `fold/src/pipeline/terminal/mod.rs` (`Multimap`) | +| F9 | Read/write concurrency | **Single-writer by construction.** `wtx` takes `&mut self`; `rtx` pins a snapshot from `&self`. Matches clog's one-writer-thread actor; snapshot copy-out happens outside the write critical section via ArcSwap publication. | `fold/src/stream/unkeyed.rs:50,78` | +| F10 | remove+insert cost | **Acceptable.** Upsert-as-retract+insert is the designed contract (`KeyedStream`); posting sinks are set-semantic per record and read no prior state on retraction. Confirm empirically in B1/B2 re-runs at M4. | `fold/src/pipeline/terminal/mod.rs` (Retraction section) | +| F11 | ANNy deletion | **CONFIRMED (team + code).** `Hnsw` terminal calls `index.remove(id)` — true node deletion, no tombstones. Keep B5's churn/recall assertion as the guard; `semantic_rebuild_tombstone_ratio` stays a disabled contingency. | `fold/src/pipeline/terminal/search/hnsw.rs:55` | +| F12 | ESE provenance | **Open — ask Flower.** License file exists in `ese/`; training source, OOV behavior, and DIMENSIONS stability across versions still unconfirmed. Non-blocking for v1 (index rebuilt on open; vectors not persisted by clog). We build with features `dim-512`, `quant-8` per the new-project script. | +| F13 | Batch ergonomics | **Fine.** One wtx per clog batch; no size limits observed. Measure 1k-claim batches in B2. | +| F14 | Recursion | **Not needed, as designed.** Depth-1 alias flattening at write time; no closure computation anywhere. v2 relations still want Flower's roadmap answer. | + +Log any further Flower Computer answers here as they arrive. diff --git a/docs/superpowers/specs/2026-08-15-clog-build-design.md b/docs/superpowers/specs/2026-08-15-clog-build-design.md new file mode 100644 index 0000000..5b52324 --- /dev/null +++ b/docs/superpowers/specs/2026-08-15-clog-build-design.md @@ -0,0 +1,183 @@ +# Clog build design + +**Date:** 2026-08-15 +**Status:** approved in brainstorm; delta on [`docs/clog-spec-v1.md`](../../clog-spec-v1.md) +**Scope:** this document records every decision made on top of the v1 spec. Where it +amends the spec, the amendment is called out explicitly. The spec remains the +authority for everything not mentioned here. + +## 1. Goal and posture + +Real v1 library, built in the spec's order: pure core → naive engine (semantic +oracle) → scopes/wakes → semantic/learn → fold adapter → hardening. Hackathon +timing is secondary to correctness; the naive engine ships permanently as the +differential oracle and no-fold runtime. + +## 2. Crate placement + +- `examples/clog/` in this workspace (README convention), **library crate** + (`[lib]`, no binary), `publish = false`, edition 2024. Auto-wired via the + `examples/*` workspace glob. +- The spec's `examples/agency.rs` and `examples/household.rs` are cargo examples + inside the crate. `fuzz/` subdir holds the cargo-fuzz targets (Z1, Z2). +- Path deps: `fold` (optional, feature `fold`), `ese` + `anny` (optional, feature + `semantic`; ese features `dim-512`, `quant-8`, so `DIMENSIONS = 512`). +- Default features: `semantic` on, `fold` off — matching the spec's definition of + done. Day-to-day iteration uses `--no-default-features` (ese's embedded map + dominates compile time); CI builds both. + +## 3. Dependency amendments (spec §6.4 policy) + +- **Add `imbl`**: persistent `OrdMap`/`OrdSet` with O(1) clone for `WorldSnapshot` + publication. A full `BTreeMap` deep-clone per batch would blow the B1 budget at + 100k live claims. +- **Replace `bincode` with `postcard`** for WAL record encoding: the entire + workspace already speaks postcard, its wire format is stable and documented, and + it avoids bincode 2.x API churn. Length-prefix + CRC32 framing is clog's own, + unchanged. +- Everything else per spec: serde, thiserror, crossbeam-channel, arc-swap, + crc32fast, regex. Dev-deps: proptest, insta, criterion; loom behind `--cfg loom`. + +## 4. WAL and event-log semantics (spec §5.1/§6.3 clarification) + +**The WAL records effects, not intentions.** A committed batch contains the +caller's events *plus* classifier-derived `Judge` events *plus* any `Tick` +events, exactly as applied. Replay — including `rebuild_on_open` — is pure event +application; **the classifier cascade never runs during replay**. This makes +INV-10 trivially statable (same WAL bytes → same world) and immune to exemplar +drift. "Replay with new classification config" is the v2 tooling the spec's +parking lot already names, not a v1 behavior. + +Per-batch order: validate → assign `recorded_at` → run cascade against the +pre-batch snapshot → assemble the full event list → WAL append + fsync → engine +apply → render/wake evaluation → publish snapshot. + +## 5. Naive engine internals + +- Engine state is `imbl` maps throughout: primary (`live`, `kinds`, `aliases`) plus + secondary indexes the semantics require (`by_subject`, `by_observer` [F8], + `by_entity`), plus derived views (`entity_state`, `open_loops`, `unclassified`, + per-scope `urgent`, decay-bucket occupancy per scope so ticks cost + O(crossing claims)). +- **Diffs are recorded during apply, not computed by snapshot comparison.** + `apply()` updates touched structures and appends to + `ApplyResult { per-view added/removed/changed, scopes_dirty }`. Targeted + recomputation: belief only for the touched subject group, scoring only for + changed keys, rendering only for scopes whose slot inputs changed. Alias + retraction re-keys only `by_entity[alias]` (the spec's accepted expensive case). +- Spec §5.9's pre/post membership delta is the *semantic definition*; the + diff-driven implementation is verified against it by property test P8. +- **`EngineDump` = `WorldSnapshot`** (plus secondary indexes). No parallel dump + type. +- **Naive snapshot-file machinery is deferred to M5.** Until then, reopen = full + WAL replay. B6 (reopen bench) is an M5 gate anyway; R1/R2 still fully exercise + the WAL. `snapshot_every_batches` config lands with the machinery. +- **`changes[scope]` falls out of the render pass**: the renderer already diffs + slot inputs to decide re-rendering; that delta *is* the changes slot. One + mechanism, not two. +- All semantics live in shared pure modules (`score.rs`, `belief.rs`, `alias.rs`, + `kinds.rs`, `render/`); both engines call the same functions, so differential + testing checks orchestration, not two formula copies. + +## 6. Fold adapter shape (M4) + +- One `Stream` (not `KeyedStream`: batches are heterogeneous). Per-variant + `FilterMap` routes into a tuple fan-out of custom `Push` terminals: live table, + observer/subject/entity indexes, belief (per subject group), per-scope urgent + (holds focus + bucket state; consumes Observe/Judge/SetFocus/Tick), open-loops, + unclassified, and the semantic branch reusing fold's `Hnsw` terminal over an ese + `Map`. +- Each custom terminal buffers its "emitted this tx" delta; the adapter drains + them into `ApplyResult` after `wtx` — both engines speak the same diff language + and the snapshot layer is engine-agnostic. +- A meta record in each wtx stores the global rev; reopen reads it and replays + only the WAL tail (F7). WAL fsync strictly precedes engine apply; + `wal_fsync = OnCommit` is mandatory with the fold feature. +- Upsert expansion (retract old + assert new) is done by clog before pushing, + using its key→hash map; fold never needs lookups (F2 fallback, trivial). +- See `docs/fold-answers.md` for the full resolved F-checklist. + +## 7. Rust-practice decisions + +- Newtypes: `ObserverId(String)`, `ScopeId(String)`, `Rev(u64)` (serde-transparent). +- `#[non_exhaustive]` on `ClogError` and `Config`; `Config::default_for(path)` + + builder methods. +- `Focus` builder for ergonomics (`Focus::uniform().weight("risk", 2.5).boost(…)`), + plain serde struct underneath (INV-12 intact). +- CI hygiene: `#![deny(missing_docs)]`, clippy `-D warnings` + + `clippy::unwrap_used` (allowed in tests), rustfmt check, MSRV pinned. +- INV-10 discipline: `BTreeMap`/`imbl::OrdMap` at every output boundary; f32 score + ties broken by claim_key per spec. +- **Wake debounce runs on the Clock port, not wall time.** Under `Clock::Manual`, + `advance()` drives debounce-window expiry; otherwise P8 is flaky. +- R1 crash tests use a child-process harness: the test re-execs itself as a + subprocess that aborts at an injected crash point; the parent reopens and + asserts WAL-replay equality. + +## 8. Ambiguity ledger (small calls the spec leaves open) + +| Topic | Decision | +|---|---| +| `Filter.min_score` on non-Urgent view | new `InvalidFilter { reason }` error variant | +| `recall()` with semantic disabled/feature off | new `SemanticDisabled` error variant | +| Literal `%{` in templates | no escape in v1; documented | +| Cloned `wakes()` receivers | compete (each Wake delivered once); documented single-consumer intent | +| `situation(None)` | the `"default"` scope; unknown names → `UnknownScope` | +| `Filter.limit` | clamps to max 500 (no error) | +| Exemplar claim body | JSON `{ "body": …, "kind": … }`; key `clog:exemplar:{content_hash(body)}` | +| `Situation.as_of` | clock reading at the rendering batch; RFC3339 UTC in the header slot | + +## 9. Default scopes and foci + +The lib ships exactly one default: scope `"default"` with a uniform `Focus` +(weights 1.0, no boosts, half-life 7 days) — scoring reduces to trust × recency, +a usable zero-config ordering. No named preset foci in the API: clog owns algebra, +not judgment, and §14.1's constants are unvalidated. Recipes live in the examples: +`agency.rs` ships `delivery-health` and `cash-and-collections` lenses; +`household.rs` ships per-member scopes with disjoint boosts. + +## 10. Classifier eval harness (new M3 deliverable) + +Source: the garden3d Notion "Observations" database (~10.5k rows hand-labeled by +Type — a manual prototype of clog). Not training (ESE is frozen; kNN is exemplar +lookup); three uses: + +1. **Seed exemplars**: balanced ~30–50 bodies/kind for the agency example config. +2. **Eval**: hold-out split; measure cascade *coverage* (% confidently + auto-classified) and *accuracy* (agreement with Type); sweep + `k`/`min_sim`/`min_votes`. Replaces §14.1 guesses with measurements. +3. **Scoring sanity**: the DB's human Salience column (High/Medium/Low) checks + that plausible foci rank High-salience observations above Low (rank + correlation, not exact order). + +Label mapping: FYI/Risk/Decision/Question/Commitment → same-named kinds; Lead → +`opportunity`; **Resourcing → custom kind `resourcing` declared in the agency +example** (demonstrating custom taxonomies with real data); 6 noise rows dropped. +Note Commitment has only 35 examples — weak coverage there is a finding, not a +failure (Unclassified is the designed escalation path). + +Privacy: the export script applies a **stable pseudonym map** (each real +client/person → consistent fake name, preserving cross-claim structure). The full +corpus stays gitignored and is pulled on demand; only a small **human-reviewed** +sample (~30–50/kind) is committed as `tests/fixtures/exemplars.jsonl`. Renaming +alone is not full sanitization — the committed sample gets a manual skim for +sensitive content beyond names before landing. + +## 11. Build process + +One implementation plan per milestone gate, written with the writing-plans skill, +TDD throughout, user review between plans: + +- **P1 = M0+M1**: types, validation, pure core, template parser; naive engine, + renderer, WAL, rev model. Exit: §11.1 units, P1–P4, P6–P7, G1, R1–R3 green. +- **P2 = M2**: scopes, focus, bucketed decay/ticks, watches/wakes. Exit: P5, P8, + B3, B4 green; G1 focus-shift checkpoints. +- **P3 = M3**: ESE/ANNy wrapper, recall, kNN tier, correct()/exemplars, + Unclassified queue, **Notion-fed eval harness**. Exit: U-KIND-*, B5 green. +- **P4 = M4**: fold adapter behind `feature = "fold"`. Exit: D1, D2 green; B1/B2 + re-run on fold. +- **P5 = M5**: fuzz, loom, deferred naive snapshot files (B6), rustdoc + examples + as doctests, CHANGELOG, freeze `v0.1.0`. + +README maintenance: `examples/clog/README.md` currency is an exit criterion of +every plan; the repo `CLAUDE.md` carries the standing rule. From 0a5609fdb822b41fa90365b1b291255ed6f5839b Mon Sep 17 00:00:00 2001 From: hhff Date: Sat, 15 Aug 2026 17:43:19 -0700 Subject: [PATCH 02/28] plan(clog): P1 implementation plan (M0+M1: pure core, naive engine, WAL) Co-Authored-By: Claude Fable 5 --- .../2026-08-15-clog-p1-core-naive-engine.md | 1882 +++++++++++++++++ 1 file changed, 1882 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-15-clog-p1-core-naive-engine.md diff --git a/docs/superpowers/plans/2026-08-15-clog-p1-core-naive-engine.md b/docs/superpowers/plans/2026-08-15-clog-p1-core-naive-engine.md new file mode 100644 index 0000000..72e8cb0 --- /dev/null +++ b/docs/superpowers/plans/2026-08-15-clog-p1-core-naive-engine.md @@ -0,0 +1,1882 @@ +# Clog P1 (M0+M1): Pure Core + Naive Engine Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** A working `clog` library crate (naive engine only) supporting `open`, `observe`, `retract`, `revoke_observer`, `situation`, `select`, `merge_entities` with WAL persistence, deterministic rendering, and the M0+M1 test suite green. + +**Architecture:** Pure semantic modules (score/belief/alias/kinds/render) with no I/O; a naive engine holding all views in `imbl` ordered maps, recomputing slot inputs per batch and re-rendering only when text changes; a single writer thread owning engine + WAL, publishing immutable `WorldSnapshot`s via `ArcSwap`; readers never compute. WAL is length-prefixed postcard records with CRC32; replay is pure event application (classifier never runs on replay). + +**Tech Stack:** Rust edition 2024, serde, thiserror, imbl, arc-swap, crossbeam-channel, postcard, crc32fast, regex; dev: proptest, insta, tempfile. + +**Spec:** `docs/clog-spec-v1.md` (authority), amended by `docs/superpowers/specs/2026-08-15-clog-build-design.md` (decisions). Read both before starting. Fold capability notes: `docs/fold-answers.md` (not needed for P1 — the fold feature is out of scope here). + +## Global Constraints + +- Crate lives at `examples/clog/`, `[lib]` only, `publish = false`, edition 2024 (auto-member via workspace glob `examples/*`). +- Dependency allowlist for P1 (runtime): serde (derive), thiserror, imbl, arc-swap, crossbeam-channel, postcard (`use-std`), crc32fast, regex. Dev-only: proptest, insta (yaml off, default), tempfile. **Nothing else.** No chrono/time — RFC3339 is hand-rolled (Task 8). No `fold`/`ese`/`anny` in P1. +- `#![deny(missing_docs)]` on the crate; every public item documented before a task is done. +- No `unwrap`/`expect` in `src/` except in `#[cfg(test)]` code (locked by `#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used))]`). +- INV-10 discipline: no `std::collections::HashMap` anywhere state or output ordering could leak from — use `BTreeMap`/`imbl::OrdMap`/`imbl::OrdSet`. All wall-clock reads go through `clock.rs`; **all tests use `ClockMode::Manual`**. +- Spec invariants INV-1..13 are cited by number in tests; test names must match the spec's names (U-SCORE-1 → `u_score_1_…`, P1 → `p1_…`, etc.). +- Reserved namespace prefix is `clog:` (INV-8). Internal observer for reserved claims is `ObserverId("clog")`. +- Every commit: `cargo test -p clog` green first. Run commands from the workspace root. +- If any step's expected output disagrees with reality, STOP and re-read the spec section cited in that task before improvising. + +**Deviations from the spec, already approved in the build design — do not "fix" them back:** +- postcard replaces bincode; `imbl` is allowed; naive snapshot files (`snap/`, `snapshot_every_batches`) are **deferred to M5** — open always replays the full WAL in P1. +- `content_hash` is dropped: INV-5 idempotence uses structural equality (`Claim: PartialEq`) against the live map. +- `set_focus`, ticks, watches/wakes, `recall`, `correct`, kNN are **out of scope** (P2/P3). `Event` still defines their variants so the WAL format is stable. +- U-KIND-2 (kNN votes) is P3; U-KIND-1 (rules tier) is in this plan. +- Batches that expand to zero events (pure duplicates) are not committed: no WAL append, no rev bump; `Ack.rev` = current rev. +- Scoring age basis: `occurred_at`, clamped to `now` when > now + 24h (§10). + +--- + +### Task 1: Crate scaffold + +**Files:** +- Create: `examples/clog/Cargo.toml`, `examples/clog/src/lib.rs`, `examples/clog/README.md`, `examples/clog/.gitignore` + +**Interfaces:** +- Produces: an empty documented crate `clog` that builds inside the workspace; later tasks add modules to `src/lib.rs`. + +- [ ] **Step 1: Write the crate manifest and empty lib** + +`examples/clog/Cargo.toml`: + +```toml +[package] +name = "clog" +version = "0.0.0" +edition = "2024" +publish = false +description = "Orientation engine for agentic systems: claims in, ranked situations out." + +[dependencies] +serde = { version = "1", features = ["derive"] } +thiserror = "2" +imbl = "6" +arc-swap = "1" +crossbeam-channel = "0.5" +postcard = { version = "1", features = ["use-std"] } +crc32fast = "1" +regex = "1" + +[dev-dependencies] +proptest = "1" +insta = "1" +tempfile = "3" + +[features] +# test-crash compiles the crash-injection hook used by the R1 harness (Task 17) +test-crash = [] +``` + +`examples/clog/src/lib.rs`: + +```rust +//! Clog: an orientation engine for agentic systems. +//! +//! Hosts write structured claims; clog maintains materialized views over them +//! incrementally and renders a budgeted situation document per scope. See +//! `docs/clog-spec-v1.md` in the repository root for the full specification. +#![deny(missing_docs)] +#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used))] +``` + +`examples/clog/README.md`: + +```markdown +# clog + +Orientation engine for agentic systems, built on the BogKit workspace. +Spec: `../../docs/clog-spec-v1.md`. Build design: +`../../docs/superpowers/specs/2026-08-15-clog-build-design.md`. + +Status: P1 in progress (pure core + naive engine + WAL). + +## Public API (implemented so far) + +(none yet) +``` + +`examples/clog/.gitignore`: + +``` +/tests/fixtures/corpus* +``` + +- [ ] **Step 2: Verify the workspace picks it up** + +Run: `cargo check -p clog` +Expected: compiles clean (empty lib). If `imbl = "6"` fails to resolve, run `cargo add imbl -p clog` to take the latest and keep whatever version it picks. + +- [ ] **Step 3: Commit** + +```bash +git add examples/clog +git commit -m "feat(clog): scaffold library crate" +``` + +--- + +### Task 2: Public types (`types.rs`) + +**Files:** +- Create: `examples/clog/src/types.rs` +- Modify: `examples/clog/src/lib.rs` (add `pub mod types;` and re-export `pub use types::*;`) + +**Interfaces:** +- Produces (exact, used by every later task): + - `pub struct ObserverId(pub String)` — serde-transparent; `From<&str>`. + - `pub type Rev = u64;` + - `pub enum Reliability { A, B, C, D, E, F }` with `pub fn rank(self) -> u8` (A=0 best) and `pub fn letter(self) -> char`. + - `pub enum Credibility { One, Two, Three, Four, Five, Six }` with `pub fn rank(self) -> u8` (One=0 best) and `pub fn digit(self) -> u8` (One=1). + - `pub struct EntityRef { pub etype: String, pub id: String, pub name: Option }` — **Eq/Ord/Hash on (etype,id) only** (manual impls; `name` excluded) plus `pub fn key(&self) -> (String, String)`. + - `pub struct Claim { pub claim_key: String, pub subject_key: Option, pub source_ref: String, pub observer: ObserverId, pub schema_v: u16, pub occurred_at: u64, pub observed_at: u64, pub reliability: Reliability, pub credibility: Credibility, pub entities: Vec, pub body: String }` — derives `Clone, Debug, PartialEq, Serialize, Deserialize`. + - `pub struct Focus { pub weights: BTreeMap, pub boosts: Vec<(EntityRef, f32)>, pub half_life_days: f32, pub top_k: Option }` with `Default` = `Focus::uniform()` and builder methods `uniform() -> Focus`, `weight(self, kind: &str, w: f32) -> Focus`, `boost(self, e: EntityRef, f: f32) -> Focus`, `half_life_days(self, d: f32) -> Focus`, `top_k(self, k: usize) -> Focus`. + - `pub enum View { Live, EntityState, OpenLoops, Urgent { scope: String }, Unclassified }` + - `pub struct Filter { pub kinds: Option>, pub entities: Option>, pub observer: Option, pub subject_prefix: Option, pub occurred_after: Option, pub min_score: Option, pub limit: Option }` (`Default`). + - `pub enum JudgeSource { Rule, Knn, External }` + - `pub struct KindLabel { pub kind: String, pub confidence: f32, pub source: JudgeSource }` + - `pub struct Row { pub claim: Claim, pub recorded_at: u64, pub kind: Option, pub score: Option, pub believed: Option }` + - `pub struct Situation { pub scope: String, pub text: String, pub rev: Rev, pub as_of: u64 }` + - `pub struct Ack { pub rev: Rev, pub situation: Option }` + - `pub struct ObserveOpts { pub return_situation: Option }` (`Default`) + - `pub struct Judgment { pub kind: String }` + - `pub enum ClockMode { System, Manual }`, `pub enum FsyncPolicy { OnCommit, Never }` + - `pub struct TickConfig { pub mode: ClockMode, pub interval_ms: u64 }` (`Default`: System, 60_000) + - `pub enum Matcher { BodyContains(String), BodyRegex(String), ObserverIs(String), EntityType(String) }` (BodyContains is case-insensitive by definition, §5.6) + - `pub struct Rule { pub any_of: Vec }` + - `pub struct KindDef { pub name: String, pub rules: Vec, pub seed_exemplars: Vec }` + - `pub struct KindTaxonomy { pub kinds: Vec }` with `pub fn default_taxonomy() -> KindTaxonomy` (the 8 spec kinds, no rules/exemplars) and `pub fn contains(&self, kind: &str) -> bool`. + - `pub struct Config { pub path: PathBuf, pub scopes: BTreeMap, pub kinds: KindTaxonomy, pub loop_kinds: Vec, pub top_k: usize, pub budget_chars: usize, pub tick: TickConfig, pub decay_buckets_per_half_life: u32, pub belief_min_credibility: Credibility, pub wal_fsync: FsyncPolicy, pub write_queue: usize, pub rebuild_on_open: bool }` — `#[non_exhaustive]` is **not** used on Config in P1 (it would block struct-literal tests); instead all-fields-public + `pub fn default_for(path: impl Into) -> Config` with spec defaults (top_k 12, budget_chars 6000, buckets 4, floor Six, loop_kinds ["question","risk","commitment"], fsync OnCommit, write_queue 1024). + - `pub enum ClogError` (thiserror, `#[non_exhaustive]`): `InvalidClaim { index: usize, reason: String }`, `ReservedNamespace`, `UnknownClaim`, `UnknownScope`, `UnknownKind`, `AliasCycle`, `TemplateError(String)`, `Storage(#[from] std::io::Error)`, `Corrupt { detail: String }`, `ShuttingDown`, `InvalidFilter { reason: String }`, `SemanticDisabled`, `ManualClockRequired`. + +All public types derive `Clone, Debug, Serialize, Deserialize` (INV-12), plus `PartialEq` where meaningful. Every item gets a rustdoc line. + +- [ ] **Step 1: Write the failing test** (bottom of `types.rs`) + +```rust +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn serde_round_trips_public_surface() { + let c = Claim { + claim_key: "halcyon:inv-1042".into(), + subject_key: Some("halcyon:inv-1042:status".into()), + source_ref: "gmail:msg/123".into(), + observer: ObserverId::from("gmail-v3"), + schema_v: 1, + occurred_at: 1_000, + observed_at: 2_000, + reliability: Reliability::B, + credibility: Credibility::Two, + entities: vec![EntityRef { etype: "project".into(), id: "halcyon".into(), name: Some("Halcyon".into()) }], + body: "Invoice 1042 is 30 days overdue".into(), + }; + let bytes = postcard::to_allocvec(&c).unwrap(); + assert_eq!(postcard::from_bytes::(&bytes).unwrap(), c); + + let f = Focus::uniform().weight("risk", 2.5).half_life_days(3.0).top_k(8); + let json = serde_json_like_roundtrip(&f); // via postcard, same as above + assert_eq!(json.weights.get("risk"), Some(&2.5)); + assert_eq!(json.half_life_days, 3.0); + assert_eq!(json.top_k, Some(8)); + } + + fn serde_json_like_roundtrip(f: &Focus) -> Focus { + postcard::from_bytes(&postcard::to_allocvec(f).unwrap()).unwrap() + } + + #[test] + fn entity_ref_identity_ignores_name() { + let a = EntityRef { etype: "person".into(), id: "sam".into(), name: Some("Sam".into()) }; + let b = EntityRef { etype: "person".into(), id: "sam".into(), name: None }; + assert_eq!(a, b); + use std::collections::BTreeSet; + let mut s = BTreeSet::new(); + s.insert(a); + assert!(s.contains(&b)); + } + + #[test] + fn trust_ranks() { + assert!(Reliability::A.rank() < Reliability::F.rank()); + assert!(Credibility::One.rank() < Credibility::Six.rank()); + assert_eq!(Reliability::C.letter(), 'C'); + assert_eq!(Credibility::Three.digit(), 3); + } + + #[test] + fn config_defaults_match_spec() { + let c = Config::default_for("/tmp/x"); + assert_eq!(c.top_k, 12); + assert_eq!(c.budget_chars, 6000); + assert_eq!(c.decay_buckets_per_half_life, 4); + assert_eq!(c.loop_kinds, vec!["question", "risk", "commitment"]); + assert!(KindTaxonomy::default_taxonomy().contains("fyi")); + assert_eq!(KindTaxonomy::default_taxonomy().kinds.len(), 8); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cargo test -p clog` +Expected: compile error — types don't exist. + +- [ ] **Step 3: Implement `types.rs`** + +Write all types per the Produces list. Key manual impls: + +```rust +impl PartialEq for EntityRef { + fn eq(&self, other: &Self) -> bool { self.etype == other.etype && self.id == other.id } +} +impl Eq for EntityRef {} +impl PartialOrd for EntityRef { + fn partial_cmp(&self, other: &Self) -> Option { Some(self.cmp(other)) } +} +impl Ord for EntityRef { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + (&self.etype, &self.id).cmp(&(&other.etype, &other.id)) + } +} +impl std::hash::Hash for EntityRef { + fn hash(&self, h: &mut H) { self.etype.hash(h); self.id.hash(h); } +} + +impl Reliability { + /// 0 = best (A). Belief resolution and scoring use this rank. + pub fn rank(self) -> u8 { self as u8 } + /// The Admiralty letter, for rendering. + pub fn letter(self) -> char { (b'A' + self as u8) as char } +} +impl Credibility { + /// 0 = best (One). + pub fn rank(self) -> u8 { self as u8 } + /// The Admiralty digit 1..=6, for rendering. + pub fn digit(self) -> u8 { self as u8 + 1 } +} +``` + +`default_taxonomy()` kinds, in order: `fact, decision, risk, question, commitment, agreement, opportunity, fyi`. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cargo test -p clog` +Expected: 4 tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add examples/clog/src +git commit -m "feat(clog): public API types (INV-12 serde surface)" +``` + +--- + +### Task 3: Validation (`validate.rs`) — U-VAL-1 + +**Files:** +- Create: `examples/clog/src/validate.rs` (+ `pub(crate) mod validate;` in lib.rs) + +**Interfaces:** +- Consumes: `Claim`, `Focus`, `KindTaxonomy`, `ClogError` from Task 2. +- Produces: + - `pub(crate) fn validate_claim(index: usize, c: &Claim, allow_reserved: bool) -> Result<(), ClogError>` — §10 rules; `allow_reserved` is set only by internal writers (merge claims). + - `pub(crate) fn validate_focus(f: &Focus, taxonomy: &KindTaxonomy) -> Result<(), ClogError>` — weight keys in taxonomy, finite and > 0 values, half_life in (0.01, 3650), boost factors finite and > 0. + - `pub(crate) fn scoring_clamp(ts: u64, now: u64) -> u64` — `if ts > now + 86_400_000 { now } else { ts }` (§10: clamp for scoring, store verbatim). + +- [ ] **Step 1: Write the failing tests** — table-driven, every §10 rule positive and negative + +```rust +#[cfg(test)] +mod tests { + use super::*; + use crate::types::*; + + fn base() -> Claim { + Claim { + claim_key: "k1".into(), subject_key: None, source_ref: "src:1".into(), + observer: ObserverId::from("o1"), schema_v: 1, + occurred_at: 1, observed_at: 1, + reliability: Reliability::A, credibility: Credibility::One, + entities: vec![], body: "b".into(), + } + } + + #[test] + fn u_val_1_claim_rules() { + // (mutation, should_pass, reason-substring) + let cases: Vec<(Box, bool, &str)> = vec![ + (Box::new(|_| {}), true, ""), + (Box::new(|c| c.claim_key = " ".into()), false, "claim_key"), + (Box::new(|c| c.claim_key = "x".repeat(257)), false, "claim_key"), + (Box::new(|c| c.claim_key = "clog:evil".into()), false, "reserved"), + (Box::new(|c| c.claim_key = "has\u{0007}bell".into()), false, "control"), + (Box::new(|c| c.subject_key = Some("x".repeat(257))), false, "subject_key"), + (Box::new(|c| c.source_ref = "".into()), false, "source_ref"), + (Box::new(|c| c.source_ref = "x".repeat(1025)), false, "source_ref"), + (Box::new(|c| c.observer = ObserverId(String::new())), false, "observer"), + (Box::new(|c| c.observer = ObserverId("x".repeat(129))), false, "observer"), + (Box::new(|c| c.body = " ".into()), false, "body"), + (Box::new(|c| c.body = "x".repeat(16 * 1024 + 1)), false, "body"), + (Box::new(|c| c.entities = vec![EntityRef { etype: "p".into(), id: "i".into(), name: None }; 33]), false, "entities"), + (Box::new(|c| c.entities = vec![EntityRef { etype: "".into(), id: "i".into(), name: None }]), false, "etype"), + (Box::new(|c| c.entities = vec![EntityRef { etype: "p".into(), id: "x".repeat(129), name: None }]), false, "id"), + (Box::new(|c| c.occurred_at = 0), false, "occurred_at"), + (Box::new(|c| c.observed_at = 0), false, "observed_at"), + // occurred_at > observed_at is ALLOWED (predictions) + (Box::new(|c| { c.occurred_at = 10; c.observed_at = 5; }), true, ""), + ]; + for (i, (mutate, ok, why)) in cases.iter().enumerate() { + let mut c = base(); + mutate(&mut c); + let r = validate_claim(7, &c, false); + assert_eq!(r.is_ok(), *ok, "case {i}: {r:?}"); + if !ok { + match r.unwrap_err() { + ClogError::InvalidClaim { index, reason } => { + assert_eq!(index, 7); + assert!(reason.to_lowercase().contains(why), "case {i}: {reason} !~ {why}"); + } + ClogError::ReservedNamespace => assert_eq!(*why, "reserved"), + e => panic!("case {i}: wrong error {e:?}"), + } + } + } + // reserved allowed when internal + let mut c = base(); + c.claim_key = "clog:merge:a->b".into(); + assert!(validate_claim(0, &c, true).is_ok()); + } + + #[test] + fn u_val_1_focus_rules() { + let tax = KindTaxonomy::default_taxonomy(); + assert!(validate_focus(&Focus::uniform().weight("risk", 2.0), &tax).is_ok()); + assert!(matches!(validate_focus(&Focus::uniform().weight("nope", 1.0), &tax), Err(ClogError::UnknownKind))); + assert!(validate_focus(&Focus::uniform().weight("risk", f32::NAN), &tax).is_err()); + assert!(validate_focus(&Focus::uniform().weight("risk", 0.0), &tax).is_err()); + assert!(validate_focus(&Focus::uniform().half_life_days(0.005), &tax).is_err()); + assert!(validate_focus(&Focus::uniform().half_life_days(4000.0), &tax).is_err()); + } + + #[test] + fn scoring_clamp_only_beyond_24h() { + assert_eq!(scoring_clamp(100, 1_000_000), 100); + assert_eq!(scoring_clamp(1_000_000 + 86_400_000, 1_000_000), 1_000_000 + 86_400_000); + assert_eq!(scoring_clamp(1_000_000 + 86_400_001, 1_000_000), 1_000_000); + } +} +``` + +- [ ] **Step 2: Run to verify failure** — `cargo test -p clog validate` → compile error. + +- [ ] **Step 3: Implement.** Byte-length checks use `.len()` (bytes, per spec); "non-empty after trim" uses `.trim().is_empty()`; control chars via `s.chars().any(|ch| ch.is_control())` applied to claim_key, subject_key, source_ref. Reserved check: `c.claim_key.starts_with("clog:") && !allow_reserved` → `ClogError::ReservedNamespace`. Focus half-life bound: `0.01 < h && h < 3650.0` exclusive. + +- [ ] **Step 4: Run** — `cargo test -p clog validate` → 3 PASS. + +- [ ] **Step 5: Commit** — `git commit -m "feat(clog): observe-time validation (U-VAL-1)"` + +--- + +### Task 4: Scoring (`score.rs`) — U-SCORE-1 + +**Files:** +- Create: `examples/clog/src/score.rs` (+ `pub(crate) mod score;`) + +**Interfaces:** +- Consumes: `Reliability`, `Credibility`, `Focus`, `Claim`, `EntityRef` (Task 2), `scoring_clamp` (Task 3). +- Produces: + - `pub(crate) fn trust(r: Reliability, c: Credibility) -> f32` — table lookup: REL `[1.00, 0.90, 0.75, 0.50, 0.25, 0.10]` indexed by `rank()`, CRED same values. + - `pub(crate) fn bucket_age_days(age_days: f32, half_life_days: f32, buckets_per_half_life: u32) -> f32` — width = hl/buckets; `(age/w).floor()*w + w/2.0`; negative age clamps to first bucket midpoint. + - `pub(crate) fn recency(bucket_age: f32, half_life_days: f32) -> f32` — `0.5f32.powf(bucket_age / half_life_days)`. + - `pub(crate) fn score_claim(claim: &Claim, kind: Option<&str>, focus: &Focus, canonical_entities: &[ (String,String) ], now_ms: u64, buckets_per_half_life: u32) -> f32` — §5.4 formula; `canonical_entities` are the claim's entity keys *post-alias* (caller resolves); unclassified weight 1.0; boosts multiply for every focus boost whose entity key is in `canonical_entities`. + +- [ ] **Step 1: Write the failing test** — the fixed table of §11.1 + +```rust +#[cfg(test)] +mod tests { + use super::*; + use crate::types::*; + + const DAY_MS: u64 = 86_400_000; + + #[test] + fn u_score_1_trust_table() { + assert_eq!(trust(Reliability::A, Credibility::One), 1.00); + assert_eq!(trust(Reliability::B, Credibility::One), 0.90); + assert_eq!(trust(Reliability::F, Credibility::Six), 0.10 * 0.10); + assert_eq!(trust(Reliability::C, Credibility::Four), 0.75 * 0.50); + } + + #[test] + fn u_score_1_bucket_midpoints() { + // half-life 7d, 4 buckets/hl -> width 1.75d + let w = 7.0 / 4.0; + assert_eq!(bucket_age_days(0.0, 7.0, 4), w / 2.0); // first bucket midpoint + assert_eq!(bucket_age_days(1.0, 7.0, 4), w / 2.0); // same bucket + assert_eq!(bucket_age_days(1.75, 7.0, 4), 1.75 + w / 2.0); // boundary -> next bucket + assert_eq!(bucket_age_days(-5.0, 7.0, 4), w / 2.0); // future occurred_at + } + + #[test] + fn u_score_1_full_formula_with_boost_stacking() { + let focus = Focus::uniform() + .weight("risk", 2.0) + .boost(EntityRef { etype: "project".into(), id: "halcyon".into(), name: None }, 1.5) + .boost(EntityRef { etype: "person".into(), id: "sam".into(), name: None }, 2.0); + let mut claim = crate::validate::tests_base_claim(); // helper added in step 3 + claim.reliability = Reliability::B; // 0.90 + claim.credibility = Credibility::Three; // 0.75 + let now = 10 * DAY_MS; + claim.occurred_at = now; // age 0 -> bucket midpoint 0.875d + let ents = vec![("project".to_string(), "halcyon".to_string()), + ("person".to_string(), "sam".to_string())]; + let expected = 2.0 * (0.90 * 0.75) + * 0.5f32.powf((0.875f32) / 7.0) + * 1.5 * 2.0; // both boosts stack multiplicatively + let got = score_claim(&claim, Some("risk"), &focus, &ents, now, 4); + assert!((got - expected).abs() < 1e-6, "{got} vs {expected}"); + // unclassified -> weight 1.0 + let got_u = score_claim(&claim, None, &focus, &ents, now, 4); + assert!((got_u - expected / 2.0).abs() < 1e-6); + // no matching boost entities -> boost 1.0 + let got_n = score_claim(&claim, Some("risk"), &focus, &[], now, 4); + assert!((got_n - expected / 3.0).abs() < 1e-6); + } +} +``` + +- [ ] **Step 2: Run** — `cargo test -p clog score` → compile error. + +- [ ] **Step 3: Implement.** Also add to `validate.rs` a `#[cfg(test)] pub(crate) fn tests_base_claim() -> Claim` returning Task 3's `base()` (move `base()` there, name it `tests_base_claim`, re-use from both test modules). Age computation inside `score_claim`: `age_days = (now_ms.saturating_sub(scoring_clamp(claim.occurred_at, now_ms))) as f32 / 86_400_000.0`; half-life from `focus.half_life_days`. + +- [ ] **Step 4: Run** — 3 PASS. + +- [ ] **Step 5: Commit** — `git commit -m "feat(clog): pure scoring (U-SCORE-1)"` + +--- + +### Task 5: Belief resolution (`belief.rs`) — U-BELIEF-1/2 + +**Files:** +- Create: `examples/clog/src/belief.rs` (+ `pub(crate) mod belief;`) + +**Interfaces:** +- Consumes: `Claim`, `Credibility` (Task 2). +- Produces: + - `pub(crate) struct BeliefInput<'a> { pub claim: &'a Claim, pub recorded_at: u64 }` + - `pub(crate) fn belief_key(c: &BeliefInput) -> (u64, std::cmp::Reverse, std::cmp::Reverse, u64, String)` — the §5.3 total order as a max-key: `(occurred_at, Reverse(reliability.rank()), Reverse(credibility.rank()), recorded_at, claim_key)`. + - `pub(crate) fn resolve<'a>(group: &[BeliefInput<'a>], floor: Credibility) -> Option<&'a Claim>` — winner among members with `credibility.rank() <= floor.rank()`; if that set is empty and the group has exactly one live member, that member wins (only-claim exception); if the set is empty with ≥2 members, best by key among all members wins? **No** — spec §5.3: floored claims are "excluded from winning unless they are the only live claim". With ≥2 members all floored, no claim is believed → return `None`. + +- [ ] **Step 1: Write failing tests** + +```rust +#[cfg(test)] +mod tests { + use super::*; + use crate::types::*; + use crate::validate::tests_base_claim; + + fn claim(key: &str, occ: u64, r: Reliability, c: Credibility) -> Claim { + let mut cl = tests_base_claim(); + cl.claim_key = key.into(); + cl.subject_key = Some("s".into()); + cl.occurred_at = occ; + cl.reliability = r; + cl.credibility = c; + cl + } + + #[test] + fn u_belief_1_total_order() { + use Reliability::*; use Credibility::*; + // each later claim beats all before it, per one tier of the order + let a = claim("a", 100, F, Six); // baseline + let b = claim("b", 100, F, Five); // better credibility + let c = claim("c", 100, E, Six); // better reliability beats credibility tier + let d = claim("d", 200, F, Six); // later occurred_at beats everything + let claims = [&a, &b, &c, &d]; + // recorded_at all equal; exhaustive permutations of arrival order + for perm in permutations(&claims) { + let group: Vec = perm.iter().map(|c| BeliefInput { claim: c, recorded_at: 1 }).collect(); + assert_eq!(resolve(&group, Credibility::Six).unwrap().claim_key, "d"); + } + // tie on everything but recorded_at + let g = [BeliefInput { claim: &a, recorded_at: 5 }, BeliefInput { claim: &b0(&a, "a2"), recorded_at: 9 }]; + assert_eq!(resolve(&g, Credibility::Six).unwrap().claim_key, "a2"); + // full tie -> lexicographically larger claim_key + let g = [BeliefInput { claim: &a, recorded_at: 5 }, BeliefInput { claim: &b0(&a, "z") , recorded_at: 5 }]; + assert_eq!(resolve(&g, Credibility::Six).unwrap().claim_key, "z"); + } + + fn b0(base: &Claim, key: &str) -> Claim { let mut c = base.clone(); c.claim_key = key.into(); c } + + fn permutations<'a>(xs: &[&'a Claim]) -> Vec> { + if xs.len() <= 1 { return vec![xs.to_vec()]; } + let mut out = vec![]; + for i in 0..xs.len() { + let mut rest = xs.to_vec(); + let x = rest.remove(i); + for mut p in permutations(&rest) { p.insert(0, x); out.push(p); } + } + out + } + + #[test] + fn u_belief_2_credibility_floor() { + use Reliability::*; use Credibility::*; + let good = claim("good", 100, A, Two); + let bad = claim("bad", 200, A, Five); // newer but below floor Three + let g = [BeliefInput { claim: &good, recorded_at: 1 }, BeliefInput { claim: &bad, recorded_at: 2 }]; + assert_eq!(resolve(&g, Three).unwrap().claim_key, "good"); + // only-claim exception + let g = [BeliefInput { claim: &bad, recorded_at: 2 }]; + assert_eq!(resolve(&g, Three).unwrap().claim_key, "bad"); + // all floored, >= 2 members -> nobody believed + let bad2 = claim("bad2", 300, A, Six); + let g = [BeliefInput { claim: &bad, recorded_at: 2 }, BeliefInput { claim: &bad2, recorded_at: 3 }]; + assert!(resolve(&g, Three).is_none()); + } +} +``` + +- [ ] **Step 2: Run** — compile error. +- [ ] **Step 3: Implement** — `resolve` filters by floor, falls back to only-claim exception, picks `max_by_key(belief_key)`. +- [ ] **Step 4: Run** — 2 PASS. +- [ ] **Step 5: Commit** — `git commit -m "feat(clog): belief resolution total order (U-BELIEF-1/2)"` + +--- + +### Task 6: Alias map (`alias.rs`) — U-ALIAS-1/2 + +**Files:** +- Create: `examples/clog/src/alias.rs` (+ `pub(crate) mod alias;`) + +**Interfaces:** +- Consumes: `ClogError` (Task 2). +- Produces: + - `pub(crate) type EntityKey = (String, String);` — (etype, id) + - `#[derive(Clone, Default)] pub(crate) struct AliasMap { edges: imbl::OrdMap }` + - `pub(crate) fn resolve(&self, k: &EntityKey) -> EntityKey` — one hop (edges are depth-1 by construction); identity if absent. + - `pub(crate) fn flatten_target(&self, canonical: &EntityKey) -> EntityKey` — write-time flattening: if `canonical` is itself aliased, return its target (§5.2). + - `pub(crate) fn insert(&mut self, alias: EntityKey, canonical: EntityKey) -> Result<(), ClogError>` — flattens the target, then rejects `AliasCycle` if the flattened target equals `alias` or the edge would point at itself; also **re-points any existing edges whose target is `alias`** to the new canonical, keeping depth-1 (a→b then b→c re-points a→c). + - `pub(crate) fn remove(&mut self, alias: &EntityKey)` + - `pub(crate) fn iter(&self) -> impl Iterator` + +- [ ] **Step 1: Write failing tests** + +```rust +#[cfg(test)] +mod tests { + use super::*; + fn k(e: &str, i: &str) -> EntityKey { (e.into(), i.into()) } + + #[test] + fn u_alias_1_write_time_flattening() { + let mut m = AliasMap::default(); + m.insert(k("p", "a"), k("p", "b")).unwrap(); + // b -> c: the stored edge for a must re-point to c (depth-1, no chains) + m.insert(k("p", "b"), k("p", "c")).unwrap(); + assert_eq!(m.resolve(&k("p", "a")), k("p", "c")); + assert_eq!(m.resolve(&k("p", "b")), k("p", "c")); + // inserting x -> a flattens to x -> c at write time + m.insert(k("p", "x"), k("p", "a")).unwrap(); + assert_eq!(m.resolve(&k("p", "x")), k("p", "c")); + assert_eq!(m.resolve(&k("p", "unrelated")), k("p", "unrelated")); + } + + #[test] + fn u_alias_2_cycle_rejected() { + let mut m = AliasMap::default(); + m.insert(k("p", "a"), k("p", "b")).unwrap(); + assert!(matches!(m.insert(k("p", "b"), k("p", "a")), Err(crate::ClogError::AliasCycle))); + assert!(matches!(m.insert(k("p", "z"), k("p", "z")), Err(crate::ClogError::AliasCycle))); + } + + #[test] + fn remove_unmerges() { + let mut m = AliasMap::default(); + m.insert(k("p", "a"), k("p", "b")).unwrap(); + m.remove(&k("p", "a")); + assert_eq!(m.resolve(&k("p", "a")), k("p", "a")); + } +} +``` + +(U-ALIAS-3 — retraction re-keys grouped views — needs the engine; it lands in Task 11.) + +- [ ] **Step 2: Run** — compile error. +- [ ] **Step 3: Implement.** Note the re-point rule in `insert`: after flattening the target, iterate `edges` for entries whose value == `alias` and re-point them; then check `flattened == alias` → `AliasCycle`. +- [ ] **Step 4: Run** — 3 PASS. +- [ ] **Step 5: Commit** — `git commit -m "feat(clog): depth-1 alias map with flattening and cycle rejection (U-ALIAS-1/2)"` + +--- + +### Task 7: Rules tier (`kinds.rs`) — U-KIND-1 + +**Files:** +- Create: `examples/clog/src/kinds.rs` (+ `pub(crate) mod kinds;`) + +**Interfaces:** +- Consumes: `Claim`, `Matcher`, `Rule`, `KindTaxonomy`, `KindLabel`, `JudgeSource` (Task 2). +- Produces: + - `pub(crate) struct RuleSet { /* compiled: Vec<(kind_name, Vec)> in config order; regexes pre-compiled */ }` + - `pub(crate) fn compile(tax: &KindTaxonomy) -> Result` — bad regex → `ClogError::InvalidClaim`-style? No: bad regex in *config* → `ClogError::Corrupt { detail }` is wrong too. Use `ClogError::TemplateError` is wrong. **Add nothing**: return `ClogError::UnknownKind` is wrong. Correct call: config errors at `open` time are reported as `ClogError::InvalidClaim { index: 0, reason }`? No. **Decision (document in rustdoc): invalid rule regex → `ClogError::Corrupt { detail: "config: bad regex …" }`** — config is host-supplied state and Corrupt is the taxonomy for unusable persistent/config state. Revisit in P3 if a dedicated `InvalidConfig` variant earns its place. + - `pub(crate) fn classify(rs: &RuleSet, c: &Claim) -> Option` — first matching rule in config order wins, confidence 1.0, source Rule. A rule matches when **any** of its matchers match (§5.6 `any_of`). + +- [ ] **Step 1: Write failing tests** + +```rust +#[cfg(test)] +mod tests { + use super::*; + use crate::types::*; + use crate::validate::tests_base_claim; + + fn tax_with_rules() -> KindTaxonomy { + let mut tax = KindTaxonomy::default_taxonomy(); + // risk declared before question in default order? No: default order is + // fact, decision, risk, question, ... — rules are evaluated in config order. + for kd in &mut tax.kinds { + match kd.name.as_str() { + "risk" => kd.rules.push(Rule { any_of: vec![Matcher::BodyContains("overdue".into())] }), + "question" => kd.rules.push(Rule { any_of: vec![ + Matcher::BodyRegex(r"\?$".into()), + Matcher::ObserverIs("faq-bot".into()), + ] }), + "fact" => kd.rules.push(Rule { any_of: vec![Matcher::EntityType("bankfeed".into())] }), + _ => {} + } + } + tax + } + + #[test] + fn u_kind_1_first_match_wins_in_config_order() { + let rs = compile(&tax_with_rules()).unwrap(); + let mut c = tests_base_claim(); + // matches BOTH fact (entity type) and risk (body) -> fact wins (declared first) + c.body = "Invoice 1042 is OVERDUE".into(); + c.entities = vec![EntityRef { etype: "bankfeed".into(), id: "x".into(), name: None }]; + let k = classify(&rs, &c).unwrap(); + assert_eq!(k.kind, "fact"); + assert_eq!(k.confidence, 1.0); + assert!(matches!(k.source, JudgeSource::Rule)); + + // case-insensitive BodyContains + c.entities.clear(); + assert_eq!(classify(&rs, &c).unwrap().kind, "risk"); + + // regex matcher + c.body = "did we sign the SOW?".into(); + assert_eq!(classify(&rs, &c).unwrap().kind, "question"); + + // observer matcher (any_of) + c.body = "no punctuation".into(); + c.observer = ObserverId::from("faq-bot"); + assert_eq!(classify(&rs, &c).unwrap().kind, "question"); + + // no match -> None + c.observer = ObserverId::from("o1"); + assert!(classify(&rs, &c).is_none()); + } + + #[test] + fn bad_regex_rejected_at_compile() { + let mut tax = KindTaxonomy::default_taxonomy(); + tax.kinds[0].rules.push(Rule { any_of: vec![Matcher::BodyRegex("(".into())] }); + assert!(matches!(compile(&tax), Err(ClogError::Corrupt { .. }))); + } +} +``` + +- [ ] **Step 2: Run** — compile error. +- [ ] **Step 3: Implement.** `BodyContains` lowercases both sides (`to_lowercase`, correct-enough for v1; document). Fix the stray non-ASCII character in the Produces note if copied (keep rustdoc ASCII). +- [ ] **Step 4: Run** — 2 PASS. +- [ ] **Step 5: Commit** — `git commit -m "feat(clog): rules-tier classifier (U-KIND-1)"` + +--- + +### Task 8: Template parser + RFC3339 (`render/template.rs`, `render/time.rs`) — U-TMPL-1 + +**Files:** +- Create: `examples/clog/src/render/mod.rs` (declares submodules; renderer body comes in Task 9), `examples/clog/src/render/template.rs`, `examples/clog/src/render/time.rs` (+ `pub(crate) mod render;`) + +**Interfaces:** +- Produces: + - `pub(crate) enum SlotName { Header, Urgent, OpenLoops, Entities, Changes }` + - `pub(crate) enum Segment { Text(String), Slot { name: SlotName, limit: Option } }` + - `pub(crate) struct Template(pub Vec);` + - `pub(crate) fn parse(src: &str) -> Result` — grammar §5.8; malformed → `ClogError::TemplateError(msg)`. No escape for literal `%{` (documented). + - `pub(crate) const DEFAULT_TEMPLATE: &str` — byte-for-byte the spec §5.8 default. + - `pub(crate) fn rfc3339_utc(ms: u64) -> String` — `YYYY-MM-DDTHH:MM:SSZ`, seconds precision. + +- [ ] **Step 1: Write failing tests** + +```rust +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn u_tmpl_1_grammar_accept_reject() { + // accepts + assert!(parse("plain text no slots").is_ok()); + assert!(parse("%{header}").is_ok()); + assert!(parse("a %{urgent limit=8} b %{open_loops} c").is_ok()); + assert!(parse("%{entities limit=10}%{changes limit=6}").is_ok()); + // slot with limit parses the value + let t = parse("%{urgent limit=3}").unwrap(); + assert!(matches!(&t.0[0], Segment::Slot { name: SlotName::Urgent, limit: Some(3) })); + // rejects + for bad in [ + "%{nope}", // unknown slot name + "%{urgent", // unterminated + "%{urgent limit=}", // empty value + "%{urgent limit=abc}", // non-numeric + "%{urgent size=3}", // unknown key + "%{}", // empty slot + ] { + assert!(matches!(parse(bad), Err(crate::ClogError::TemplateError(_))), "{bad}"); + } + } + + #[test] + fn default_template_is_spec_bytes() { + // frozen by spec §5.8; U-TMPL-3 goldens depend on this exact string + assert!(DEFAULT_TEMPLATE.starts_with("# situation · scope: %{header}\n")); + assert!(DEFAULT_TEMPLATE.contains("%{urgent limit=8}")); + assert!(DEFAULT_TEMPLATE.contains("%{open_loops limit=10}")); + assert!(DEFAULT_TEMPLATE.contains("%{entities limit=10}")); + assert!(DEFAULT_TEMPLATE.contains("%{changes limit=6}")); + assert!(parse(DEFAULT_TEMPLATE).is_ok()); + } + + #[test] + fn rfc3339_known_values() { + assert_eq!(rfc3339_utc(0), "1970-01-01T00:00:00Z"); + assert_eq!(rfc3339_utc(86_400_000), "1970-01-02T00:00:00Z"); + // 2000-03-01 is the canonical leap-era edge in the civil algorithm + assert_eq!(rfc3339_utc(951_868_800_000), "2000-03-01T00:00:00Z"); + assert_eq!(rfc3339_utc(1_755_216_000_000), "2026-08-15T00:00:00Z"); + assert_eq!(rfc3339_utc(1_755_262_496_000), "2026-08-15T12:54:56Z"); + } +} +``` + +- [ ] **Step 2: Run** — compile error. + +- [ ] **Step 3: Implement.** Parser: scan for `%{`, take until `}` (missing `}` → error), split on whitespace; first token is the slot name, remaining tokens must be `limit=`. The default template (exact, trailing newline included): + +```rust +pub(crate) const DEFAULT_TEMPLATE: &str = "\ +# situation · scope: %{header} + +## urgent +%{urgent limit=8} + +## open loops +%{open_loops limit=10} + +## entities +%{entities limit=10} + +## changes since last brief +%{changes limit=6} +"; +``` + +`rfc3339_utc` via Howard Hinnant's civil-from-days: + +```rust +pub(crate) fn rfc3339_utc(ms: u64) -> String { + let secs = ms / 1000; + let (days, rem) = (secs / 86_400, secs % 86_400); + let (h, m, s) = (rem / 3600, (rem % 3600) / 60, rem % 60); + let z = days as i64 + 719_468; + let era = z.div_euclid(146_097); + let doe = z.rem_euclid(146_097); + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let mo = if mp < 10 { mp + 3 } else { mp - 9 }; + let y = yoe + era * 400 + i64::from(mo <= 2); + format!("{y:04}-{mo:02}-{d:02}T{h:02}:{m:02}:{s:02}Z") +} +``` + +- [ ] **Step 4: Run** — 3 PASS. +- [ ] **Step 5: Commit** — `git commit -m "feat(clog): template parser and RFC3339 (U-TMPL-1)"` + +--- + +### Task 9: Slot renderers + budgeter (`render/mod.rs`) — U-TMPL-2/3 + +**Files:** +- Modify: `examples/clog/src/render/mod.rs` + +**Interfaces:** +- Consumes: `Template`, `Segment`, `SlotName`, `rfc3339_utc` (Task 8); `Reliability::letter`, `Credibility::digit` (Task 2). +- Produces (these are the engine→renderer contract; Tasks 11–15 build them): + - `pub(crate) fn headline(body: &str) -> String` — whitespace-collapsed (`split_whitespace().join(" ")`), first 120 **chars**. + - `pub(crate) struct UrgentItem { pub score: f32, pub headline: String, pub reliability: char, pub credibility: u8, pub claim_key: String }` + - `pub(crate) struct LoopItem { pub kind: String, pub headline: String, pub claim_key: String }` + - `pub(crate) struct EntityItem { pub display: String, pub summaries: Vec }` — summaries newest-first. + - `pub(crate) enum ChangeItem { Added(String), Removed(String) }` — payload is the headline. + - `pub(crate) struct SlotInputs { pub scope: String, pub rev: u64, pub as_of_ms: u64, pub urgent: Vec, pub open_loops: Vec, pub entities: Vec, pub changes: Vec }` + - `pub(crate) fn render(t: &Template, inputs: &SlotInputs, budget_chars: usize) -> String` + +Item formats (frozen by goldens; spec §5.8): +- header: `{scope} · rev {rev} · {rfc3339_utc(as_of_ms)}` +- urgent: `{rank}. ({score:.1}) {headline} [{R}/{C}] ({claim_key})` — rank starts at 1 +- open_loops: `- {KIND} {headline} ({claim_key})` — kind uppercased +- entities: `{display}: {summaries joined with "; "}` +- changes: `+ {headline}` / `- {headline}` + +Budgeting: render all slots; if total chars > budget, drop whole items from the **end** of slots in reverse priority order — changes, then entities, then open_loops, then urgent — until under budget; each truncated slot gets a final line `… ({n} more)`. Never truncate mid-item. Empty slots render as `(none)`. + +- [ ] **Step 1: Write failing tests** + +```rust +#[cfg(test)] +mod render_tests { + use super::*; + use crate::render::template::{parse, DEFAULT_TEMPLATE}; + + fn inputs() -> SlotInputs { + SlotInputs { + scope: "default".into(), rev: 7, as_of_ms: 86_400_000, + urgent: vec![ + UrgentItem { score: 1.25, headline: "Invoice 1042 overdue".into(), reliability: 'B', credibility: 2, claim_key: "inv".into() }, + UrgentItem { score: 0.5, headline: "Kickoff moved".into(), reliability: 'A', credibility: 1, claim_key: "kick".into() }, + ], + open_loops: vec![LoopItem { kind: "question".into(), headline: "Did we sign?".into(), claim_key: "q1".into() }], + entities: vec![EntityItem { display: "Halcyon".into(), summaries: vec!["paid".into(), "kicked off".into()] }], + changes: vec![ChangeItem::Added("Invoice 1042 overdue".into()), ChangeItem::Removed("old thing".into())], + } + } + + #[test] + fn u_tmpl_3_default_template_byte_stability() { + let out = render(&parse(DEFAULT_TEMPLATE).unwrap(), &inputs(), 6000); + let expected = "\ +# situation · scope: default · rev 7 · 1970-01-02T00:00:00Z + +## urgent +1. (1.2) Invoice 1042 overdue [B/2] (inv) +2. (0.5) Kickoff moved [A/1] (kick) + +## open loops +- QUESTION Did we sign? (q1) + +## entities +Halcyon: paid; kicked off + +## changes since last brief ++ Invoice 1042 overdue +- old thing +"; + assert_eq!(out, expected); + } + + #[test] + fn u_tmpl_2_budget_truncation_order() { + // budget small enough to force dropping all changes and one entity summary line + let t = parse(DEFAULT_TEMPLATE).unwrap(); + let full = render(&t, &inputs(), 6000); + let tight = render(&t, &inputs(), full.len() - 1); + // changes go first, replaced by the marker + assert!(tight.contains("… (") && tight.contains("more)")); + assert!(!tight.contains("- old thing")); + // urgent survives longest + assert!(tight.contains("1. (1.2)")); + // never over budget + assert!(tight.chars().count() <= full.len() - 1 || tight.contains("more)")); + } + + #[test] + fn per_slot_limit_caps_items() { + let t = parse("%{urgent limit=1}").unwrap(); + let out = render(&t, &inputs(), 6000); + assert!(out.contains("1. (1.2)")); + assert!(!out.contains("Kickoff")); + assert!(out.contains("… (1 more)")); + } + + #[test] + fn headline_collapses_and_caps() { + assert_eq!(headline(" a\n\n b\tc "), "a b c"); + let long = "x".repeat(300); + assert_eq!(headline(&long).chars().count(), 120); + } + + #[test] + fn empty_slots_render_none() { + let t = parse("%{changes}").unwrap(); + let mut i = inputs(); + i.changes.clear(); + assert_eq!(render(&t, &i, 6000), "(none)"); + } +} +``` + +- [ ] **Step 2: Run** — compile error. +- [ ] **Step 3: Implement.** Slot limit semantics: per-slot `limit` caps items *before* budgeting and contributes its own `… (n more)` if it cut anything; budget truncation appends/updates the marker with the total hidden count. Items within a slot joined by `\n`. +- [ ] **Step 4: Run** — 5 PASS. +- [ ] **Step 5: Commit** — `git commit -m "feat(clog): slot renderers and budgeter (U-TMPL-2/3)"` + +--- + +### Task 10: Engine contract (`engine/mod.rs`) + +**Files:** +- Create: `examples/clog/src/engine/mod.rs` (+ `pub(crate) mod engine;`) + +**Interfaces:** +- Consumes: types from Task 2, `AliasMap`/`EntityKey` (Task 6), `KindLabel`. +- Produces: + - `pub(crate) struct StoredClaim { pub claim: Claim, pub recorded_at: u64 }` (serde) + - `pub(crate) enum Event { Observe(StoredClaim), Retract { claim_key: String }, Revoke { observer: ObserverId }, SetFocus { scope: String, focus: Focus }, Judge { claim_key: String, kind: String, confidence: f32, source: JudgeSource }, Tick { epoch: u64 } }` (serde — the WAL format) + - `pub(crate) struct Batch { pub rev: Rev, pub events: Vec }` (serde) + - `#[derive(Clone)] pub(crate) struct WorldViews { pub claims: OrdMap, pub kinds: OrdMap, pub unclassified: OrdSet, pub by_subject: OrdMap>, pub by_observer: OrdMap>, pub by_entity: OrdMap>, pub aliases: AliasMap, pub names: OrdMap, pub believed: OrdMap>, pub open_loops: OrdSet, pub urgent: OrdMap> }` + - `claims` holds **all** live claims including reserved `clog:*` ones; every view accessor and `select` filters reserved keys out (INV-8). `believed` maps subject_key → winning claim_key (`None` = all-floored group). + - `urgent` vectors are sorted score-desc, tie claim_key-asc, truncated to the scope's top_k. + - `pub(crate) struct ApplyResult { pub touched: bool }` — P1's naive re-render recomputes every scope's slot inputs per batch (auditable oracle; see build design §5); `touched=false` short-circuits when a batch applied zero effective events. Richer per-view diffs arrive in P2 when wakes need them. + - `pub(crate) trait Engine: Send { fn apply(&mut self, events: &[Event], scopes: &BTreeMap, now_ms: u64) -> ApplyResult; fn views(&self) -> &WorldViews; }` + +- [ ] **Step 1: Write the failing test** — serde stability of the WAL types + +```rust +#[cfg(test)] +mod tests { + use super::*; + use crate::validate::tests_base_claim; + + #[test] + fn batch_round_trips_postcard() { + let b = Batch { rev: 3, events: vec![ + Event::Observe(StoredClaim { claim: tests_base_claim(), recorded_at: 9 }), + Event::Retract { claim_key: "k1".into() }, + Event::Judge { claim_key: "k1".into(), kind: "risk".into(), confidence: 1.0, source: crate::JudgeSource::Rule }, + Event::Tick { epoch: 4 }, + ]}; + let bytes = postcard::to_allocvec(&b).unwrap(); + let b2: Batch = postcard::from_bytes(&bytes).unwrap(); + assert_eq!(b2.rev, 3); + assert_eq!(b2.events.len(), 4); + } +} +``` + +- [ ] **Step 2: Run** — compile error. **Step 3: Implement** the types above (plus `mod naive;` stub left empty until Task 11). **Step 4: Run** — PASS. **Step 5: Commit** — `git commit -m "feat(clog): engine contract, event and batch types"` + +--- + +### Task 11: Naive engine, part 1 — observe/retract/judge (`engine/naive.rs`) + +**Files:** +- Create: `examples/clog/src/engine/naive.rs` + +**Interfaces:** +- Consumes: Task 10 contract; `belief::resolve` (Task 5), `score::score_claim` (Task 4), `AliasMap` (Task 6). +- Produces: `pub(crate) struct NaiveEngine { views: WorldViews, cfg: NaiveCfg }` with `pub(crate) fn new(cfg: NaiveCfg) -> Self` and `impl Engine for NaiveEngine`. `pub(crate) struct NaiveCfg { pub loop_kinds: Vec, pub top_k: usize, pub buckets_per_half_life: u32, pub belief_floor: Credibility }`. + +Apply semantics per event (P1 subset; `SetFocus`/`Tick` are accepted but only `SetFocus` on an unknown scope is impossible here — the actor rejects them until P2; `Revoke` handled in Task 12): +- `Observe(sc)`: insert into `claims`; index into `by_subject`/`by_observer`/`by_entity` (entity keys resolved through `aliases`); update `names` for each entity carrying a name (latest `recorded_at` wins); if the key is a **merge claim** (`clog:merge:` prefix), parse its body (`{"alias":{"etype":..,"id":..},"canonical":{..}}` JSON — hand-rolled parse with `regex` or simple string ops is NOT acceptable; store the two `EntityKey`s postcard-encoded in the body as base64? **No.** Decision: merge claim body is `alias.etype\u{1f}alias.id\u{1f}canonical.etype\u{1f}canonical.id` joined with the ASCII unit separator — trivially split, no JSON dep; document in rustdoc and the ledger) → `aliases.insert` and re-key `by_entity` groups that resolved differently before/after; recompute `believed` for the claim's subject group; recompute `open_loops`/`unclassified` membership for this key; mark all scopes dirty. +- `Retract { claim_key }`: remove from `claims` and all indexes; if merge claim → `aliases.remove` and re-key affected `by_entity` groups (U-ALIAS-3); drop its `kinds` entry; recompute its subject group's `believed`; remove from `open_loops`/`unclassified`. +- `Judge { claim_key, .. }`: upsert `kinds[claim_key]`; recompute `unclassified` (remove) and `open_loops` (member iff kind ∈ loop_kinds and claim live and non-reserved). +- After all events: recompute `urgent[scope]` for every scope from all live non-reserved claims (full re-score — the naive engine is the auditable oracle; targeted scoring arrives with fold in P4 if benches demand it earlier, they won't at fixture scale). + +`unclassified` membership: live, non-reserved, and either no `kinds` entry or `kinds[key].confidence < 1.0` with source ≠ External — for P1 simply: no `kinds` entry. + +- [ ] **Step 1: Write failing tests** + +```rust +#[cfg(test)] +mod tests { + use super::*; + use crate::engine::{Engine, Event, StoredClaim}; + use crate::types::*; + use crate::validate::tests_base_claim; + use std::collections::BTreeMap; + + fn cfg() -> NaiveCfg { + NaiveCfg { loop_kinds: vec!["question".into(), "risk".into(), "commitment".into()], + top_k: 12, buckets_per_half_life: 4, belief_floor: Credibility::Six } + } + fn scopes() -> BTreeMap { + BTreeMap::from([("default".to_string(), Focus::uniform())]) + } + fn obs(key: &str, subject: Option<&str>, body: &str, occ: u64, rec: u64) -> Event { + let mut c = tests_base_claim(); + c.claim_key = key.into(); + c.subject_key = subject.map(Into::into); + c.body = body.into(); + c.occurred_at = occ; c.observed_at = occ; + Event::Observe(StoredClaim { claim: c, recorded_at: rec }) + } + + #[test] + fn observe_retract_round_trip_inv3() { + let mut e = NaiveEngine::new(cfg()); + let empty = e.views().clone(); + e.apply(&[obs("a", Some("s1"), "hello", 10, 10)], &scopes(), 1000); + assert!(e.views().claims.contains_key("a")); + assert_eq!(e.views().believed.get("s1"), Some(&Some("a".to_string()))); + e.apply(&[Event::Retract { claim_key: "a".into() }], &scopes(), 1001); + // INV-3: identical to never having observed (view contents, not revs) + assert!(e.views().claims.is_empty()); + assert!(e.views().believed.is_empty()); + assert!(e.views().by_subject.is_empty()); + assert_eq!(e.views().urgent.get("default").map(Vec::len), empty.urgent.get("default").map(Vec::len).or(Some(0))); + } + + #[test] + fn belief_competition_and_flags() { + let mut e = NaiveEngine::new(cfg()); + e.apply(&[obs("old", Some("s1"), "invoice overdue", 100, 1), + obs("new", Some("s1"), "invoice paid", 200, 2)], &scopes(), 1000); + assert_eq!(e.views().believed.get("s1"), Some(&Some("new".to_string()))); + } + + #[test] + fn judge_moves_between_views() { + let mut e = NaiveEngine::new(cfg()); + e.apply(&[obs("a", None, "x", 10, 10)], &scopes(), 1000); + assert!(e.views().unclassified.contains("a")); + assert!(!e.views().open_loops.contains("a")); + e.apply(&[Event::Judge { claim_key: "a".into(), kind: "risk".into(), confidence: 1.0, source: JudgeSource::Rule }], &scopes(), 1001); + assert!(!e.views().unclassified.contains("a")); + assert!(e.views().open_loops.contains("a")); + assert_eq!(e.views().kinds.get("a").unwrap().kind, "risk"); + } + + #[test] + fn urgent_ranked_desc_tiebreak_key() { + let mut e = NaiveEngine::new(cfg()); + // same trust/recency -> equal scores -> claim_key asc breaks tie + e.apply(&[obs("b", None, "x", 100, 1), obs("a", None, "y", 100, 1)], &scopes(), 200); + let u = e.views().urgent.get("default").unwrap(); + assert_eq!(u.iter().map(|(_, k)| k.as_str()).collect::>(), vec!["a", "b"]); + } + + #[test] + fn reserved_claims_invisible_in_urgent_and_loops() { + let mut e = NaiveEngine::new(cfg()); + let mut c = tests_base_claim(); + c.claim_key = "clog:merge:p:a->p:b".into(); + c.body = ["p", "a", "p", "b"].join("\u{1f}"); + e.apply(&[Event::Observe(StoredClaim { claim: c, recorded_at: 1 })], &scopes(), 100); + assert!(e.views().urgent.get("default").unwrap().is_empty()); + assert!(e.views().unclassified.is_empty()); + // but the alias took effect + assert_eq!(e.views().aliases.resolve(&("p".into(), "a".into())), ("p".into(), "b".into())); + } +} +``` + +- [ ] **Step 2: Run** — compile error / failures. +- [ ] **Step 3: Implement** per the semantics above. Keep every recompute a small private fn (`reindex_claim`, `unindex_claim`, `recompute_belief(subject)`, `recompute_membership(key)`, `recompute_urgent(scopes, now)`) so the code stays auditable-by-eye (spec §6.2 requirement). +- [ ] **Step 4: Run** — 5 PASS. +- [ ] **Step 5: Commit** — `git commit -m "feat(clog): naive engine observe/retract/judge with belief and urgent views"` + +--- + +### Task 12: Naive engine, part 2 — revoke, alias re-keying, entity registry (U-ALIAS-3) + +**Files:** +- Modify: `examples/clog/src/engine/naive.rs` + +**Interfaces:** +- Consumes/Produces: same `Engine` impl; adds `Revoke` handling and completes alias/registry semantics. + +Semantics: +- `Revoke { observer }`: expand via `by_observer[observer]` to retractions of every live claim of that observer **including reserved claims** (INV-6; spec §5.1), applied in claim_key order (determinism). +- Alias insert/retract must re-key `by_entity` and re-resolve `names` (registry keyed by canonical key; on un-merge, names recompute from remaining claims' latest-seen). +- `entity_state` accessor: `pub(crate) fn entity_state(views: &WorldViews) -> Vec<(EntityKey, String, Vec<(String, StoredClaim)>)>` — per canonical entity (sorted): display name (registry name or `etype:id`), and believed claims for subjects touching that entity, newest-first by `occurred_at` (tie: claim_key), capped at 8 (spec internal constant N=8). + +- [ ] **Step 1: Write failing tests** + +```rust + #[test] + fn revoke_retracts_all_of_observer_inv6() { + let mut e = NaiveEngine::new(cfg()); + let mut c1 = tests_base_claim(); c1.claim_key = "a".into(); c1.observer = ObserverId::from("gmail"); + let mut c2 = tests_base_claim(); c2.claim_key = "b".into(); c2.observer = ObserverId::from("gmail"); + let mut c3 = tests_base_claim(); c3.claim_key = "c".into(); c3.observer = ObserverId::from("twist"); + e.apply(&[Event::Observe(StoredClaim { claim: c1, recorded_at: 1 }), + Event::Observe(StoredClaim { claim: c2, recorded_at: 1 }), + Event::Observe(StoredClaim { claim: c3, recorded_at: 1 })], &scopes(), 100); + e.apply(&[Event::Revoke { observer: ObserverId::from("gmail") }], &scopes(), 101); + assert!(!e.views().claims.contains_key("a")); + assert!(!e.views().claims.contains_key("b")); + assert!(e.views().claims.contains_key("c")); + assert!(e.views().by_observer.get("gmail").is_none()); + } + + #[test] + fn u_alias_3_merge_retraction_rekeys_views() { + let mut e = NaiveEngine::new(cfg()); + let mut c = tests_base_claim(); + c.claim_key = "about-a".into(); + c.entities = vec![EntityRef { etype: "p".into(), id: "a".into(), name: Some("Aye".into()) }]; + e.apply(&[Event::Observe(StoredClaim { claim: c, recorded_at: 1 })], &scopes(), 100); + + let mut m = tests_base_claim(); + m.claim_key = "clog:merge:p:a->p:b".into(); + m.observer = ObserverId::from("clog"); + m.body = ["p", "a", "p", "b"].join("\u{1f}"); + e.apply(&[Event::Observe(StoredClaim { claim: m, recorded_at: 2 })], &scopes(), 101); + // grouped under canonical b now + assert!(e.views().by_entity.get(&("p".into(), "b".into())).unwrap().contains("about-a")); + assert!(e.views().by_entity.get(&("p".into(), "a".into())).is_none()); + + e.apply(&[Event::Retract { claim_key: "clog:merge:p:a->p:b".into() }], &scopes(), 102); + // un-merged: re-keyed back under a, name registry intact + assert!(e.views().by_entity.get(&("p".into(), "a".into())).unwrap().contains("about-a")); + let es = entity_state(e.views()); + let (_, display, rows) = es.iter().find(|(k, _, _)| k == &("p".to_string(), "a".to_string())).unwrap(); + assert_eq!(display, "Aye"); + assert_eq!(rows.len(), 0); // no subject_key -> no believed rows + } + + #[test] + fn entity_state_newest_first_capped() { + let mut e = NaiveEngine::new(cfg()); + let ent = EntityRef { etype: "proj".into(), id: "h".into(), name: None }; + let mut evs = vec![]; + for i in 0..10 { + let mut c = tests_base_claim(); + c.claim_key = format!("c{i}"); + c.subject_key = Some(format!("s{i}")); + c.occurred_at = 100 + i; + c.entities = vec![ent.clone()]; + evs.push(Event::Observe(StoredClaim { claim: c, recorded_at: 1 })); + } + e.apply(&evs, &scopes(), 1000); + let es = entity_state(e.views()); + let (_, display, rows) = &es[0]; + assert_eq!(display, "proj:h"); + assert_eq!(rows.len(), 8); + assert_eq!(rows[0].0, "s9"); // newest occurred_at first + } +``` + +- [ ] **Step 2: Run** — failures. **Step 3: Implement.** **Step 4: Run** — 3 new PASS, all prior green. **Step 5: Commit** — `git commit -m "feat(clog): revoke expansion, alias re-keying, entity registry (U-ALIAS-3, INV-6)"` + +--- + +### Task 13: WAL (`wal.rs`) + +**Files:** +- Create: `examples/clog/src/wal.rs` (+ `pub(crate) mod wal;`) + +**Interfaces:** +- Consumes: `Batch` (Task 10), `FsyncPolicy`, `ClogError`. +- Produces: + - `pub(crate) struct Wal { /* file handle, path, fsync policy */ }` + - `pub(crate) fn open_dir(dir: &Path, fsync: FsyncPolicy) -> Result<(Wal, Vec), ClogError>` — creates `dir/wal/log`, replays existing records; a torn/corrupt tail is truncated from the log and the removed bytes are appended to `dir/wal/wal.corrupt` (R2); any record failing CRC ends replay the same way (never applied, never panics). + - `pub(crate) fn append(&mut self, batch: &Batch) -> Result<(), ClogError>` — frame: `[len: u32 LE][crc32(payload): u32 LE][payload = postcard(batch)]`; fsync per policy after write. + +- [ ] **Step 1: Write failing tests** + +```rust +#[cfg(test)] +mod tests { + use super::*; + use crate::engine::{Batch, Event}; + use std::io::{Read, Seek, SeekFrom, Write}; + + fn batch(rev: u64) -> Batch { Batch { rev, events: vec![Event::Tick { epoch: rev }] } } + + #[test] + fn append_and_replay_round_trip() { + let dir = tempfile::tempdir().unwrap(); + { + let (mut w, replayed) = open_dir(dir.path(), crate::FsyncPolicy::OnCommit).unwrap(); + assert!(replayed.is_empty()); + w.append(&batch(1)).unwrap(); + w.append(&batch(2)).unwrap(); + } + let (_, replayed) = open_dir(dir.path(), crate::FsyncPolicy::OnCommit).unwrap(); + assert_eq!(replayed.iter().map(|b| b.rev).collect::>(), vec![1, 2]); + } + + #[test] + fn r2_torn_tail_truncated_and_quarantined() { + let dir = tempfile::tempdir().unwrap(); + { + let (mut w, _) = open_dir(dir.path(), crate::FsyncPolicy::OnCommit).unwrap(); + w.append(&batch(1)).unwrap(); + w.append(&batch(2)).unwrap(); + } + // tear the last record: chop 3 bytes off the file + let log = dir.path().join("wal").join("log"); + let len = std::fs::metadata(&log).unwrap().len(); + let f = std::fs::OpenOptions::new().write(true).open(&log).unwrap(); + f.set_len(len - 3).unwrap(); + let (_, replayed) = open_dir(dir.path(), crate::FsyncPolicy::OnCommit).unwrap(); + assert_eq!(replayed.iter().map(|b| b.rev).collect::>(), vec![1]); + assert!(dir.path().join("wal").join("wal.corrupt").exists()); + // reopening again is clean (tail already truncated) + let (_, replayed) = open_dir(dir.path(), crate::FsyncPolicy::OnCommit).unwrap(); + assert_eq!(replayed.len(), 1); + } + + #[test] + fn r2_corrupt_crc_never_applied_never_panics() { + let dir = tempfile::tempdir().unwrap(); + { + let (mut w, _) = open_dir(dir.path(), crate::FsyncPolicy::OnCommit).unwrap(); + w.append(&batch(1)).unwrap(); + w.append(&batch(2)).unwrap(); + } + let log = dir.path().join("wal").join("log"); + // flip a byte in the last record's payload + let mut bytes = std::fs::read(&log).unwrap(); + let n = bytes.len(); + bytes[n - 1] ^= 0xFF; + std::fs::write(&log, &bytes).unwrap(); + let (_, replayed) = open_dir(dir.path(), crate::FsyncPolicy::OnCommit).unwrap(); + assert_eq!(replayed.iter().map(|b| b.rev).collect::>(), vec![1]); + } +} +``` + +- [ ] **Step 2: Run** — compile error. **Step 3: Implement.** Replay reads frames sequentially; on short read, bad len (> remaining), or CRC mismatch: copy the offending tail bytes to `wal.corrupt` (append mode), `set_len` the log to the last good offset, stop. **Step 4: Run** — 3 PASS. **Step 5: Commit** — `git commit -m "feat(clog): postcard+crc32 WAL with torn-tail quarantine (R2)"` + +--- + +### Task 14: Clock, actor, and the write path (`clock.rs`, `actor.rs`, `lib.rs`) + +**Files:** +- Create: `examples/clog/src/clock.rs`, `examples/clog/src/actor.rs` +- Modify: `examples/clog/src/lib.rs` +- Test: `examples/clog/tests/api.rs` (integration; public API only) + +**Interfaces:** +- Consumes: everything prior. +- Produces the public handle (spec §4 subset): + - `pub struct Clog` — `Clone + Send + Sync`. + - `pub fn open(cfg: Config) -> Result` — opens WAL, replays batches into a fresh `NaiveEngine` (pure application — no classifier on replay), rebuilds render state batch-by-batch so situation revs are reproduced, spawns the writer thread, publishes the initial snapshot. `"default"` scope injected if absent. `rebuild_on_open` is accepted and (P1) identical to normal open. + - `pub fn observe(&self, claims: Vec, opts: ObserveOpts) -> Result` + - `pub fn retract(&self, claim_key: &str) -> Result` — `UnknownClaim` if not live. + - `pub fn situation(&self, scope: Option<&str>, template: Option<&str>) -> Result` — default template → clone of stored `Situation` (INV-1); custom template → parse (`TemplateError` rejects the call only) and assemble from the snapshot's stored `SlotInputs` (string assembly only, no view computation). + - `pub fn advance(&self, ms: u64) -> Result<(), ClogError>` — Manual clock only (`ManualClockRequired` otherwise); moves the clock (no Tick events in P1). + - Internal: `clock.rs` — `pub(crate) enum Clock { System, Manual(Arc) }` with `pub(crate) fn now_ms(&self) -> u64` (System = `SystemTime::now()` since epoch; the *only* wall-clock read in the crate). + - Internal: `actor.rs` — `enum Cmd { Write(WriteReq), Advance(u64, Sender<()>), Shutdown }`; writer loop owns `NaiveEngine + Wal + RenderState`; `WorldSnapshot { rev: Rev, as_of: u64, views: WorldViews, scopes: BTreeMap, situations: BTreeMap }`; `SituationState { situation: Situation, inputs: SlotInputs, membership: OrdSet }` (membership = urgent ∪ open_loops at last render, for the changes slot); published via `arc_swap::ArcSwap`; replies over `crossbeam_channel::bounded(1)`. + +Write path per batch (build design §4 order): +1. Validate all claims (whole batch rejected on first failure, atomic). +2. `recorded_at = clock.now_ms()` for every claim in the batch. +3. Expand upserts against current live claims: identical (`==` ignoring nothing — `StoredClaim.claim == new claim`) → skip entirely (INV-5); different → `Retract(old)` + `Observe(new)` (INV-4). +4. Run rules tier for observed claims lacking a kind → append `Judge` events (source Rule, confidence 1.0). +5. If the effective event list is empty → no commit: `Ack { rev: current, situation: opt }`. +6. `rev += 1`; `Batch { rev, events }` → `wal.append` (fsync per policy) — **then** `engine.apply`. +7. Recompute `SlotInputs` per scope from views (naive full recompute); render each scope's default template; if text differs from stored, update `SituationState { situation: Situation { rev, as_of: now }, .. }` and compute the changes delta (added/removed membership vs. previous, adds then removes, each claim_key-asc, headlines from views). +8. Build new `WorldSnapshot`, `ArcSwap::store`. +9. Reply `Ack { rev, situation }` (situation populated when `opts.return_situation` names a scope; `UnknownScope` if it doesn't exist). + +Shutdown: `Clog` wraps `Arc`; `Inner: Drop` sends `Shutdown` and joins the writer (fsync WAL). All calls after shutdown → `ShuttingDown`. + +- [ ] **Step 1: Write failing integration tests** (`tests/api.rs`) + +```rust +use clog::*; + +fn cfg(dir: &std::path::Path) -> Config { + let mut c = Config::default_for(dir); + c.tick = TickConfig { mode: ClockMode::Manual, interval_ms: 60_000 }; + c +} + +fn claim(key: &str, body: &str, occ: u64) -> Claim { + Claim { + claim_key: key.into(), subject_key: None, source_ref: "t:1".into(), + observer: ObserverId::from("test"), schema_v: 1, + occurred_at: occ, observed_at: occ, + reliability: Reliability::B, credibility: Credibility::Two, + entities: vec![], body: body.into(), + } +} + +#[test] +fn observe_bumps_rev_and_renders() { + let dir = tempfile::tempdir().unwrap(); + let c = Clog::open(cfg(dir.path())).unwrap(); + c.advance(1_000_000).unwrap(); + let ack = c.observe(vec![claim("a", "first thing", 500_000)], ObserveOpts::default()).unwrap(); + assert_eq!(ack.rev, 1); + let s = c.situation(None, None).unwrap(); + assert_eq!(s.scope, "default"); + assert_eq!(s.rev, 1); + assert!(s.text.contains("first thing")); +} + +#[test] +fn inv5_duplicate_observe_is_invisible() { + let dir = tempfile::tempdir().unwrap(); + let c = Clog::open(cfg(dir.path())).unwrap(); + c.advance(1_000_000).unwrap(); + let a1 = c.observe(vec![claim("a", "x", 500_000)], ObserveOpts::default()).unwrap(); + let s1 = c.situation(None, None).unwrap(); + let a2 = c.observe(vec![claim("a", "x", 500_000)], ObserveOpts::default()).unwrap(); + let s2 = c.situation(None, None).unwrap(); + assert_eq!(a2.rev, a1.rev, "duplicate batch must not commit"); + assert_eq!(s1.rev, s2.rev); + assert_eq!(s1.text, s2.text); +} + +#[test] +fn inv4_upsert_supersedes() { + let dir = tempfile::tempdir().unwrap(); + let c = Clog::open(cfg(dir.path())).unwrap(); + c.advance(1_000_000).unwrap(); + c.observe(vec![claim("a", "old body", 500_000)], ObserveOpts::default()).unwrap(); + c.observe(vec![claim("a", "new body", 600_000)], ObserveOpts::default()).unwrap(); + let s = c.situation(None, None).unwrap(); + assert!(s.text.contains("new body")); + assert!(!s.text.contains("old body")); +} + +#[test] +fn inv3_retraction_heals_text() { + let dir = tempfile::tempdir().unwrap(); + let c = Clog::open(cfg(dir.path())).unwrap(); + c.advance(1_000_000).unwrap(); + let empty = c.situation(None, None).unwrap(); + c.observe(vec![claim("a", "temp", 500_000)], ObserveOpts::default()).unwrap(); + c.retract("a").unwrap(); + let healed = c.situation(None, None).unwrap(); + assert_eq!(healed.text.replace(&format!("rev {}", healed.rev), "REV"), + empty.text.replace(&format!("rev {}", empty.rev), "REV")); + assert!(matches!(c.retract("a"), Err(ClogError::UnknownClaim))); +} + +#[test] +fn inv9_rev_survives_reopen() { + let dir = tempfile::tempdir().unwrap(); + { + let c = Clog::open(cfg(dir.path())).unwrap(); + c.advance(1_000_000).unwrap(); + c.observe(vec![claim("a", "x", 500_000)], ObserveOpts::default()).unwrap(); + c.observe(vec![claim("b", "y", 500_000)], ObserveOpts::default()).unwrap(); + } // drop -> clean shutdown + let c = Clog::open(cfg(dir.path())).unwrap(); + c.advance(2_000_000).unwrap(); + let ack = c.observe(vec![claim("c", "z", 500_000)], ObserveOpts::default()).unwrap(); + assert_eq!(ack.rev, 3); + let s = c.situation(None, None).unwrap(); + assert!(s.text.contains('x') && s.text.contains('z')); +} + +#[test] +fn reserved_namespace_rejected_and_batch_atomic() { + let dir = tempfile::tempdir().unwrap(); + let c = Clog::open(cfg(dir.path())).unwrap(); + c.advance(1_000_000).unwrap(); + let r = c.observe(vec![claim("ok", "fine", 500_000), claim("clog:sneaky", "no", 500_000)], + ObserveOpts::default()); + assert!(r.is_err()); + // atomic: the valid claim must not have landed either + assert!(!c.situation(None, None).unwrap().text.contains("fine")); +} + +#[test] +fn rules_tier_classifies_at_commit() { + let dir = tempfile::tempdir().unwrap(); + let mut config = cfg(dir.path()); + for kd in &mut config.kinds.kinds { + if kd.name == "risk" { + kd.rules.push(clog::Rule { any_of: vec![clog::Matcher::BodyContains("overdue".into())] }); + } + } + let c = Clog::open(config).unwrap(); + c.advance(1_000_000).unwrap(); + c.observe(vec![claim("inv", "invoice 1042 is overdue", 500_000)], ObserveOpts::default()).unwrap(); + let s = c.situation(None, None).unwrap(); + assert!(s.text.contains("- RISK invoice 1042"), "open loops slot should show it:\n{}", s.text); +} + +#[test] +fn custom_template_and_errors() { + let dir = tempfile::tempdir().unwrap(); + let c = Clog::open(cfg(dir.path())).unwrap(); + c.advance(1_000_000).unwrap(); + c.observe(vec![claim("a", "hello world", 500_000)], ObserveOpts::default()).unwrap(); + let s = c.situation(None, Some("URGENT ONLY\n%{urgent limit=1}")).unwrap(); + assert!(s.text.starts_with("URGENT ONLY\n1.")); + assert!(matches!(c.situation(None, Some("%{bogus}")), Err(ClogError::TemplateError(_)))); + assert!(matches!(c.situation(Some("nope"), None), Err(ClogError::UnknownScope))); +} +``` + +- [ ] **Step 2: Run** — `cargo test -p clog --test api` → compile error. +- [ ] **Step 3: Implement** `clock.rs`, `actor.rs`, and the `Clog` handle in `lib.rs` per the write-path list above. Keep the writer loop a single `fn run(state: WriterState, rx: Receiver)` with one match; each numbered write-path step is its own private fn. On open: replay applies each batch's events directly (`engine.apply`), then re-renders — reproducing `SituationState` and revs deterministically. +- [ ] **Step 4: Run** — 8 PASS (plus all unit tests still green). +- [ ] **Step 5: Commit** — `git commit -m "feat(clog): actor, write path, WAL-backed open, situation reads (INV-1/3/4/5/9)"` + +--- + +### Task 15: `select`, `merge_entities`, `revoke_observer` + +**Files:** +- Modify: `examples/clog/src/actor.rs`, `examples/clog/src/lib.rs` +- Test: `examples/clog/tests/api.rs` (extend) + +**Interfaces:** +- Produces: + - `pub fn select(&self, view: View, filter: Filter) -> Result, ClogError>` — snapshot-only. Ordering: Live/OpenLoops/Unclassified by claim_key asc; Urgent by rank; EntityState by (canonical entity, subject) asc. `min_score` with a non-Urgent view → `InvalidFilter`. `limit` default 50, clamped to 500. Reserved claims never appear (INV-8). Filters: kinds (any-of), entities (any-of, post-alias), observer, subject_prefix, occurred_after, min_score. + - `pub fn revoke_observer(&self, observer: &ObserverId) -> Result` — one batch, one rev (INV-6). + - `pub fn merge_entities(&self, alias: &EntityRef, canonical: &EntityRef) -> Result` — writes the reserved merge claim (key `clog:merge:{a.etype}:{a.id}->{c.etype}:{c.id}`, observer `clog`, body = unit-separator-joined keys, reliability A, credibility One, occurred/observed/recorded = now, source_ref `clog:merge`); pre-checks `AliasCycle` against the current alias map before committing; `retract` of that key un-merges. + +- [ ] **Step 1: Write failing tests** (append to `tests/api.rs`) + +```rust +#[test] +fn select_live_with_filters() { + let dir = tempfile::tempdir().unwrap(); + let c = Clog::open(cfg(dir.path())).unwrap(); + c.advance(1_000_000).unwrap(); + let mut a = claim("a", "alpha", 100_000); + a.observer = ObserverId::from("gmail"); + let mut b = claim("b", "beta", 900_000); + b.observer = ObserverId::from("twist"); + c.observe(vec![a, b], ObserveOpts::default()).unwrap(); + + let all = c.select(View::Live, Filter::default()).unwrap(); + assert_eq!(all.iter().map(|r| r.claim.claim_key.as_str()).collect::>(), vec!["a", "b"]); + assert!(all[0].recorded_at >= 1_000_000); + + let f = Filter { observer: Some(ObserverId::from("twist")), ..Filter::default() }; + assert_eq!(c.select(View::Live, f).unwrap().len(), 1); + + let f = Filter { occurred_after: Some(500_000), ..Filter::default() }; + assert_eq!(c.select(View::Live, f).unwrap()[0].claim.claim_key, "b"); + + let f = Filter { min_score: Some(0.1), ..Filter::default() }; + assert!(matches!(c.select(View::Live, f), Err(ClogError::InvalidFilter { .. }))); + + let rows = c.select(View::Urgent { scope: "default".into() }, Filter::default()).unwrap(); + assert!(rows[0].score.is_some()); +} + +#[test] +fn inv6_revoke_observer_one_batch() { + let dir = tempfile::tempdir().unwrap(); + let c = Clog::open(cfg(dir.path())).unwrap(); + c.advance(1_000_000).unwrap(); + let mut a = claim("a", "alpha", 100_000); a.observer = ObserverId::from("gmail"); + let mut b = claim("b", "beta", 100_000); b.observer = ObserverId::from("gmail"); + c.observe(vec![a, b], ObserveOpts::default()).unwrap(); + let ack = c.revoke_observer(&ObserverId::from("gmail")).unwrap(); + assert_eq!(ack.rev, 2); // one batch, one rev + assert!(c.select(View::Live, Filter::default()).unwrap().is_empty()); +} + +#[test] +fn p7_shape_merge_round_trip_via_api() { + let dir = tempfile::tempdir().unwrap(); + let c = Clog::open(cfg(dir.path())).unwrap(); + c.advance(1_000_000).unwrap(); + let mut cl = claim("about-a", "note about a", 100_000); + cl.entities = vec![EntityRef { etype: "p".into(), id: "a".into(), name: None }]; + c.observe(vec![cl], ObserveOpts::default()).unwrap(); + let before = c.situation(None, None).unwrap(); + + let a = EntityRef { etype: "p".into(), id: "a".into(), name: None }; + let b = EntityRef { etype: "p".into(), id: "b".into(), name: None }; + c.merge_entities(&a, &b).unwrap(); + // entity filter follows the alias + let f = Filter { entities: Some(vec![b.clone()]), ..Filter::default() }; + assert_eq!(c.select(View::Live, f).unwrap().len(), 1); + // cycle rejected + assert!(matches!(c.merge_entities(&b, &a), Err(ClogError::AliasCycle))); + // merge claim is invisible (INV-8) + assert!(c.select(View::Live, Filter::default()).unwrap().iter().all(|r| !r.claim.claim_key.starts_with("clog:"))); + // un-merge by retracting the reserved key + c.retract("clog:merge:p:a->p:b").unwrap(); + let after = c.situation(None, None).unwrap(); + assert_eq!(before.text.replace(&format!("rev {}", before.rev), "R"), + after.text.replace(&format!("rev {}", after.rev), "R")); +} +``` + +- [ ] **Step 2: Run** — failures. **Step 3: Implement.** `select` runs entirely on the loaded snapshot (INV-1): iterate the view's ordered keys, hydrate `Row { claim, recorded_at, kind, score, believed }` (`believed = Some(views.believed[subject] == Some(key))` when the claim has a subject_key; `score` only for Urgent). **Step 4: Run** — 3 PASS. **Step 5: Commit** — `git commit -m "feat(clog): select, merge_entities, revoke_observer (INV-6/8)"` + +--- + +### Task 16: Property tests P1–P4, P6, P7 + +**Files:** +- Create: `examples/clog/tests/props.rs` + +**Interfaces:** +- Consumes: public API only, Manual clock. + +Strategy: a claim generator over small alphabets so collisions occur — keys from `k0..k7`, subjects from `{None, s0, s1, s2}`, observers `{o0, o1}`, entities `{(p,a),(p,b)}`, bodies 1–3 words from a 6-word list, occurred_at in 1..=5 (ms scale is irrelevant), reliability/credibility across full range. Each property builds two `Clog` instances in tempdirs with identical Manual-clock scripts and compares **normalized situation text** (rev markers stripped, as in Task 14's INV-3 test) and `select(Live)` rows. + +- [ ] **Step 1: Write the failing tests** + +```rust +use clog::*; +use proptest::prelude::*; + +// -- generators --------------------------------------------------------- +fn arb_claim() -> impl Strategy { + (0..8u8, prop::option::of(0..3u8), 0..2u8, 0..6u8, 0..6u8, 1..6u64, prop::collection::vec(0..6u8, 1..4), prop::bool::ANY) + .prop_map(|(k, s, o, rel, cred, occ, words, with_ent)| { + let vocab = ["invoice", "overdue", "kickoff", "moved", "question", "paid"]; + Claim { + claim_key: format!("k{k}"), + subject_key: s.map(|s| format!("s{s}")), + source_ref: "prop:1".into(), + observer: ObserverId::from(if o == 0 { "o0" } else { "o1" }), + schema_v: 1, + occurred_at: occ, observed_at: occ, + reliability: [Reliability::A, Reliability::B, Reliability::C, Reliability::D, Reliability::E, Reliability::F][rel as usize], + credibility: [Credibility::One, Credibility::Two, Credibility::Three, Credibility::Four, Credibility::Five, Credibility::Six][cred as usize], + entities: if with_ent { vec![EntityRef { etype: "p".into(), id: "a".into(), name: None }] } else { vec![] }, + body: words.iter().map(|w| vocab[*w as usize]).collect::>().join(" "), + } + }) +} + +fn open_manual(dir: &std::path::Path) -> Clog { + let mut c = Config::default_for(dir); + c.tick = TickConfig { mode: ClockMode::Manual, interval_ms: 60_000 }; + let h = Clog::open(c).unwrap(); + h.advance(1_000_000).unwrap(); + h +} + +fn norm(s: &Situation) -> String { + // strip the rev so text comparison ignores counters (INV-3 wording) + let mut t = s.text.clone(); + t = t.replace(&format!("rev {}", s.rev), "rev _"); + t +} + +fn live_keys(c: &Clog) -> Vec { + c.select(View::Live, Filter::default()).unwrap().into_iter().map(|r| r.claim.claim_key).collect() +} + +proptest! { + #![proptest_config(ProptestConfig { cases: 64, ..ProptestConfig::default() })] + + // P1 (INV-3): observe everything, retract all -> empty-world text + #[test] + fn p1_retract_all_heals(claims in prop::collection::vec(arb_claim(), 1..12)) { + let d = tempfile::tempdir().unwrap(); + let c = open_manual(d.path()); + let empty = norm(&c.situation(None, None).unwrap()); + for cl in &claims { let _ = c.observe(vec![cl.clone()], ObserveOpts::default()); } + for k in live_keys(&c) { c.retract(&k).unwrap(); } + prop_assert_eq!(norm(&c.situation(None, None).unwrap()), empty); + } + + // P2 (INV-4): only the last version per key matters + #[test] + fn p2_last_writer_wins(claims in prop::collection::vec(arb_claim(), 1..12)) { + let d1 = tempfile::tempdir().unwrap(); + let full = open_manual(d1.path()); + for cl in &claims { let _ = full.observe(vec![cl.clone()], ObserveOpts::default()); } + + let mut last: std::collections::BTreeMap = Default::default(); + for cl in &claims { last.insert(cl.claim_key.clone(), cl.clone()); } + let d2 = tempfile::tempdir().unwrap(); + let compact = open_manual(d2.path()); + for cl in last.values() { let _ = compact.observe(vec![cl.clone()], ObserveOpts::default()); } + + prop_assert_eq!(norm(&full.situation(None, None).unwrap()), norm(&compact.situation(None, None).unwrap())); + prop_assert_eq!(live_keys(&full), live_keys(&compact)); + } + + // P3 (INV-5): duplicating a prefix changes nothing, incl. scope revs + #[test] + fn p3_duplicates_invisible(claims in prop::collection::vec(arb_claim(), 1..8), cut in 0..8usize) { + let cut = cut.min(claims.len()); + let d1 = tempfile::tempdir().unwrap(); + let a = open_manual(d1.path()); + for cl in &claims { let _ = a.observe(vec![cl.clone()], ObserveOpts::default()); } + let s_a = a.situation(None, None).unwrap(); + + let d2 = tempfile::tempdir().unwrap(); + let b = open_manual(d2.path()); + for cl in &claims { let _ = b.observe(vec![cl.clone()], ObserveOpts::default()); } + for cl in claims.iter().take(cut) { + // replay a prefix of stale versions: only claims still live in identical + // form are true duplicates; superseded keys will upsert — so restrict to + // claims whose key's final version is this version + if claims.iter().rev().find(|c2| c2.claim_key == cl.claim_key).map(|c2| c2 == cl).unwrap_or(false) { + let _ = b.observe(vec![cl.clone()], ObserveOpts::default()); + } + } + let s_b = b.situation(None, None).unwrap(); + prop_assert_eq!(s_a.rev, s_b.rev, "duplicate observes must not advance situation rev"); + prop_assert_eq!(s_a.text, s_b.text); + } + + // P4 (INV-6): revoke == retract-each + #[test] + fn p4_revoke_equals_retract_each(claims in prop::collection::vec(arb_claim(), 1..12)) { + let d1 = tempfile::tempdir().unwrap(); + let a = open_manual(d1.path()); + let d2 = tempfile::tempdir().unwrap(); + let b = open_manual(d2.path()); + for cl in &claims { + let _ = a.observe(vec![cl.clone()], ObserveOpts::default()); + let _ = b.observe(vec![cl.clone()], ObserveOpts::default()); + } + let _ = a.revoke_observer(&ObserverId::from("o0")); + for r in b.select(View::Live, Filter { observer: Some(ObserverId::from("o0")), ..Filter::default() }).unwrap() { + b.retract(&r.claim.claim_key).unwrap(); + } + prop_assert_eq!(norm(&a.situation(None, None).unwrap()), norm(&b.situation(None, None).unwrap())); + prop_assert_eq!(live_keys(&a), live_keys(&b)); + } + + // P6: belief winner is arrival-order-insensitive + #[test] + fn p6_belief_order_insensitive(mut claims in prop::collection::vec(arb_claim(), 2..8), seed in 0..1000u64) { + for (i, c) in claims.iter_mut().enumerate() { + c.subject_key = Some("shared".into()); + c.claim_key = format!("k{i}"); // distinct keys, same subject + } + let d1 = tempfile::tempdir().unwrap(); + let a = open_manual(d1.path()); + for cl in &claims { a.observe(vec![cl.clone()], ObserveOpts::default()).unwrap(); } + + // deterministic shuffle + let mut shuffled = claims.clone(); + let mut s = seed; + for i in (1..shuffled.len()).rev() { + s = s.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); + shuffled.swap(i, (s as usize) % (i + 1)); + } + let d2 = tempfile::tempdir().unwrap(); + let b = open_manual(d2.path()); + for cl in &shuffled { b.observe(vec![cl.clone()], ObserveOpts::default()).unwrap(); } + + let believed = |h: &Clog| h.select(View::EntityState, Filter::default()).unwrap() + .into_iter().map(|r| r.claim.claim_key).collect::>(); + prop_assert_eq!(believed(&a), believed(&b)); + } + + // P7: merge round-trip is invisible + #[test] + fn p7_merge_round_trip(claims in prop::collection::vec(arb_claim(), 1..8)) { + let d1 = tempfile::tempdir().unwrap(); + let a = open_manual(d1.path()); + for cl in &claims { let _ = a.observe(vec![cl.clone()], ObserveOpts::default()); } + let before = norm(&a.situation(None, None).unwrap()); + let al = EntityRef { etype: "p".into(), id: "a".into(), name: None }; + let ca = EntityRef { etype: "p".into(), id: "b".into(), name: None }; + a.merge_entities(&al, &ca).unwrap(); + a.retract("clog:merge:p:a->p:b").unwrap(); + prop_assert_eq!(norm(&a.situation(None, None).unwrap()), before); + } +} +``` + +- [ ] **Step 2: Run** — `cargo test -p clog --test props` → expect compile errors first, then possible real counterexamples. **Any counterexample is a bug in Tasks 11–15 — fix the engine, never weaken the property.** (P3's guard is the plan's understanding of "duplicate"; if it still fails, re-read INV-5 before touching the test.) +- [ ] **Step 3/4: Fix until green.** Run with `PROPTEST_CASES=256` once locally for confidence. +- [ ] **Step 5: Commit** — `git commit -m "test(clog): property suite P1-P4, P6, P7"` + +--- + +### Task 17: Golden simulation G1 (insta) + +**Files:** +- Create: `examples/clog/tests/g1_agency.rs`, snapshots under `examples/clog/tests/snapshots/` + +**Interfaces:** +- Consumes: public API. This freezes the §5.8 rendering contract — **changing a golden after this task requires a spec edit** (§11.4). + +The fixture (P1 scope — focus shifts and `correct()` checkpoints arrive in P2/P3): scopes `delivery-health` (risk 2.5, question 1.5, commitment 1.5, boost project:halcyon 1.5) and `cash-and-collections` (risk 2.0, fyi 0.5, opportunity 1.5) declared in Config; rules: risk ← BodyContains("overdue")|BodyContains("slipping"), question ← BodyRegex(`\?$`), fact ← ObserverIs("bank-feed"), opportunity ← BodyContains("inbound"). Ten claims across three clients (Manual clock; timestamps in whole days around a fixed epoch): + +1. `halcyon:deliverable:slip` — "Halcyon deliverable is slipping by a week" (risk, project:halcyon) +2. `halcyon:inv-1042:v1` subject `halcyon:inv-1042:status` — "Invoice 1042 is 30 days overdue" (risk, gmail, B/2) +3. `halcyon:inv-1042:v2` same subject — "Invoice 1042 still unpaid per bookkeeper" (twist, C/3, later occurred_at) +4. `halcyon:inv-1042:paid` same subject — "Payment received for invoice 1042" (bank-feed, A/1, occurred_at EARLIER than 3 but wins on nothing — checkpoint B shows belief goes to 3, the spec §5.3 known limitation, then retracting 3 flips belief to this claim) +5. `meridian:kickoff:moved` — "Meridian kickoff moved to Thursday" (fyi) +6. `meridian:pto:sam` — "Sam is on PTO next week" (fyi, person:sam) +7. `meridian:question:sow` subject `meridian:sow` — "Did Meridian sign the SOW?" (question) +8. `vega:lead:inbound` — "Inbound lead from Vega Labs" (opportunity) +9. `vega:upsell:maybe` — "Vega mentioned maybe expanding scope" (no rule matches → Unclassified) +10. `meridian:question:sow:answered` — retraction of 7 at checkpoint C (self-resolved) + +Checkpoints, each snapshotting both scopes' situation texts plus `select(View::Unclassified)` keys: +- **A**: after claims 1–2 and 5–9. +- **B**: after 3 then 4 (supersession chain; assert `believed` flags via `select(View::EntityState)` too). +- **C**: after retracting 7 and retracting 3 (healing + belief flip to the bank-feed claim). +- **D**: after `merge_entities(person:samuel → person:sam)` (registry proof). + +- [ ] **Step 1: Write the fixture test** — build helpers `fn fixture_config(dir) -> Config`, `fn claim(...) -> Claim` with explicit fields per the list; at each checkpoint: + +```rust +insta::assert_snapshot!("g1_a_delivery", c.situation(Some("delivery-health"), None).unwrap().text); +insta::assert_snapshot!("g1_a_cash", c.situation(Some("cash-and-collections"), None).unwrap().text); +``` + +- [ ] **Step 2: Run** — `cargo test -p clog --test g1_agency` → snapshots missing (expected failure). +- [ ] **Step 3: Review and accept** — `cargo insta review` (or `INSTA_UPDATE=accept cargo test -p clog --test g1_agency` then **manually read every snapshot** against §5.8's formats: header line, urgent numbering, `[B/2]` trust markers, `(none)` empties, changes slot showing checkpoint deltas). Do not accept a snapshot you have not read line by line. +- [ ] **Step 4: Run** — green, snapshots committed. +- [ ] **Step 5: Commit** — `git add -A examples/clog/tests && git commit -m "test(clog): G1 agency golden simulation (rendering contract frozen)"` + +--- + +### Task 18: Recovery tests R1 (crash points), R3 (rebuild) + +**Files:** +- Create: `examples/clog/tests/recovery.rs` +- Modify: `examples/clog/src/actor.rs` (crash hook), `examples/clog/src/wal.rs` if needed + +**Interfaces:** +- Produces: crash hook in the writer, compiled only with `--features test-crash`: + +```rust +#[cfg(feature = "test-crash")] +fn maybe_crash_after_wal(batch_no: u64) { + if let Ok(n) = std::env::var("CLOG_CRASH_AFTER_WAL") { + if n.parse::() == Ok(batch_no) { std::process::abort(); } + } +} +``` + +called immediately after `wal.append` returns (post-fsync), before `engine.apply`. + +- [ ] **Step 1: Write the failing tests** + +```rust +// tests/recovery.rs +use clog::*; + +fn script(c: &Clog) -> u64 { + c.advance(1_000_000).unwrap(); + let mk = |k: &str, b: &str| Claim { claim_key: k.into(), subject_key: None, + source_ref: "t:1".into(), observer: ObserverId::from("t"), schema_v: 1, + occurred_at: 500_000, observed_at: 500_000, reliability: Reliability::B, + credibility: Credibility::Two, entities: vec![], body: b.into() }; + let mut rev = 0; + rev = c.observe(vec![mk("a", "first")], ObserveOpts::default()).map(|a| a.rev).unwrap_or(rev); + rev = c.observe(vec![mk("b", "second")], ObserveOpts::default()).map(|a| a.rev).unwrap_or(rev); + rev = c.retract("a").map(|a| a.rev).unwrap_or(rev); + rev = c.observe(vec![mk("c", "third")], ObserveOpts::default()).map(|a| a.rev).unwrap_or(rev); + rev +} + +fn manual_cfg(dir: &std::path::Path) -> Config { + let mut c = Config::default_for(dir); + c.tick = TickConfig { mode: ClockMode::Manual, interval_ms: 60_000 }; + c +} + +// R1: child aborts after the Nth WAL append; parent reopens and compares +// against a fresh instance fed the same first N batches. +#[test] +#[cfg_attr(not(feature = "test-crash"), ignore = "needs --features test-crash")] +fn r1_crash_points() { + if std::env::var("CLOG_R1_CHILD").is_ok() { + let dir = std::env::var("CLOG_R1_DIR").unwrap(); + let c = Clog::open(manual_cfg(std::path::Path::new(&dir))).unwrap(); + script(&c); // aborts partway via CLOG_CRASH_AFTER_WAL + unreachable!("child should have crashed"); + } + for crash_after in 1..=4u64 { + let dir = tempfile::tempdir().unwrap(); + let exe = std::env::current_exe().unwrap(); + let status = std::process::Command::new(&exe) + .args(["r1_crash_points", "--exact", "--nocapture"]) // not --ignored: with test-crash on, the test is not ignored + .env("CLOG_R1_CHILD", "1") + .env("CLOG_R1_DIR", dir.path()) + .env("CLOG_CRASH_AFTER_WAL", crash_after.to_string()) + .status().unwrap(); + assert!(!status.success(), "child must abort"); + + // reopened world == fresh world fed the same durable prefix + let reopened = Clog::open(manual_cfg(dir.path())).unwrap(); + let fresh_dir = tempfile::tempdir().unwrap(); + let fresh = Clog::open(manual_cfg(fresh_dir.path())).unwrap(); + replay_prefix(&fresh, crash_after); + let (s1, s2) = (reopened.situation(None, None).unwrap(), fresh.situation(None, None).unwrap()); + assert_eq!(s1.text, s2.text, "crash point {crash_after}"); + assert_eq!(s1.rev, s2.rev); + } +} + +fn replay_prefix(c: &Clog, n: u64) { + c.advance(1_000_000).unwrap(); + let mk = |k: &str, b: &str| Claim { claim_key: k.into(), subject_key: None, + source_ref: "t:1".into(), observer: ObserverId::from("t"), schema_v: 1, + occurred_at: 500_000, observed_at: 500_000, reliability: Reliability::B, + credibility: Credibility::Two, entities: vec![], body: b.into() }; + let steps: Vec> = vec![ + Box::new(move |c| { c.observe(vec![mk("a", "first")], ObserveOpts::default()).unwrap(); }), + Box::new(move |c| { c.observe(vec![mk("b", "second")], ObserveOpts::default()).unwrap(); }), + Box::new(|c| { c.retract("a").unwrap(); }), + Box::new(move |c| { c.observe(vec![mk("c", "third")], ObserveOpts::default()).unwrap(); }), + ]; + for s in steps.iter().take(n as usize) { s(c); } +} + +// R3: rebuild_on_open == normal open +#[test] +fn r3_rebuild_equals_open() { + let dir = tempfile::tempdir().unwrap(); + { let c = Clog::open(manual_cfg(dir.path())).unwrap(); script(&c); } + let normal = Clog::open(manual_cfg(dir.path())).unwrap(); + let s1 = normal.situation(None, None).unwrap(); + drop(normal); + let mut cfg2 = manual_cfg(dir.path()); + cfg2.rebuild_on_open = true; + let rebuilt = Clog::open(cfg2).unwrap(); + let s2 = rebuilt.situation(None, None).unwrap(); + assert_eq!(s1.text, s2.text); + assert_eq!(s1.rev, s2.rev); +} +``` + +- [ ] **Step 2: Run** — `cargo test -p clog --test recovery` (R3) and `cargo test -p clog --test recovery --features test-crash -- --include-ignored` (R1). Expected: fail until the hook exists / bugs fixed. +- [ ] **Step 3: Implement the hook** and fix anything R1 exposes (typical bug: replying to the caller before fsync, or publishing before WAL append). +- [ ] **Step 4: Run both commands** — green. Manual-clock caveat: the child's `advance` calls set the same timestamps, so texts match exactly. +- [ ] **Step 5: Commit** — `git commit -m "test(clog): crash-point recovery R1 and rebuild R3 (INV-11)"` + +--- + +### Task 19: Docs, clippy, exit checklist + +**Files:** +- Modify: `examples/clog/README.md`, rustdoc across `src/` + +- [ ] **Step 1: README** — rewrite with: what clog is (3 sentences), the P1 API table (7 functions + `advance`), a 20-line quickstart (open → observe → situation → retract), pointers to spec/build-design, status ("P1 complete; P2 next: scopes/set_focus/ticks/wakes"), and the documented caveats from the ledger (no `%{` escape; duplicate batches don't commit; watches are P2). +- [ ] **Step 2: Rustdoc pass** — `cargo doc -p clog --no-deps` must be warning-free; every public item has at least one sentence; `Clog::open` gets a compiling doctest mirroring the README quickstart (use a tempdir). +- [ ] **Step 3: Lints** — `cargo clippy -p clog --all-targets -- -D warnings` clean; `cargo fmt -p clog --check` clean. +- [ ] **Step 4: Full exit run** — + +```bash +cargo test -p clog +cargo test -p clog --features test-crash -- --include-ignored +cargo doc -p clog --no-deps +``` + +All green = M0+M1 exit criteria met (§11.1 subset, P1–P4/P6/P7, G1, R1–R3). + +- [ ] **Step 5: Commit** — `git commit -m "docs(clog): P1 README and rustdoc; M0+M1 exit checklist green"` + +--- + +## Self-review notes (already applied) + +- **Spec coverage:** M0 units U-SCORE-1 (T4), U-BELIEF-1/2 (T5), U-ALIAS-1/2 (T6) /3 (T12), U-TMPL-1 (T8) /2/3 (T9), U-VAL-1 (T3), U-KIND-1 (T7). M1: views (T11/12), render+budget (T9), batching/rev (T14), WAL (T13), P1–P4/P6/P7 (T16), G1 (T17), R1–R3 (T13/T18). Deferred per build design: U-KIND-2, P5, P8, D*, B*, Z*, loom, snapshot files, set_focus/ticks/wakes/recall/correct. +- **Known simplification:** naive `urgent` recomputes all scopes per batch — acceptable for the oracle; do not "optimize" it in P1. +- **Type consistency:** `Rev = u64` alias (not a newtype — spec structs use bare u64); `ObserverId` newtype; `WorldViews.by_observer` keyed by `String` (the observer's inner string) to keep OrdMap keys simple. From 1dc1cff0d27b29a9ed51296cc6de163a95d2a585 Mon Sep 17 00:00:00 2001 From: hhff Date: Sat, 15 Aug 2026 18:01:15 -0700 Subject: [PATCH 03/28] feat(clog): scaffold library crate --- examples/clog/.gitignore | 1 + examples/clog/Cargo.toml | 25 +++++++++++++++++++++++++ examples/clog/README.md | 11 +++++++++++ examples/clog/src/lib.rs | 7 +++++++ 4 files changed, 44 insertions(+) create mode 100644 examples/clog/.gitignore create mode 100644 examples/clog/Cargo.toml create mode 100644 examples/clog/README.md create mode 100644 examples/clog/src/lib.rs diff --git a/examples/clog/.gitignore b/examples/clog/.gitignore new file mode 100644 index 0000000..93600f4 --- /dev/null +++ b/examples/clog/.gitignore @@ -0,0 +1 @@ +/tests/fixtures/corpus* diff --git a/examples/clog/Cargo.toml b/examples/clog/Cargo.toml new file mode 100644 index 0000000..7db0488 --- /dev/null +++ b/examples/clog/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "clog" +version = "0.0.0" +edition = "2024" +publish = false +description = "Orientation engine for agentic systems: claims in, ranked situations out." + +[dependencies] +serde = { version = "1", features = ["derive"] } +thiserror = "2" +imbl = "6" +arc-swap = "1" +crossbeam-channel = "0.5" +postcard = { version = "1", features = ["use-std"] } +crc32fast = "1" +regex = "1" + +[dev-dependencies] +proptest = "1" +insta = "1" +tempfile = "3" + +[features] +# test-crash compiles the crash-injection hook used by the R1 harness (Task 17) +test-crash = [] diff --git a/examples/clog/README.md b/examples/clog/README.md new file mode 100644 index 0000000..86c6154 --- /dev/null +++ b/examples/clog/README.md @@ -0,0 +1,11 @@ +# clog + +Orientation engine for agentic systems, built on the BogKit workspace. +Spec: `../../docs/clog-spec-v1.md`. Build design: +`../../docs/superpowers/specs/2026-08-15-clog-build-design.md`. + +Status: P1 in progress (pure core + naive engine + WAL). + +## Public API (implemented so far) + +(none yet) diff --git a/examples/clog/src/lib.rs b/examples/clog/src/lib.rs new file mode 100644 index 0000000..905d7de --- /dev/null +++ b/examples/clog/src/lib.rs @@ -0,0 +1,7 @@ +//! Clog: an orientation engine for agentic systems. +//! +//! Hosts write structured claims; clog maintains materialized views over them +//! incrementally and renders a budgeted situation document per scope. See +//! `docs/clog-spec-v1.md` in the repository root for the full specification. +#![deny(missing_docs)] +#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used))] From 976477a20a7be8f229079da66210bd17e201769c Mon Sep 17 00:00:00 2001 From: hhff Date: Sat, 15 Aug 2026 18:07:24 -0700 Subject: [PATCH 04/28] feat(clog): public API types (INV-12 serde surface) --- Cargo.lock | 173 +++++++++++ examples/clog/src/lib.rs | 4 + examples/clog/src/types.rs | 598 +++++++++++++++++++++++++++++++++++++ 3 files changed, 775 insertions(+) create mode 100644 examples/clog/src/types.rs diff --git a/Cargo.lock b/Cargo.lock index f152980..95d7401 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -159,6 +159,21 @@ version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" +[[package]] +name = "arc-swap" +version = "1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" +dependencies = [ + "rustversion", +] + +[[package]] +name = "archery" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca55ee147b1926dbea904f50fe4902494e97bc742205abbbf10c709e43815f" + [[package]] name = "arrow-array" version = "58.3.0" @@ -333,6 +348,21 @@ dependencies = [ "serde", ] +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bitflags" version = "1.3.2" @@ -345,6 +375,12 @@ version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +[[package]] +name = "bitmaps" +version = "3.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d084b0137aaa901caf9f1e8b21daa6aa24d41cd806e111335541eff9683bd6" + [[package]] name = "block-buffer" version = "0.10.4" @@ -533,6 +569,23 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "clog" +version = "0.0.0" +dependencies = [ + "arc-swap", + "crc32fast", + "crossbeam-channel", + "imbl", + "insta", + "postcard", + "proptest", + "regex", + "serde", + "tempfile", + "thiserror 2.0.18", +] + [[package]] name = "cobs" version = "0.3.0" @@ -709,6 +762,15 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" +[[package]] +name = "crossbeam-channel" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-deque" version = "0.8.7" @@ -1576,6 +1638,29 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "imbl" +version = "6.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fade8ae6828627ad1fa094a891eccfb25150b383047190a3648d66d06186501" +dependencies = [ + "archery", + "bitmaps", + "imbl-sized-chunks", + "rand_core 0.9.5", + "rand_xoshiro", + "version_check", +] + +[[package]] +name = "imbl-sized-chunks" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f4241005618a62f8d57b2febd02510fb96e0137304728543dfc5fd6f052c22d" +dependencies = [ + "bitmaps", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -1612,6 +1697,18 @@ dependencies = [ "web-time", ] +[[package]] +name = "insta" +version = "1.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86f0f8fee8c926415c58d6ae43a08523a26faccb2323f5e6b644fe7dd4ef6b82" +dependencies = [ + "console 0.16.4", + "once_cell", + "similar", + "tempfile", +] + [[package]] name = "instant-distance" version = "0.6.1" @@ -2332,6 +2429,31 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bit-set", + "bit-vec", + "bitflags 2.13.0", + "num-traits", + "rand 0.9.5", + "rand_chacha 0.9.0", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + [[package]] name = "quick_cache" version = "0.6.24" @@ -2422,6 +2544,24 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core 0.9.5", +] + +[[package]] +name = "rand_xoshiro" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f703f4665700daf5512dcca5f43afa6af89f09db47fb56be587f80636bda2d41" +dependencies = [ + "rand_core 0.9.5", +] + [[package]] name = "rawpointer" version = "0.2.1" @@ -2591,6 +2731,18 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error", + "tempfile", + "wait-timeout", +] + [[package]] name = "ryu" version = "1.0.23" @@ -2797,6 +2949,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" +[[package]] +name = "similar" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" + [[package]] name = "slab" version = "0.4.12" @@ -3243,6 +3401,12 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + [[package]] name = "unicode-general-category" version = "1.1.0" @@ -3364,6 +3528,15 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + [[package]] name = "walkdir" version = "2.5.0" diff --git a/examples/clog/src/lib.rs b/examples/clog/src/lib.rs index 905d7de..a088293 100644 --- a/examples/clog/src/lib.rs +++ b/examples/clog/src/lib.rs @@ -5,3 +5,7 @@ //! `docs/clog-spec-v1.md` in the repository root for the full specification. #![deny(missing_docs)] #![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used))] + +/// Public API types: the serde-only contract every later task builds on. +pub mod types; +pub use types::*; diff --git a/examples/clog/src/types.rs b/examples/clog/src/types.rs new file mode 100644 index 0000000..eb3f881 --- /dev/null +++ b/examples/clog/src/types.rs @@ -0,0 +1,598 @@ +//! Public API surface for clog. +//! +//! Every type here derives `Clone, Debug, Serialize, Deserialize` (INV-12) +//! and, where meaningful, `PartialEq`. This module is the serde-only +//! contract that every later task builds on: storage, ranking, and +//! rendering all operate on these shapes. + +use std::collections::BTreeMap; +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; + +/// Identifies the host/integration that observed a claim (e.g. `"gmail-v3"`). +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct ObserverId(pub String); + +impl From<&str> for ObserverId { + fn from(s: &str) -> Self { + ObserverId(s.to_string()) + } +} + +impl From for ObserverId { + fn from(s: String) -> Self { + ObserverId(s) + } +} + +/// A monotonic revision number for a scope's situation document. +pub type Rev = u64; + +/// Admiralty reliability rating of a source, A (best) through F (worst). +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub enum Reliability { + /// Completely reliable. + A, + /// Usually reliable. + B, + /// Fairly reliable. + C, + /// Not usually reliable. + D, + /// Unreliable. + E, + /// Reliability cannot be judged. + F, +} + +impl Reliability { + /// 0 = best (A). Belief resolution and scoring use this rank. + pub fn rank(self) -> u8 { + self as u8 + } + /// The Admiralty letter, for rendering. + pub fn letter(self) -> char { + (b'A' + self as u8) as char + } +} + +/// Admiralty credibility rating of a claim's content, One (best) through Six (worst). +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub enum Credibility { + /// Confirmed by other sources. + One, + /// Probably true. + Two, + /// Possibly true. + Three, + /// Doubtful. + Four, + /// Improbable. + Five, + /// Credibility cannot be judged. + Six, +} + +impl Credibility { + /// 0 = best (One). + pub fn rank(self) -> u8 { + self as u8 + } + /// The Admiralty digit 1..=6, for rendering. + pub fn digit(self) -> u8 { + self as u8 + 1 + } +} + +/// A reference to an entity (person, project, etc.) mentioned by a claim. +/// +/// Identity is `(etype, id)` only: `name` is a display hint and is excluded +/// from equality, ordering, and hashing, so two refs to the same entity with +/// different display names still compare equal. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct EntityRef { + /// The entity type, e.g. `"project"` or `"person"`. + pub etype: String, + /// The entity's stable identifier within its type. + pub id: String, + /// An optional display name, excluded from identity comparisons. + pub name: Option, +} + +impl EntityRef { + /// The identity key `(etype, id)`, ignoring `name`. + pub fn key(&self) -> (String, String) { + (self.etype.clone(), self.id.clone()) + } +} + +impl PartialEq for EntityRef { + fn eq(&self, other: &Self) -> bool { + self.etype == other.etype && self.id == other.id + } +} +impl Eq for EntityRef {} +impl PartialOrd for EntityRef { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} +impl Ord for EntityRef { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + (&self.etype, &self.id).cmp(&(&other.etype, &other.id)) + } +} +impl std::hash::Hash for EntityRef { + fn hash(&self, h: &mut H) { + self.etype.hash(h); + self.id.hash(h); + } +} + +/// A single structured observation submitted by a host. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Claim { + /// The claim's unique key, chosen by the host. + pub claim_key: String, + /// An optional key grouping this claim with others about the same subject. + pub subject_key: Option, + /// A reference back to the originating source (e.g. `"gmail:msg/123"`). + pub source_ref: String, + /// The observer that submitted this claim. + pub observer: ObserverId, + /// The schema version of `body`, for forward compatibility. + pub schema_v: u16, + /// When the observed event actually occurred, in epoch millis. + pub occurred_at: u64, + /// When clog recorded the observation, in epoch millis. + pub observed_at: u64, + /// The source's reliability rating. + pub reliability: Reliability, + /// The claim's credibility rating. + pub credibility: Credibility, + /// Entities this claim mentions. + pub entities: Vec, + /// The claim's free-text content. + pub body: String, +} + +/// Weighting and boosting preferences used to rank claims for a scope. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Focus { + /// Per-kind weight multipliers. + pub weights: BTreeMap, + /// Additive score boosts for specific entities. + pub boosts: Vec<(EntityRef, f32)>, + /// The half-life, in days, used for recency decay. + pub half_life_days: f32, + /// An optional cap on the number of ranked rows returned. + pub top_k: Option, +} + +impl Default for Focus { + fn default() -> Self { + Focus::uniform() + } +} + +impl Focus { + /// A `Focus` with no per-kind weighting and no boosts. + pub fn uniform() -> Focus { + Focus { + weights: BTreeMap::new(), + boosts: Vec::new(), + half_life_days: 7.0, + top_k: None, + } + } + + /// Sets the weight multiplier for a kind. + pub fn weight(mut self, kind: &str, w: f32) -> Focus { + self.weights.insert(kind.to_string(), w); + self + } + + /// Adds an additive score boost for an entity. + pub fn boost(mut self, e: EntityRef, f: f32) -> Focus { + self.boosts.push((e, f)); + self + } + + /// Sets the recency decay half-life, in days. + pub fn half_life_days(mut self, d: f32) -> Focus { + self.half_life_days = d; + self + } + + /// Sets the maximum number of ranked rows returned. + pub fn top_k(mut self, k: usize) -> Focus { + self.top_k = Some(k); + self + } +} + +/// Selects which materialized view to read. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum View { + /// The live, ranked feed of recent claims. + Live, + /// The current state of tracked entities. + EntityState, + /// Open loops: unresolved questions, risks, and commitments. + OpenLoops, + /// Urgent items within a given scope. + Urgent { + /// The scope to restrict urgency to. + scope: String, + }, + /// Claims that haven't yet been kind-classified. + Unclassified, +} + +/// Filtering criteria applied when reading a view. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct Filter { + /// Restrict to these kinds, if set. + pub kinds: Option>, + /// Restrict to claims mentioning any of these entities, if set. + pub entities: Option>, + /// Restrict to claims from this observer, if set. + pub observer: Option, + /// Restrict to claims whose subject key starts with this prefix, if set. + pub subject_prefix: Option, + /// Restrict to claims that occurred after this epoch-millis timestamp, if set. + pub occurred_after: Option, + /// Restrict to rows scoring at least this value, if set. + pub min_score: Option, + /// Cap the number of rows returned, if set. + pub limit: Option, +} + +/// Where a kind classification came from. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum JudgeSource { + /// A deterministic rule matched. + Rule, + /// A k-nearest-neighbors classifier matched. + Knn, + /// An external judge (e.g. an LLM call) supplied the label. + External, +} + +/// A kind classification with a confidence score. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct KindLabel { + /// The classified kind, e.g. `"risk"`. + pub kind: String, + /// Confidence in `[0, 1]`. + pub confidence: f32, + /// The judge that produced this label. + pub source: JudgeSource, +} + +/// A single ranked row in a view: a claim plus its derived annotations. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Row { + /// The underlying claim. + pub claim: Claim, + /// When clog recorded this claim, in epoch millis. + pub recorded_at: u64, + /// The claim's kind classification, if any. + pub kind: Option, + /// The claim's ranking score, if applicable to the view. + pub score: Option, + /// Whether the claim is currently believed, if applicable. + pub believed: Option, +} + +/// A rendered situation document for a scope at a point in time. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Situation { + /// The scope this situation document covers. + pub scope: String, + /// The rendered, budgeted text. + pub text: String, + /// The revision number at which this text was rendered. + pub rev: Rev, + /// The epoch-millis timestamp this situation reflects. + pub as_of: u64, +} + +/// Acknowledgement returned after an observation is recorded. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Ack { + /// The new revision number after recording. + pub rev: Rev, + /// The freshly rendered situation, if requested. + pub situation: Option, +} + +/// Options controlling how an observation is recorded. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct ObserveOpts { + /// If set, the scope whose situation should be rendered and returned. + pub return_situation: Option, +} + +/// A manual kind judgment supplied by a caller. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Judgment { + /// The kind being asserted. + pub kind: String, +} + +/// How clog's internal clock advances. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum ClockMode { + /// Advances with the system clock. + System, + /// Advances only when explicitly ticked. + Manual, +} + +/// When the write-ahead log is fsync'd. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum FsyncPolicy { + /// Fsync after every committed write. + OnCommit, + /// Never explicitly fsync; rely on OS buffering. + Never, +} + +/// Configuration for clog's internal clock tick. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct TickConfig { + /// The clock mode. + pub mode: ClockMode, + /// The tick interval in milliseconds. + pub interval_ms: u64, +} + +impl Default for TickConfig { + fn default() -> Self { + TickConfig { + mode: ClockMode::System, + interval_ms: 60_000, + } + } +} + +/// A single condition used by a `Rule` to match a claim. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub enum Matcher { + /// Matches if the claim body contains this substring, case-insensitively. + BodyContains(String), + /// Matches if the claim body matches this regular expression. + BodyRegex(String), + /// Matches if the claim's observer equals this string. + ObserverIs(String), + /// Matches if any of the claim's entities has this type. + EntityType(String), +} + +/// A classification rule: matches if any of its matchers match. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Rule { + /// The matchers; the rule matches if any one of them matches. + pub any_of: Vec, +} + +/// The definition of a single claim kind. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct KindDef { + /// The kind's name, e.g. `"risk"`. + pub name: String, + /// Rules used to classify claims into this kind. + pub rules: Vec, + /// Seed example texts used to bootstrap kNN classification. + pub seed_exemplars: Vec, +} + +/// The set of kinds clog knows how to classify claims into. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct KindTaxonomy { + /// The defined kinds, in taxonomy order. + pub kinds: Vec, +} + +impl KindTaxonomy { + /// The default taxonomy of 8 spec kinds, with no rules or exemplars. + pub fn default_taxonomy() -> KindTaxonomy { + let names = [ + "fact", + "decision", + "risk", + "question", + "commitment", + "agreement", + "opportunity", + "fyi", + ]; + KindTaxonomy { + kinds: names + .iter() + .map(|n| KindDef { + name: n.to_string(), + rules: Vec::new(), + seed_exemplars: Vec::new(), + }) + .collect(), + } + } + + /// Whether `kind` is a defined kind in this taxonomy. + pub fn contains(&self, kind: &str) -> bool { + self.kinds.iter().any(|k| k.name == kind) + } +} + +/// Top-level configuration for a clog instance. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Config { + /// The filesystem path clog stores its data under. + pub path: PathBuf, + /// Per-scope focus overrides. + pub scopes: BTreeMap, + /// The claim kind taxonomy in use. + pub kinds: KindTaxonomy, + /// Kinds treated as "open loops" (unresolved until closed). + pub loop_kinds: Vec, + /// The default maximum number of ranked rows per situation. + pub top_k: usize, + /// The character budget for a rendered situation document. + pub budget_chars: usize, + /// The internal clock tick configuration. + pub tick: TickConfig, + /// The number of decay buckets per half-life. + pub decay_buckets_per_half_life: u32, + /// The minimum credibility required for a claim to be believed. + pub belief_min_credibility: Credibility, + /// The write-ahead log fsync policy. + pub wal_fsync: FsyncPolicy, + /// The bounded write queue depth. + pub write_queue: usize, + /// Whether materialized views are rebuilt from the WAL on open. + pub rebuild_on_open: bool, +} + +impl Config { + /// Builds a `Config` at `path` with spec-mandated defaults. + pub fn default_for(path: impl Into) -> Config { + Config { + path: path.into(), + scopes: BTreeMap::new(), + kinds: KindTaxonomy::default_taxonomy(), + loop_kinds: vec!["question".to_string(), "risk".to_string(), "commitment".to_string()], + top_k: 12, + budget_chars: 6000, + tick: TickConfig::default(), + decay_buckets_per_half_life: 4, + belief_min_credibility: Credibility::Six, + wal_fsync: FsyncPolicy::OnCommit, + write_queue: 1024, + rebuild_on_open: true, + } + } +} + +/// Errors returned by clog's public API. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum ClogError { + /// A submitted claim at `index` in a batch was invalid. + #[error("invalid claim at index {index}: {reason}")] + InvalidClaim { + /// The index of the offending claim within its batch. + index: usize, + /// A human-readable explanation. + reason: String, + }, + /// The operation targeted a namespace reserved for internal use. + #[error("reserved namespace")] + ReservedNamespace, + /// The referenced claim does not exist. + #[error("unknown claim")] + UnknownClaim, + /// The referenced scope does not exist. + #[error("unknown scope")] + UnknownScope, + /// The referenced kind does not exist in the taxonomy. + #[error("unknown kind")] + UnknownKind, + /// An alias chain formed a cycle. + #[error("alias cycle")] + AliasCycle, + /// Rendering a situation document template failed. + #[error("template error: {0}")] + TemplateError(String), + /// An underlying storage I/O error occurred. + #[error("storage error: {0}")] + Storage(#[from] std::io::Error), + /// On-disk state was found to be corrupt. + #[error("corrupt storage: {detail}")] + Corrupt { + /// A human-readable explanation. + detail: String, + }, + /// The instance is shutting down and cannot accept the operation. + #[error("shutting down")] + ShuttingDown, + /// The supplied filter was invalid. + #[error("invalid filter: {reason}")] + InvalidFilter { + /// A human-readable explanation. + reason: String, + }, + /// Semantic (embedding-based) features are disabled in this build/config. + #[error("semantic features disabled")] + SemanticDisabled, + /// The operation requires manual clock mode. + #[error("manual clock required")] + ManualClockRequired, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn serde_round_trips_public_surface() { + let c = Claim { + claim_key: "halcyon:inv-1042".into(), + subject_key: Some("halcyon:inv-1042:status".into()), + source_ref: "gmail:msg/123".into(), + observer: ObserverId::from("gmail-v3"), + schema_v: 1, + occurred_at: 1_000, + observed_at: 2_000, + reliability: Reliability::B, + credibility: Credibility::Two, + entities: vec![EntityRef { etype: "project".into(), id: "halcyon".into(), name: Some("Halcyon".into()) }], + body: "Invoice 1042 is 30 days overdue".into(), + }; + let bytes = postcard::to_allocvec(&c).unwrap(); + assert_eq!(postcard::from_bytes::(&bytes).unwrap(), c); + + let f = Focus::uniform().weight("risk", 2.5).half_life_days(3.0).top_k(8); + let json = serde_json_like_roundtrip(&f); // via postcard, same as above + assert_eq!(json.weights.get("risk"), Some(&2.5)); + assert_eq!(json.half_life_days, 3.0); + assert_eq!(json.top_k, Some(8)); + } + + fn serde_json_like_roundtrip(f: &Focus) -> Focus { + postcard::from_bytes(&postcard::to_allocvec(f).unwrap()).unwrap() + } + + #[test] + fn entity_ref_identity_ignores_name() { + let a = EntityRef { etype: "person".into(), id: "sam".into(), name: Some("Sam".into()) }; + let b = EntityRef { etype: "person".into(), id: "sam".into(), name: None }; + assert_eq!(a, b); + use std::collections::BTreeSet; + let mut s = BTreeSet::new(); + s.insert(a); + assert!(s.contains(&b)); + } + + #[test] + fn trust_ranks() { + assert!(Reliability::A.rank() < Reliability::F.rank()); + assert!(Credibility::One.rank() < Credibility::Six.rank()); + assert_eq!(Reliability::C.letter(), 'C'); + assert_eq!(Credibility::Three.digit(), 3); + } + + #[test] + fn config_defaults_match_spec() { + let c = Config::default_for("/tmp/x"); + assert_eq!(c.top_k, 12); + assert_eq!(c.budget_chars, 6000); + assert_eq!(c.decay_buckets_per_half_life, 4); + assert_eq!(c.loop_kinds, vec!["question", "risk", "commitment"]); + assert!(KindTaxonomy::default_taxonomy().contains("fyi")); + assert_eq!(KindTaxonomy::default_taxonomy().kinds.len(), 8); + } +} From 0ee8163c0b909355d3e0f4cd7881e181b332b7d9 Mon Sep 17 00:00:00 2001 From: hhff Date: Sat, 15 Aug 2026 18:08:37 -0700 Subject: [PATCH 05/28] =?UTF-8?q?fix(clog):=20rebuild=5Fon=5Fopen=20defaul?= =?UTF-8?q?ts=20to=20false=20per=20spec=20=C2=A79?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- examples/clog/src/types.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/examples/clog/src/types.rs b/examples/clog/src/types.rs index eb3f881..a4a317e 100644 --- a/examples/clog/src/types.rs +++ b/examples/clog/src/types.rs @@ -453,7 +453,9 @@ pub struct Config { pub wal_fsync: FsyncPolicy, /// The bounded write queue depth. pub write_queue: usize, - /// Whether materialized views are rebuilt from the WAL on open. + /// If `true`, drops cached engine state and rebuilds materialized views + /// by replaying the WAL from scratch on open, rather than resuming from + /// the persisted cache. pub rebuild_on_open: bool, } @@ -472,7 +474,7 @@ impl Config { belief_min_credibility: Credibility::Six, wal_fsync: FsyncPolicy::OnCommit, write_queue: 1024, - rebuild_on_open: true, + rebuild_on_open: false, } } } From 6e15a684b924c08dad9a77eee382748ca59f9423 Mon Sep 17 00:00:00 2001 From: hhff Date: Sat, 15 Aug 2026 18:16:02 -0700 Subject: [PATCH 06/28] feat(clog): observe-time validation (U-VAL-1) --- examples/clog/src/lib.rs | 3 + examples/clog/src/validate.rs | 243 ++++++++++++++++++++++++++++++++++ 2 files changed, 246 insertions(+) create mode 100644 examples/clog/src/validate.rs diff --git a/examples/clog/src/lib.rs b/examples/clog/src/lib.rs index a088293..f14fa02 100644 --- a/examples/clog/src/lib.rs +++ b/examples/clog/src/lib.rs @@ -9,3 +9,6 @@ /// Public API types: the serde-only contract every later task builds on. pub mod types; pub use types::*; + +/// Observe-time validation rules (spec §10). +pub(crate) mod validate; diff --git a/examples/clog/src/validate.rs b/examples/clog/src/validate.rs new file mode 100644 index 0000000..9550ac0 --- /dev/null +++ b/examples/clog/src/validate.rs @@ -0,0 +1,243 @@ +//! Observe-time validation rules (spec §10, test U-VAL-1). +//! +//! These functions are internal: hosts never call them directly, they run +//! inside `Clog::observe` (and friends) before a batch is committed. The +//! whole batch is rejected on the first invalid claim (atomic, no partial +//! commit). + +use crate::types::{ClogError, Claim, Focus, KindTaxonomy}; + +/// Builds a `Claim` with every field satisfying §10, for tests to mutate. +/// +/// Defined at module level (not inside `mod tests`) so later task's test +/// modules can reuse it via `crate::validate::tests_base_claim`. +#[cfg(test)] +pub(crate) fn tests_base_claim() -> Claim { + use crate::types::{Credibility, ObserverId, Reliability}; + Claim { + claim_key: "k1".into(), + subject_key: None, + source_ref: "src:1".into(), + observer: ObserverId::from("o1"), + schema_v: 1, + occurred_at: 1, + observed_at: 1, + reliability: Reliability::A, + credibility: Credibility::One, + entities: vec![], + body: "b".into(), + } +} + +/// Validates a single observed `Claim` against §10's rules. +/// +/// `index` is the claim's position within its batch, echoed back in +/// `ClogError::InvalidClaim` for host-side error reporting. `allow_reserved` +/// is set only by internal writers (e.g. merge claims), which are permitted +/// to use the `clog:` namespace reserved from hosts by INV-8. +// Not yet called from production code: wired into `Clog::observe` by a +// later task. Exercised directly by this module's tests in the meantime. +#[allow(dead_code)] +pub(crate) fn validate_claim(index: usize, c: &Claim, allow_reserved: bool) -> Result<(), ClogError> { + fn has_control(s: &str) -> bool { + s.chars().any(|ch| ch.is_control()) + } + fn invalid(index: usize, reason: impl Into) -> ClogError { + ClogError::InvalidClaim { index, reason: reason.into() } + } + + if c.claim_key.trim().is_empty() { + return Err(invalid(index, "claim_key must be non-empty after trim")); + } + if c.claim_key.len() > 256 { + return Err(invalid(index, "claim_key must be <= 256 bytes")); + } + if has_control(&c.claim_key) { + return Err(invalid(index, "claim_key must not contain control characters")); + } + if c.claim_key.starts_with("clog:") && !allow_reserved { + return Err(ClogError::ReservedNamespace); + } + + if let Some(subject_key) = &c.subject_key { + if subject_key.len() > 256 { + return Err(invalid(index, "subject_key must be <= 256 bytes")); + } + if has_control(subject_key) { + return Err(invalid(index, "subject_key must not contain control characters")); + } + } + + if c.source_ref.trim().is_empty() { + return Err(invalid(index, "source_ref must be non-empty")); + } + if c.source_ref.len() > 1024 { + return Err(invalid(index, "source_ref must be <= 1024 bytes")); + } + if has_control(&c.source_ref) { + return Err(invalid(index, "source_ref must not contain control characters")); + } + + if c.observer.0.trim().is_empty() { + return Err(invalid(index, "observer must be non-empty")); + } + if c.observer.0.len() > 128 { + return Err(invalid(index, "observer must be <= 128 bytes")); + } + + if c.body.trim().is_empty() { + return Err(invalid(index, "body must be non-empty after trim")); + } + if c.body.len() > 16 * 1024 { + return Err(invalid(index, "body must be <= 16 KiB")); + } + + if c.entities.len() > 32 { + return Err(invalid(index, "entities must be <= 32")); + } + for e in &c.entities { + if e.etype.trim().is_empty() { + return Err(invalid(index, "entity etype must be non-empty")); + } + if e.etype.len() > 128 { + return Err(invalid(index, "entity etype must be <= 128 bytes")); + } + if e.id.trim().is_empty() { + return Err(invalid(index, "entity id must be non-empty")); + } + if e.id.len() > 128 { + return Err(invalid(index, "entity id must be <= 128 bytes")); + } + } + + if c.occurred_at == 0 { + return Err(invalid(index, "occurred_at must be > 0")); + } + if c.observed_at == 0 { + return Err(invalid(index, "observed_at must be > 0")); + } + // occurred_at > observed_at is allowed (predictions/backdated corrections). + + Ok(()) +} + +/// Validates a `Focus` against §10's rules, given the active `KindTaxonomy`. +/// +/// Every weight key must name a defined kind; weight values and boost +/// factors must be finite and strictly positive; `half_life_days` must lie +/// in the open interval `(0.01, 3650)`. +// Not yet called from production code: wired into `Clog::focus`/`observe` +// by a later task. Exercised directly by this module's tests in the +// meantime. +#[allow(dead_code)] +pub(crate) fn validate_focus(f: &Focus, taxonomy: &KindTaxonomy) -> Result<(), ClogError> { + fn valid_factor(v: f32) -> bool { + v.is_finite() && v > 0.0 + } + + for (kind, w) in &f.weights { + if !taxonomy.contains(kind) { + return Err(ClogError::UnknownKind); + } + if !valid_factor(*w) { + return Err(ClogError::InvalidFilter { reason: format!("focus weight for {kind} must be finite and > 0") }); + } + } + + for (_, boost) in &f.boosts { + if !valid_factor(*boost) { + return Err(ClogError::InvalidFilter { reason: "focus boost factor must be finite and > 0".into() }); + } + } + + if !(0.01 < f.half_life_days && f.half_life_days < 3650.0) { + return Err(ClogError::InvalidFilter { reason: "half_life_days must be in (0.01, 3650)".into() }); + } + + Ok(()) +} + +/// Clamps a timestamp for scoring purposes only (§10): values more than 24h +/// beyond `now` are clamped to `now`. Storage always keeps the verbatim +/// value; only scoring consumes this clamped result. +// Not yet called from production code: consumed by `score.rs` (Task 4). +// Exercised directly by this module's tests in the meantime. +#[allow(dead_code)] +pub(crate) fn scoring_clamp(ts: u64, now: u64) -> u64 { + if ts > now + 86_400_000 { + now + } else { + ts + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::*; + + #[test] + #[allow(clippy::type_complexity)] // table-driven cases, per the brief's spec verbatim + fn u_val_1_claim_rules() { + // (mutation, should_pass, reason-substring) + let cases: Vec<(Box, bool, &str)> = vec![ + (Box::new(|_| {}), true, ""), + (Box::new(|c| c.claim_key = " ".into()), false, "claim_key"), + (Box::new(|c| c.claim_key = "x".repeat(257)), false, "claim_key"), + (Box::new(|c| c.claim_key = "clog:evil".into()), false, "reserved"), + (Box::new(|c| c.claim_key = "has\u{0007}bell".into()), false, "control"), + (Box::new(|c| c.subject_key = Some("x".repeat(257))), false, "subject_key"), + (Box::new(|c| c.source_ref = "".into()), false, "source_ref"), + (Box::new(|c| c.source_ref = "x".repeat(1025)), false, "source_ref"), + (Box::new(|c| c.observer = ObserverId(String::new())), false, "observer"), + (Box::new(|c| c.observer = ObserverId("x".repeat(129))), false, "observer"), + (Box::new(|c| c.body = " ".into()), false, "body"), + (Box::new(|c| c.body = "x".repeat(16 * 1024 + 1)), false, "body"), + (Box::new(|c| c.entities = vec![EntityRef { etype: "p".into(), id: "i".into(), name: None }; 33]), false, "entities"), + (Box::new(|c| c.entities = vec![EntityRef { etype: "".into(), id: "i".into(), name: None }]), false, "etype"), + (Box::new(|c| c.entities = vec![EntityRef { etype: "p".into(), id: "x".repeat(129), name: None }]), false, "id"), + (Box::new(|c| c.occurred_at = 0), false, "occurred_at"), + (Box::new(|c| c.observed_at = 0), false, "observed_at"), + // occurred_at > observed_at is ALLOWED (predictions) + (Box::new(|c| { c.occurred_at = 10; c.observed_at = 5; }), true, ""), + ]; + for (i, (mutate, ok, why)) in cases.iter().enumerate() { + let mut c = tests_base_claim(); + mutate(&mut c); + let r = validate_claim(7, &c, false); + assert_eq!(r.is_ok(), *ok, "case {i}: {r:?}"); + if !ok { + match r.unwrap_err() { + ClogError::InvalidClaim { index, reason } => { + assert_eq!(index, 7); + assert!(reason.to_lowercase().contains(why), "case {i}: {reason} !~ {why}"); + } + ClogError::ReservedNamespace => assert_eq!(*why, "reserved"), + e => panic!("case {i}: wrong error {e:?}"), + } + } + } + // reserved allowed when internal + let mut c = tests_base_claim(); + c.claim_key = "clog:merge:a->b".into(); + assert!(validate_claim(0, &c, true).is_ok()); + } + + #[test] + fn u_val_1_focus_rules() { + let tax = KindTaxonomy::default_taxonomy(); + assert!(validate_focus(&Focus::uniform().weight("risk", 2.0), &tax).is_ok()); + assert!(matches!(validate_focus(&Focus::uniform().weight("nope", 1.0), &tax), Err(ClogError::UnknownKind))); + assert!(validate_focus(&Focus::uniform().weight("risk", f32::NAN), &tax).is_err()); + assert!(validate_focus(&Focus::uniform().weight("risk", 0.0), &tax).is_err()); + assert!(validate_focus(&Focus::uniform().half_life_days(0.005), &tax).is_err()); + assert!(validate_focus(&Focus::uniform().half_life_days(4000.0), &tax).is_err()); + } + + #[test] + fn scoring_clamp_only_beyond_24h() { + assert_eq!(scoring_clamp(100, 1_000_000), 100); + assert_eq!(scoring_clamp(1_000_000 + 86_400_000, 1_000_000), 1_000_000 + 86_400_000); + assert_eq!(scoring_clamp(1_000_000 + 86_400_001, 1_000_000), 1_000_000); + } +} From c689a251ffcd57f0dafa3ed45c1bea1d4944a3b7 Mon Sep 17 00:00:00 2001 From: hhff Date: Sat, 15 Aug 2026 18:20:15 -0700 Subject: [PATCH 07/28] test(clog): close U-VAL-1 coverage gaps (boosts, entity bounds, control chars) --- examples/clog/src/validate.rs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/examples/clog/src/validate.rs b/examples/clog/src/validate.rs index 9550ac0..e63e876 100644 --- a/examples/clog/src/validate.rs +++ b/examples/clog/src/validate.rs @@ -187,19 +187,28 @@ mod tests { (Box::new(|c| c.claim_key = "clog:evil".into()), false, "reserved"), (Box::new(|c| c.claim_key = "has\u{0007}bell".into()), false, "control"), (Box::new(|c| c.subject_key = Some("x".repeat(257))), false, "subject_key"), + (Box::new(|c| c.subject_key = Some("has\u{0007}bell".into())), false, "control"), (Box::new(|c| c.source_ref = "".into()), false, "source_ref"), (Box::new(|c| c.source_ref = "x".repeat(1025)), false, "source_ref"), + (Box::new(|c| c.source_ref = "has\u{0007}bell".into()), false, "control"), (Box::new(|c| c.observer = ObserverId(String::new())), false, "observer"), (Box::new(|c| c.observer = ObserverId("x".repeat(129))), false, "observer"), (Box::new(|c| c.body = " ".into()), false, "body"), (Box::new(|c| c.body = "x".repeat(16 * 1024 + 1)), false, "body"), (Box::new(|c| c.entities = vec![EntityRef { etype: "p".into(), id: "i".into(), name: None }; 33]), false, "entities"), (Box::new(|c| c.entities = vec![EntityRef { etype: "".into(), id: "i".into(), name: None }]), false, "etype"), + (Box::new(|c| c.entities = vec![EntityRef { etype: "x".repeat(129), id: "i".into(), name: None }]), false, "etype"), (Box::new(|c| c.entities = vec![EntityRef { etype: "p".into(), id: "x".repeat(129), name: None }]), false, "id"), + (Box::new(|c| c.entities = vec![EntityRef { etype: "p".into(), id: "".into(), name: None }]), false, "id"), (Box::new(|c| c.occurred_at = 0), false, "occurred_at"), (Box::new(|c| c.observed_at = 0), false, "observed_at"), // occurred_at > observed_at is ALLOWED (predictions) (Box::new(|c| { c.occurred_at = 10; c.observed_at = 5; }), true, ""), + // valid subject_key + non-empty entities vec + (Box::new(|c| { + c.subject_key = Some("valid-subject".into()); + c.entities = vec![EntityRef { etype: "project".into(), id: "halcyon".into(), name: None }]; + }), true, ""), ]; for (i, (mutate, ok, why)) in cases.iter().enumerate() { let mut c = tests_base_claim(); @@ -232,6 +241,14 @@ mod tests { assert!(validate_focus(&Focus::uniform().weight("risk", 0.0), &tax).is_err()); assert!(validate_focus(&Focus::uniform().half_life_days(0.005), &tax).is_err()); assert!(validate_focus(&Focus::uniform().half_life_days(4000.0), &tax).is_err()); + // half-life bounds are exclusive: exactly the endpoints must fail. + assert!(validate_focus(&Focus::uniform().half_life_days(0.01), &tax).is_err()); + assert!(validate_focus(&Focus::uniform().half_life_days(3650.0), &tax).is_err()); + // boost factors: valid, NaN, zero. + let e = EntityRef { etype: "p".into(), id: "x".into(), name: None }; + assert!(validate_focus(&Focus::uniform().boost(e.clone(), 1.5), &tax).is_ok()); + assert!(validate_focus(&Focus::uniform().boost(e.clone(), f32::NAN), &tax).is_err()); + assert!(validate_focus(&Focus::uniform().boost(e, 0.0), &tax).is_err()); } #[test] From 11ea55235b764e357bfcbbc1cc79831e56ebce2c Mon Sep 17 00:00:00 2001 From: hhff Date: Sat, 15 Aug 2026 18:22:48 -0700 Subject: [PATCH 08/28] feat(clog): pure scoring (U-SCORE-1) --- examples/clog/src/lib.rs | 3 + examples/clog/src/score.rs | 125 +++++++++++++++++++++++++++++++++++++ 2 files changed, 128 insertions(+) create mode 100644 examples/clog/src/score.rs diff --git a/examples/clog/src/lib.rs b/examples/clog/src/lib.rs index f14fa02..2725dcf 100644 --- a/examples/clog/src/lib.rs +++ b/examples/clog/src/lib.rs @@ -12,3 +12,6 @@ pub use types::*; /// Observe-time validation rules (spec §10). pub(crate) mod validate; + +/// Pure scoring functions (spec §5.4). +pub(crate) mod score; diff --git a/examples/clog/src/score.rs b/examples/clog/src/score.rs new file mode 100644 index 0000000..7b8063a --- /dev/null +++ b/examples/clog/src/score.rs @@ -0,0 +1,125 @@ +//! Pure scoring functions (spec §5.4, test U-SCORE-1). +//! +//! These functions are internal: hosts never call them directly, they run +//! inside the ranking engine when it materializes the live view. Kept pure +//! (no I/O, no clock reads) so they're trivially unit-testable. + +use crate::types::{Claim, Credibility, Focus, Reliability}; +use crate::validate::scoring_clamp; + +/// Admiralty trust table, indexed by `rank()`: `A`/`One` = 1.00 down to +/// `F`/`Six` = 0.10. +const TRUST_TABLE: [f32; 6] = [1.00, 0.90, 0.75, 0.50, 0.25, 0.10]; + +/// Combines a source's reliability and a claim's credibility into a single +/// trust multiplier in `(0, 1]`, per §5.4's fixed Admiralty table. +// Not yet called from production code: wired into the ranking engine by a +// later task. Exercised directly by this module's tests in the meantime. +#[allow(dead_code)] +pub(crate) fn trust(r: Reliability, c: Credibility) -> f32 { + TRUST_TABLE[r.rank() as usize] * TRUST_TABLE[c.rank() as usize] +} + +/// Buckets an age in days into fixed-width midpoints, so scores are stable +/// across a bucket rather than continuously decaying. +/// +/// Bucket width is `half_life_days / buckets_per_half_life`. A negative age +/// (an `occurred_at` in the future, after clamping) is treated as age zero, +/// clamping to the first bucket's midpoint. +// Not yet called from production code: wired into the ranking engine by a +// later task. Exercised directly by this module's tests in the meantime. +#[allow(dead_code)] +pub(crate) fn bucket_age_days(age_days: f32, half_life_days: f32, buckets_per_half_life: u32) -> f32 { + let w = half_life_days / buckets_per_half_life as f32; + if age_days < 0.0 { + return w / 2.0; + } + (age_days / w).floor() * w + w / 2.0 +} + +/// Exponential recency decay: `0.5 ^ (bucket_age / half_life_days)`. +// Not yet called from production code: wired into the ranking engine by a +// later task. Exercised directly by this module's tests in the meantime. +#[allow(dead_code)] +pub(crate) fn recency(bucket_age: f32, half_life_days: f32) -> f32 { + 0.5f32.powf(bucket_age / half_life_days) +} + +/// Computes a claim's ranking score per §5.4: kind weight (default 1.0 for +/// unclassified) times admiralty trust times bucketed recency decay times +/// the product of every focus boost whose entity key appears in +/// `canonical_entities` (the claim's entity keys, post-alias-resolution, +/// supplied by the caller). +// Not yet called from production code: wired into the ranking engine by a +// later task. Exercised directly by this module's tests in the meantime. +#[allow(dead_code)] +pub(crate) fn score_claim( + claim: &Claim, + kind: Option<&str>, + focus: &Focus, + canonical_entities: &[(String, String)], + now_ms: u64, + buckets_per_half_life: u32, +) -> f32 { + let age_days = (now_ms.saturating_sub(scoring_clamp(claim.occurred_at, now_ms))) as f32 / 86_400_000.0; + let bucket_age = bucket_age_days(age_days, focus.half_life_days, buckets_per_half_life); + let rec = recency(bucket_age, focus.half_life_days); + let trust_val = trust(claim.reliability, claim.credibility); + let kind_weight = kind.and_then(|k| focus.weights.get(k)).copied().unwrap_or(1.0); + let boost: f32 = focus + .boosts + .iter() + .filter(|(e, _)| canonical_entities.contains(&e.key())) + .map(|(_, f)| *f) + .product(); + kind_weight * trust_val * rec * boost +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::*; + + const DAY_MS: u64 = 86_400_000; + + #[test] + fn u_score_1_trust_table() { + assert_eq!(trust(Reliability::A, Credibility::One), 1.00); + assert_eq!(trust(Reliability::B, Credibility::One), 0.90); + assert_eq!(trust(Reliability::F, Credibility::Six), 0.10 * 0.10); + assert_eq!(trust(Reliability::C, Credibility::Four), 0.75 * 0.50); + } + + #[test] + fn u_score_1_bucket_midpoints() { + // half-life 7d, 4 buckets/hl -> width 1.75d + let w = 7.0 / 4.0; + assert_eq!(bucket_age_days(0.0, 7.0, 4), w / 2.0); // first bucket midpoint + assert_eq!(bucket_age_days(1.0, 7.0, 4), w / 2.0); // same bucket + assert_eq!(bucket_age_days(1.75, 7.0, 4), 1.75 + w / 2.0); // boundary -> next bucket + assert_eq!(bucket_age_days(-5.0, 7.0, 4), w / 2.0); // future occurred_at + } + + #[test] + fn u_score_1_full_formula_with_boost_stacking() { + let focus = Focus::uniform() + .weight("risk", 2.0) + .boost(EntityRef { etype: "project".into(), id: "halcyon".into(), name: None }, 1.5) + .boost(EntityRef { etype: "person".into(), id: "sam".into(), name: None }, 2.0); + let mut claim = crate::validate::tests_base_claim(); // helper added in step 3 + claim.reliability = Reliability::B; // 0.90 + claim.credibility = Credibility::Three; // 0.75 + let now = 10 * DAY_MS; + claim.occurred_at = now; // age 0 -> bucket midpoint 0.875d + let ents = vec![("project".to_string(), "halcyon".to_string()), ("person".to_string(), "sam".to_string())]; + let expected = 2.0 * (0.90 * 0.75) * 0.5f32.powf((0.875f32) / 7.0) * 1.5 * 2.0; // both boosts stack multiplicatively + let got = score_claim(&claim, Some("risk"), &focus, &ents, now, 4); + assert!((got - expected).abs() < 1e-6, "{got} vs {expected}"); + // unclassified -> weight 1.0 + let got_u = score_claim(&claim, None, &focus, &ents, now, 4); + assert!((got_u - expected / 2.0).abs() < 1e-6); + // no matching boost entities -> boost 1.0 + let got_n = score_claim(&claim, Some("risk"), &focus, &[], now, 4); + assert!((got_n - expected / 3.0).abs() < 1e-6); + } +} From be17bd745e3e92adf0dd880f8081160a50ccccc1 Mon Sep 17 00:00:00 2001 From: hhff Date: Sat, 15 Aug 2026 18:27:55 -0700 Subject: [PATCH 09/28] feat(clog): belief resolution total order (U-BELIEF-1/2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the §5.3 total order over a subject's claims: later occurred_at wins, tie-broken by reliability, then credibility, then recorded_at, then claim_key lexicographically. resolve() applies the credibility floor (only claims at or above it are eligible to win) with an only-claim exception when a lone member is floored. --- examples/clog/src/belief.rs | 139 ++++++++++++++++++++++++++++++++++++ examples/clog/src/lib.rs | 4 ++ 2 files changed, 143 insertions(+) create mode 100644 examples/clog/src/belief.rs diff --git a/examples/clog/src/belief.rs b/examples/clog/src/belief.rs new file mode 100644 index 0000000..110d198 --- /dev/null +++ b/examples/clog/src/belief.rs @@ -0,0 +1,139 @@ +//! Belief resolution: the total order that decides which claim is believed +//! per subject (spec §5.3, tests U-BELIEF-1/2). +//! +//! Pure functions, no I/O. Consumed by the ranking engine when it +//! materializes the `believed` flag on rows for a subject group. + +use crate::types::{Claim, Credibility}; + +/// One member of a subject group being resolved for belief: the claim plus +/// when clog recorded it (used as a tiebreaker in the total order). +// Not yet called from production code: wired into the ranking engine by a +// later task. Exercised directly by this module's tests in the meantime. +#[allow(dead_code)] +pub(crate) struct BeliefInput<'a> { + /// The candidate claim. + pub claim: &'a Claim, + /// When clog recorded this claim, in epoch millis. + pub recorded_at: u64, +} + +/// The §5.3 total order as a max-key: later `occurred_at` wins; tie goes to +/// better reliability, then better credibility, then later `recorded_at`, +/// then the lexicographically larger `claim_key`. +// Not yet called from production code: wired into the ranking engine by a +// later task. Exercised directly by this module's tests in the meantime. +#[allow(dead_code)] +pub(crate) fn belief_key(c: &BeliefInput) -> (u64, std::cmp::Reverse, std::cmp::Reverse, u64, String) { + ( + c.claim.occurred_at, + std::cmp::Reverse(c.claim.reliability.rank()), + std::cmp::Reverse(c.claim.credibility.rank()), + c.recorded_at, + c.claim.claim_key.clone(), + ) +} + +/// Resolves which claim, if any, is believed among a subject group. +/// +/// Only members with `credibility.rank() <= floor.rank()` are eligible to +/// win. If no member is eligible and the group has exactly one member (the +/// only-claim exception), that member wins anyway. If no member is eligible +/// and the group has two or more members, nobody is believed and `None` is +/// returned. Otherwise the winner is the max by [`belief_key`] among the +/// eligible members. +// Not yet called from production code: wired into the ranking engine by a +// later task. Exercised directly by this module's tests in the meantime. +#[allow(dead_code)] +pub(crate) fn resolve<'a>(group: &[BeliefInput<'a>], floor: Credibility) -> Option<&'a Claim> { + let eligible: Vec<&BeliefInput<'a>> = group.iter().filter(|c| c.claim.credibility.rank() <= floor.rank()).collect(); + + if eligible.is_empty() { + return if group.len() == 1 { + group.first().map(|c| c.claim) + } else { + None + }; + } + + eligible.into_iter().max_by_key(|c| belief_key(c)).map(|c| c.claim) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::*; + use crate::validate::tests_base_claim; + + fn claim(key: &str, occ: u64, r: Reliability, c: Credibility) -> Claim { + let mut cl = tests_base_claim(); + cl.claim_key = key.into(); + cl.subject_key = Some("s".into()); + cl.occurred_at = occ; + cl.reliability = r; + cl.credibility = c; + cl + } + + #[test] + fn u_belief_1_total_order() { + use Reliability::*; + use Credibility::*; + // each later claim beats all before it, per one tier of the order + let a = claim("a", 100, F, Six); // baseline + let b = claim("b", 100, F, Five); // better credibility + let c = claim("c", 100, E, Six); // better reliability beats credibility tier + let d = claim("d", 200, F, Six); // later occurred_at beats everything + let claims = [&a, &b, &c, &d]; + // recorded_at all equal; exhaustive permutations of arrival order + for perm in permutations(&claims) { + let group: Vec = perm.iter().map(|c| BeliefInput { claim: c, recorded_at: 1 }).collect(); + assert_eq!(resolve(&group, Credibility::Six).unwrap().claim_key, "d"); + } + // tie on everything but recorded_at + let g = [BeliefInput { claim: &a, recorded_at: 5 }, BeliefInput { claim: &b0(&a, "a2"), recorded_at: 9 }]; + assert_eq!(resolve(&g, Credibility::Six).unwrap().claim_key, "a2"); + // full tie -> lexicographically larger claim_key + let g = [BeliefInput { claim: &a, recorded_at: 5 }, BeliefInput { claim: &b0(&a, "z"), recorded_at: 5 }]; + assert_eq!(resolve(&g, Credibility::Six).unwrap().claim_key, "z"); + } + + fn b0(base: &Claim, key: &str) -> Claim { + let mut c = base.clone(); + c.claim_key = key.into(); + c + } + + fn permutations<'a>(xs: &[&'a Claim]) -> Vec> { + if xs.len() <= 1 { + return vec![xs.to_vec()]; + } + let mut out = vec![]; + for i in 0..xs.len() { + let mut rest = xs.to_vec(); + let x = rest.remove(i); + for mut p in permutations(&rest) { + p.insert(0, x); + out.push(p); + } + } + out + } + + #[test] + fn u_belief_2_credibility_floor() { + use Reliability::*; + use Credibility::*; + let good = claim("good", 100, A, Two); + let bad = claim("bad", 200, A, Five); // newer but below floor Three + let g = [BeliefInput { claim: &good, recorded_at: 1 }, BeliefInput { claim: &bad, recorded_at: 2 }]; + assert_eq!(resolve(&g, Three).unwrap().claim_key, "good"); + // only-claim exception + let g = [BeliefInput { claim: &bad, recorded_at: 2 }]; + assert_eq!(resolve(&g, Three).unwrap().claim_key, "bad"); + // all floored, >= 2 members -> nobody believed + let bad2 = claim("bad2", 300, A, Six); + let g = [BeliefInput { claim: &bad, recorded_at: 2 }, BeliefInput { claim: &bad2, recorded_at: 3 }]; + assert!(resolve(&g, Three).is_none()); + } +} diff --git a/examples/clog/src/lib.rs b/examples/clog/src/lib.rs index 2725dcf..c67c46d 100644 --- a/examples/clog/src/lib.rs +++ b/examples/clog/src/lib.rs @@ -15,3 +15,7 @@ pub(crate) mod validate; /// Pure scoring functions (spec §5.4). pub(crate) mod score; + +/// Belief resolution: the total order that decides which claim is believed +/// per subject (spec §5.3). +pub(crate) mod belief; From d2900b6c78121254c0016ee25e1ae4f0adbf1e8b Mon Sep 17 00:00:00 2001 From: hhff Date: Sat, 15 Aug 2026 18:32:33 -0700 Subject: [PATCH 10/28] feat(clog): depth-1 alias map with flattening and cycle rejection (U-ALIAS-1/2) --- examples/clog/src/alias.rs | 113 +++++++++++++++++++++++++++++++++++++ examples/clog/src/lib.rs | 3 + 2 files changed, 116 insertions(+) create mode 100644 examples/clog/src/alias.rs diff --git a/examples/clog/src/alias.rs b/examples/clog/src/alias.rs new file mode 100644 index 0000000..588e63b --- /dev/null +++ b/examples/clog/src/alias.rs @@ -0,0 +1,113 @@ +//! Depth-1 entity alias map with write-time flattening (spec §5.2, tests +//! U-ALIAS-1/2). +//! +//! Edges are always depth-1: `insert` flattens the target at write time (if +//! `canonical` is itself aliased, the new edge points at `canonical`'s +//! target instead) and re-points any existing edges that pointed at `alias` +//! so no chain ever forms. `resolve` is therefore always a single hop. + +use imbl::OrdMap; + +use crate::ClogError; + +/// An entity's identity key: `(etype, id)`. +pub(crate) type EntityKey = (String, String); + +/// A depth-1 alias map from entity key to its canonical entity key. +#[derive(Clone, Default)] +// Not yet called from production code: wired into the ranking engine by a +// later task. Exercised directly by this module's tests in the meantime. +#[allow(dead_code)] +pub(crate) struct AliasMap { + edges: OrdMap, +} + +#[allow(dead_code)] +impl AliasMap { + /// Resolves `k` to its canonical key: one hop, since edges are depth-1 + /// by construction. Identity if `k` has no alias edge. + pub(crate) fn resolve(&self, k: &EntityKey) -> EntityKey { + self.edges.get(k).cloned().unwrap_or_else(|| k.clone()) + } + + /// Write-time flattening helper: if `canonical` is itself aliased, + /// returns its target; otherwise returns `canonical` unchanged. + pub(crate) fn flatten_target(&self, canonical: &EntityKey) -> EntityKey { + self.edges.get(canonical).cloned().unwrap_or_else(|| canonical.clone()) + } + + /// Inserts an alias edge `alias -> canonical`, flattening `canonical` + /// first so edges stay depth-1, then re-pointing any existing edges that + /// targeted `alias` at the newly flattened canonical. Rejects + /// `ClogError::AliasCycle` if the flattened target equals `alias` + /// (covers both reverse edges, e.g. inserting `b -> a` after `a -> b`, + /// and self-loops, e.g. `z -> z`). + pub(crate) fn insert(&mut self, alias: EntityKey, canonical: EntityKey) -> Result<(), ClogError> { + let flattened = self.flatten_target(&canonical); + if flattened == alias { + return Err(ClogError::AliasCycle); + } + // Re-point any existing edges whose target is `alias` so they keep + // pointing at a canonical (depth-1), not at `alias` itself. + let repoint: Vec = self + .edges + .iter() + .filter(|(_, v)| **v == alias) + .map(|(k, _)| k.clone()) + .collect(); + for k in repoint { + self.edges.insert(k, flattened.clone()); + } + self.edges.insert(alias, flattened); + Ok(()) + } + + /// Removes any alias edge for `alias`, un-merging it back to its own + /// identity. + pub(crate) fn remove(&mut self, alias: &EntityKey) { + self.edges.remove(alias); + } + + /// Iterates over the raw alias edges. + pub(crate) fn iter(&self) -> impl Iterator { + self.edges.iter() + } +} + +#[cfg(test)] +mod tests { + use super::*; + fn k(e: &str, i: &str) -> EntityKey { + (e.into(), i.into()) + } + + #[test] + fn u_alias_1_write_time_flattening() { + let mut m = AliasMap::default(); + m.insert(k("p", "a"), k("p", "b")).unwrap(); + // b -> c: the stored edge for a must re-point to c (depth-1, no chains) + m.insert(k("p", "b"), k("p", "c")).unwrap(); + assert_eq!(m.resolve(&k("p", "a")), k("p", "c")); + assert_eq!(m.resolve(&k("p", "b")), k("p", "c")); + // inserting x -> a flattens to x -> c at write time + m.insert(k("p", "x"), k("p", "a")).unwrap(); + assert_eq!(m.resolve(&k("p", "x")), k("p", "c")); + assert_eq!(m.resolve(&k("p", "unrelated")), k("p", "unrelated")); + } + + #[test] + fn u_alias_2_cycle_rejected() { + let mut m = AliasMap::default(); + m.insert(k("p", "a"), k("p", "b")).unwrap(); + assert!(matches!(m.insert(k("p", "b"), k("p", "a")), Err(crate::ClogError::AliasCycle))); + assert!(matches!(m.insert(k("p", "z"), k("p", "z")), Err(crate::ClogError::AliasCycle))); + } + + #[test] + fn remove_unmerges() { + let mut m = AliasMap::default(); + m.insert(k("p", "a"), k("p", "b")).unwrap(); + m.remove(&k("p", "a")); + assert_eq!(m.resolve(&k("p", "a")), k("p", "a")); + } +} diff --git a/examples/clog/src/lib.rs b/examples/clog/src/lib.rs index c67c46d..f18a4f2 100644 --- a/examples/clog/src/lib.rs +++ b/examples/clog/src/lib.rs @@ -19,3 +19,6 @@ pub(crate) mod score; /// Belief resolution: the total order that decides which claim is believed /// per subject (spec §5.3). pub(crate) mod belief; + +/// Depth-1 entity alias map with write-time flattening (spec §5.2). +pub(crate) mod alias; From 7a6c69d73ecd0184e41feab0757475a10a33cfed Mon Sep 17 00:00:00 2001 From: hhff Date: Sat, 15 Aug 2026 18:37:11 -0700 Subject: [PATCH 11/28] feat(clog): rules-tier classifier (U-KIND-1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the free, deterministic rules tier of the cascade classifier (spec §5.6): compile() pre-compiles a KindTaxonomy's regexes into a RuleSet, classify() returns the first matching kind in config order with confidence 1.0 and JudgeSource::Rule. A rule matches when any of its matchers match (any_of). Invalid rule regexes are rejected at compile() with ClogError::Corrupt, documented as a deliberate v1 fit (config-as-state) pending a possible dedicated InvalidConfig variant. --- examples/clog/src/kinds.rs | 186 +++++++++++++++++++++++++++++++++++++ examples/clog/src/lib.rs | 4 + 2 files changed, 190 insertions(+) create mode 100644 examples/clog/src/kinds.rs diff --git a/examples/clog/src/kinds.rs b/examples/clog/src/kinds.rs new file mode 100644 index 0000000..8e68a40 --- /dev/null +++ b/examples/clog/src/kinds.rs @@ -0,0 +1,186 @@ +//! Rules-tier classifier: the free, deterministic first tier of the +//! cascade classifier (spec §5.6, test U-KIND-1). +//! +//! Config (a `KindTaxonomy`) is compiled once into a `RuleSet` with +//! pre-compiled regexes, then `classify` is called per-claim. Rules are +//! evaluated in config order: taxonomy declaration order, then each kind's +//! rules in their declared order. The first rule whose `any_of` matchers +//! contain any match wins; ties are impossible because the search stops at +//! the first hit. + +use regex::Regex; + +use crate::types::{Claim, ClogError, JudgeSource, KindLabel, KindTaxonomy, Matcher, Rule}; + +/// A `Matcher` with any embedded regex pre-compiled at `compile()` time. +#[derive(Debug)] +enum CompiledMatcher { + /// Case-insensitive substring match (both sides lowercased via + /// `to_lowercase`). This is a v1 simplification: it is not full Unicode + /// case-folding, just `char::to_lowercase` applied to the whole string, + /// which is correct-enough for the ASCII- and common-case text clog + /// expects in claim bodies. + BodyContains(String), + /// Regex match against the claim body, pre-compiled. + BodyRegex(Regex), + /// Exact match against the observer's inner string. + ObserverIs(String), + /// Matches if any claim entity has this `etype`. + EntityType(String), +} + +/// A `Rule` with its matchers pre-compiled. +#[derive(Debug)] +struct CompiledRule { + any_of: Vec, +} + +impl CompiledRule { + fn matches(&self, c: &Claim) -> bool { + self.any_of.iter().any(|m| match m { + CompiledMatcher::BodyContains(needle) => c.body.to_lowercase().contains(needle), + CompiledMatcher::BodyRegex(re) => re.is_match(&c.body), + CompiledMatcher::ObserverIs(s) => &c.observer.0 == s, + CompiledMatcher::EntityType(etype) => c.entities.iter().any(|e| &e.etype == etype), + }) + } +} + +/// A compiled `KindTaxonomy`, ready for repeated `classify` calls. +/// +/// Holds `(kind_name, rules)` pairs in config order, with every +/// `Matcher::BodyRegex` already compiled so `classify` never re-parses a +/// regex on the hot path. +// Not yet consumed by production code: wired into the cascade classifier by +// a later task. Exercised directly by this module's tests in the meantime. +#[allow(dead_code)] +pub(crate) struct RuleSet { + kinds: Vec<(String, Vec)>, +} + +/// Compiles a `KindTaxonomy` into a `RuleSet`, pre-compiling every regex. +/// +/// # Errors +/// +/// If any `Matcher::BodyRegex` fails to compile, returns +/// `ClogError::Corrupt { detail: "config: bad regex ..." }`. This is a +/// deliberate choice, not a perfect fit: the taxonomy is host-supplied +/// config rather than on-disk state, but `Corrupt` is the closest existing +/// variant for "state clog was handed is unusable" and config counts as +/// state clog must trust. Revisit in P3 if a dedicated `InvalidConfig` +/// variant earns its place. +// Not yet consumed by production code: wired into the cascade classifier by +// a later task. Exercised directly by this module's tests in the meantime. +#[allow(dead_code)] +pub(crate) fn compile(tax: &KindTaxonomy) -> Result { + fn compile_matcher(m: &Matcher) -> Result { + Ok(match m { + Matcher::BodyContains(s) => CompiledMatcher::BodyContains(s.to_lowercase()), + Matcher::BodyRegex(pat) => { + let re = Regex::new(pat) + .map_err(|e| ClogError::Corrupt { detail: format!("config: bad regex {pat:?}: {e}") })?; + CompiledMatcher::BodyRegex(re) + } + Matcher::ObserverIs(s) => CompiledMatcher::ObserverIs(s.clone()), + Matcher::EntityType(s) => CompiledMatcher::EntityType(s.clone()), + }) + } + + fn compile_rule(r: &Rule) -> Result { + let any_of = r.any_of.iter().map(compile_matcher).collect::, _>>()?; + Ok(CompiledRule { any_of }) + } + + let kinds = tax + .kinds + .iter() + .map(|kd| -> Result<(String, Vec), ClogError> { + let rules = kd.rules.iter().map(compile_rule).collect::, _>>()?; + Ok((kd.name.clone(), rules)) + }) + .collect::, _>>()?; + + Ok(RuleSet { kinds }) +} + +/// Classifies `c` against `rs`, returning the first matching kind in config +/// order (taxonomy declaration order, then each kind's rules in their +/// declared order), or `None` if no rule matches. +/// +/// A rule matches when any of its matchers match (§5.6 `any_of`). Matches +/// are always confidence `1.0` from `JudgeSource::Rule`. +// Not yet consumed by production code: wired into the cascade classifier by +// a later task. Exercised directly by this module's tests in the meantime. +#[allow(dead_code)] +pub(crate) fn classify(rs: &RuleSet, c: &Claim) -> Option { + for (kind, rules) in &rs.kinds { + for rule in rules { + if rule.matches(c) { + return Some(KindLabel { kind: kind.clone(), confidence: 1.0, source: JudgeSource::Rule }); + } + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::*; + use crate::validate::tests_base_claim; + + fn tax_with_rules() -> KindTaxonomy { + let mut tax = KindTaxonomy::default_taxonomy(); + // risk declared before question in default order? No: default order is + // fact, decision, risk, question, ... — rules are evaluated in config order. + for kd in &mut tax.kinds { + match kd.name.as_str() { + "risk" => kd.rules.push(Rule { any_of: vec![Matcher::BodyContains("overdue".into())] }), + "question" => kd.rules.push(Rule { any_of: vec![ + Matcher::BodyRegex(r"\?$".into()), + Matcher::ObserverIs("faq-bot".into()), + ] }), + "fact" => kd.rules.push(Rule { any_of: vec![Matcher::EntityType("bankfeed".into())] }), + _ => {} + } + } + tax + } + + #[test] + fn u_kind_1_first_match_wins_in_config_order() { + let rs = compile(&tax_with_rules()).unwrap(); + let mut c = tests_base_claim(); + // matches BOTH fact (entity type) and risk (body) -> fact wins (declared first) + c.body = "Invoice 1042 is OVERDUE".into(); + c.entities = vec![EntityRef { etype: "bankfeed".into(), id: "x".into(), name: None }]; + let k = classify(&rs, &c).unwrap(); + assert_eq!(k.kind, "fact"); + assert_eq!(k.confidence, 1.0); + assert!(matches!(k.source, JudgeSource::Rule)); + + // case-insensitive BodyContains + c.entities.clear(); + assert_eq!(classify(&rs, &c).unwrap().kind, "risk"); + + // regex matcher + c.body = "did we sign the SOW?".into(); + assert_eq!(classify(&rs, &c).unwrap().kind, "question"); + + // observer matcher (any_of) + c.body = "no punctuation".into(); + c.observer = ObserverId::from("faq-bot"); + assert_eq!(classify(&rs, &c).unwrap().kind, "question"); + + // no match -> None + c.observer = ObserverId::from("o1"); + assert!(classify(&rs, &c).is_none()); + } + + #[test] + fn bad_regex_rejected_at_compile() { + let mut tax = KindTaxonomy::default_taxonomy(); + tax.kinds[0].rules.push(Rule { any_of: vec![Matcher::BodyRegex("(".into())] }); + assert!(matches!(compile(&tax), Err(ClogError::Corrupt { .. }))); + } +} diff --git a/examples/clog/src/lib.rs b/examples/clog/src/lib.rs index f18a4f2..2e66535 100644 --- a/examples/clog/src/lib.rs +++ b/examples/clog/src/lib.rs @@ -22,3 +22,7 @@ pub(crate) mod belief; /// Depth-1 entity alias map with write-time flattening (spec §5.2). pub(crate) mod alias; + +/// Rules-tier classifier: the free, deterministic first tier of the +/// cascade classifier (spec §5.6). +pub(crate) mod kinds; From 4591a6cabc862130d1a343cfbede8e028410c5f1 Mon Sep 17 00:00:00 2001 From: hhff Date: Sat, 15 Aug 2026 18:43:03 -0700 Subject: [PATCH 12/28] feat(clog): template parser and RFC3339 (U-TMPL-1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the render module: a §5.8 grammar parser (SlotName, Segment, Template, parse) that scans for %{...} slots and rejects unknown names/keys, unterminated slots, empty slots, and non-usize limit values as ClogError::TemplateError without ever panicking; the byte-for-byte DEFAULT_TEMPLATE; and rfc3339_utc via Howard Hinnant's civil-from-days algorithm. Renderer body itself lands in the next task. Co-Authored-By: Claude Fable 5 --- examples/clog/src/lib.rs | 4 + examples/clog/src/render/mod.rs | 16 +++ examples/clog/src/render/template.rs | 197 +++++++++++++++++++++++++++ examples/clog/src/render/time.rs | 50 +++++++ 4 files changed, 267 insertions(+) create mode 100644 examples/clog/src/render/mod.rs create mode 100644 examples/clog/src/render/template.rs create mode 100644 examples/clog/src/render/time.rs diff --git a/examples/clog/src/lib.rs b/examples/clog/src/lib.rs index 2e66535..8081a71 100644 --- a/examples/clog/src/lib.rs +++ b/examples/clog/src/lib.rs @@ -26,3 +26,7 @@ pub(crate) mod alias; /// Rules-tier classifier: the free, deterministic first tier of the /// cascade classifier (spec §5.6). pub(crate) mod kinds; + +/// Deterministic rendering: template parsing, RFC3339 timestamps, and (in +/// a later task) the slot renderer itself (spec §5.8). +pub(crate) mod render; diff --git a/examples/clog/src/render/mod.rs b/examples/clog/src/render/mod.rs new file mode 100644 index 0000000..b056d2d --- /dev/null +++ b/examples/clog/src/render/mod.rs @@ -0,0 +1,16 @@ +//! Deterministic rendering: template parsing, RFC3339 timestamps, and (in a +//! later task) the slot renderer itself (spec §5.8). + +/// Template grammar parser (spec §5.8, test U-TMPL-1). +pub(crate) mod template; + +/// RFC3339 (UTC, seconds precision) timestamp formatting used by the +/// `header` slot (spec §5.8). +pub(crate) mod time; + +// Re-exported for the renderer body landing in the next task; not yet +// consumed by production code. +#[allow(unused_imports)] +pub(crate) use template::{parse, Segment, SlotName, Template, DEFAULT_TEMPLATE}; +#[allow(unused_imports)] +pub(crate) use time::rfc3339_utc; diff --git a/examples/clog/src/render/template.rs b/examples/clog/src/render/template.rs new file mode 100644 index 0000000..71e5bb9 --- /dev/null +++ b/examples/clog/src/render/template.rs @@ -0,0 +1,197 @@ +//! Template grammar parser (spec §5.8, test U-TMPL-1). +//! +//! ```text +//! template := ( text | slot )* +//! slot := "%{" name ( WS+ key "=" value )* "}" +//! name := "header" | "urgent" | "open_loops" | "entities" | "changes" +//! key := "limit" (usize; per-slot cap) +//! ``` +//! +//! There is no escape for a literal `%{` in v1: the two-byte sequence +//! `%{` always begins a slot. A host template that needs a literal `%{` +//! in its rendered text cannot express one; this is a deliberate v1 +//! limitation (see spec §5.8), not an oversight. + +use crate::types::ClogError; + +/// A slot name in the template grammar (spec §5.8). +// Not yet consumed by production code: the renderer body lands in the +// next task. Exercised directly by this module's tests in the meantime. +#[allow(dead_code)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum SlotName { + /// `%{header}` — `{scope} · rev {rev} · {as_of RFC3339}`. + Header, + /// `%{urgent}` — ranked urgent items. + Urgent, + /// `%{open_loops}` — open-loop items. + OpenLoops, + /// `%{entities}` — entity summaries. + Entities, + /// `%{changes}` — changes since the last brief. + Changes, +} + +/// One parsed unit of a template: literal text, or a slot to be rendered. +// Not yet consumed by production code: the renderer body lands in the +// next task. Exercised directly by this module's tests in the meantime. +#[allow(dead_code)] +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum Segment { + /// Literal text, emitted verbatim. + Text(String), + /// A slot to be rendered, with its optional `limit` cap. + Slot { + /// Which materialized view this slot renders. + name: SlotName, + /// Per-slot item cap parsed from `limit=`, if present. + limit: Option, + }, +} + +/// A parsed template: an ordered sequence of segments. +// Not yet consumed by production code: the renderer body lands in the +// next task. Exercised directly by this module's tests in the meantime. +#[allow(dead_code)] +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct Template(pub Vec); + +/// The default template (spec §5.8), used when `template == None`. Frozen +/// byte-for-byte: the §11.4 golden tests (U-TMPL-3) depend on this exact +/// string, including its trailing newline. +// Not yet consumed by production code: wired in as the renderer's default +// by a later task. Exercised directly by this module's tests in the +// meantime. +#[allow(dead_code)] +pub(crate) const DEFAULT_TEMPLATE: &str = "\ +# situation · scope: %{header} + +## urgent +%{urgent limit=8} + +## open loops +%{open_loops limit=10} + +## entities +%{entities limit=10} + +## changes since last brief +%{changes limit=6} +"; + +/// Parses `src` per the template grammar (spec §5.8). +/// +/// Scans for the literal `%{`; everything before it is a `Text` segment. +/// Everything up to the next `}` is the slot body: its first +/// whitespace-separated token is the slot name, and each remaining token +/// must be `limit=`. +/// +/// # Errors +/// +/// Returns `ClogError::TemplateError` for any malformed or unknown slot: +/// an unterminated slot (`%{` with no matching `}`), an empty slot +/// (`%{}`), an unknown slot name, an unknown key, or a `limit` value that +/// fails to parse as a `usize` (including an empty value). Never panics +/// on any input — malformed byte sequences produce an `Err`, not a panic +/// (a fuzz target covers this in a later milestone). +// Not yet consumed by production code: the renderer body lands in the +// next task. Exercised directly by this module's tests in the meantime. +#[allow(dead_code)] +pub(crate) fn parse(src: &str) -> Result { + let mut segments = Vec::new(); + let mut rest = src; + loop { + match rest.find("%{") { + None => { + if !rest.is_empty() { + segments.push(Segment::Text(rest.to_string())); + } + break; + } + Some(start) => { + if start > 0 { + segments.push(Segment::Text(rest[..start].to_string())); + } + let after_open = &rest[start + 2..]; + let close = after_open + .find('}') + .ok_or_else(|| ClogError::TemplateError("unterminated slot: missing '}'".to_string()))?; + let body = &after_open[..close]; + segments.push(parse_slot(body)?); + rest = &after_open[close + 1..]; + } + } + } + Ok(Template(segments)) +} + +/// Parses the body of a single slot (the text between `%{` and `}`). +fn parse_slot(body: &str) -> Result { + let mut tokens = body.split_whitespace(); + let name_tok = tokens + .next() + .ok_or_else(|| ClogError::TemplateError("empty slot: '%{}' has no name".to_string()))?; + let name = match name_tok { + "header" => SlotName::Header, + "urgent" => SlotName::Urgent, + "open_loops" => SlotName::OpenLoops, + "entities" => SlotName::Entities, + "changes" => SlotName::Changes, + other => return Err(ClogError::TemplateError(format!("unknown slot name {other:?}"))), + }; + + let mut limit = None; + for tok in tokens { + let (key, value) = tok + .split_once('=') + .ok_or_else(|| ClogError::TemplateError(format!("malformed slot argument {tok:?}: expected key=value")))?; + if key != "limit" { + return Err(ClogError::TemplateError(format!("unknown slot key {key:?}"))); + } + let parsed = value + .parse::() + .map_err(|_| ClogError::TemplateError(format!("invalid limit value {value:?}: expected a usize")))?; + limit = Some(parsed); + } + + Ok(Segment::Slot { name, limit }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn u_tmpl_1_grammar_accept_reject() { + // accepts + assert!(parse("plain text no slots").is_ok()); + assert!(parse("%{header}").is_ok()); + assert!(parse("a %{urgent limit=8} b %{open_loops} c").is_ok()); + assert!(parse("%{entities limit=10}%{changes limit=6}").is_ok()); + // slot with limit parses the value + let t = parse("%{urgent limit=3}").unwrap(); + assert!(matches!(&t.0[0], Segment::Slot { name: SlotName::Urgent, limit: Some(3) })); + // rejects + for bad in [ + "%{nope}", // unknown slot name + "%{urgent", // unterminated + "%{urgent limit=}", // empty value + "%{urgent limit=abc}", // non-numeric + "%{urgent size=3}", // unknown key + "%{}", // empty slot + ] { + assert!(matches!(parse(bad), Err(crate::ClogError::TemplateError(_))), "{bad}"); + } + } + + #[test] + fn default_template_is_spec_bytes() { + // frozen by spec §5.8; U-TMPL-3 goldens depend on this exact string + assert!(DEFAULT_TEMPLATE.starts_with("# situation · scope: %{header}\n")); + assert!(DEFAULT_TEMPLATE.contains("%{urgent limit=8}")); + assert!(DEFAULT_TEMPLATE.contains("%{open_loops limit=10}")); + assert!(DEFAULT_TEMPLATE.contains("%{entities limit=10}")); + assert!(DEFAULT_TEMPLATE.contains("%{changes limit=6}")); + assert!(parse(DEFAULT_TEMPLATE).is_ok()); + } +} diff --git a/examples/clog/src/render/time.rs b/examples/clog/src/render/time.rs new file mode 100644 index 0000000..c53f652 --- /dev/null +++ b/examples/clog/src/render/time.rs @@ -0,0 +1,50 @@ +//! RFC3339 (UTC, seconds precision) timestamp formatting (spec §5.8, +//! header slot: `{as_of RFC3339}`). + +/// Formats `ms` (milliseconds since the Unix epoch, UTC) as +/// `YYYY-MM-DDTHH:MM:SSZ`. +/// +/// Sub-second precision is dropped (truncated, not rounded). Uses Howard +/// Hinnant's civil-from-days algorithm (proleptic Gregorian, no external +/// crate) to go from a day count to a calendar date; this is exact for +/// every `u64` millisecond value representable here (no leap seconds are +/// modeled, matching Unix time). +// Not yet consumed by production code: used by the renderer's `header` +// slot in a later task. Exercised directly by this module's tests in the +// meantime. +#[allow(dead_code)] +pub(crate) fn rfc3339_utc(ms: u64) -> String { + let secs = ms / 1000; + let (days, rem) = (secs / 86_400, secs % 86_400); + let (h, m, s) = (rem / 3600, (rem % 3600) / 60, rem % 60); + let z = days as i64 + 719_468; + let era = z.div_euclid(146_097); + let doe = z.rem_euclid(146_097); + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let mo = if mp < 10 { mp + 3 } else { mp - 9 }; + let y = yoe + era * 400 + i64::from(mo <= 2); + format!("{y:04}-{mo:02}-{d:02}T{h:02}:{m:02}:{s:02}Z") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rfc3339_known_values() { + assert_eq!(rfc3339_utc(0), "1970-01-01T00:00:00Z"); + assert_eq!(rfc3339_utc(86_400_000), "1970-01-02T00:00:00Z"); + // 2000-03-01 is the canonical leap-era edge in the civil algorithm + assert_eq!(rfc3339_utc(951_868_800_000), "2000-03-01T00:00:00Z"); + // task-8-brief.md gave 1_755_216_000_000 / 1_755_262_496_000 for + // these two rows, but those ms values are actually 2025-08-15 (one + // year off from the intended "today" sanity check per the RFC3339 + // algorithm above and independent verification). Corrected here to + // the ms values that actually correspond to 2026-08-15. + assert_eq!(rfc3339_utc(1_786_752_000_000), "2026-08-15T00:00:00Z"); + assert_eq!(rfc3339_utc(1_786_798_496_000), "2026-08-15T12:54:56Z"); + } +} From ffdb264cb97de4f30d362c2dc69c47363fb85838 Mon Sep 17 00:00:00 2001 From: hhff Date: Sat, 15 Aug 2026 18:49:58 -0700 Subject: [PATCH 13/28] feat(clog): slot renderers and budgeter (U-TMPL-2/3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the render/mod.rs slot renderer + budgeter (spec §5.8): headline() normalization, the UrgentItem/LoopItem/EntityItem/ChangeItem/ SlotInputs engine-to-renderer contract, and render() itself, which substitutes each %{...} slot's formatted item lines into the template and budgets down to a char count by dropping whole items from the end of slots in reverse priority order (changes, entities, open_loops, urgent), appending/updating a "… (n more)" marker per truncated slot. Per-slot limit= caps items before budgeting. Corrected the u_tmpl_2 test's tight-budget derivation from full.len() - 1 (bytes) to full.chars().count() - 1: the render contains three U+00B7 middle dots, each 2 bytes but 1 char, so a byte-derived budget was never tight enough to trigger truncation against the spec-correct char-counting budgeter, which would have made the test's own assertions vacuous. The assertions themselves are unchanged. Co-Authored-By: Claude Fable 5 --- examples/clog/src/render/mod.rs | 360 +++++++++++++++++++++++++++++++- 1 file changed, 352 insertions(+), 8 deletions(-) diff --git a/examples/clog/src/render/mod.rs b/examples/clog/src/render/mod.rs index b056d2d..4399233 100644 --- a/examples/clog/src/render/mod.rs +++ b/examples/clog/src/render/mod.rs @@ -1,5 +1,5 @@ -//! Deterministic rendering: template parsing, RFC3339 timestamps, and (in a -//! later task) the slot renderer itself (spec §5.8). +//! Deterministic rendering: template parsing, RFC3339 timestamps, and the +//! slot renderer + budgeter (spec §5.8, tests U-TMPL-2/3). /// Template grammar parser (spec §5.8, test U-TMPL-1). pub(crate) mod template; @@ -8,9 +8,353 @@ pub(crate) mod template; /// `header` slot (spec §5.8). pub(crate) mod time; -// Re-exported for the renderer body landing in the next task; not yet -// consumed by production code. -#[allow(unused_imports)] -pub(crate) use template::{parse, Segment, SlotName, Template, DEFAULT_TEMPLATE}; -#[allow(unused_imports)] -pub(crate) use time::rfc3339_utc; +use template::{Segment, SlotName, Template}; +use time::rfc3339_utc; + +/// Collapses whitespace and caps length for item headlines (spec §5.8). +/// +/// Splits `body` on any whitespace run (`split_whitespace`) and rejoins +/// with single spaces, then truncates to the first 120 **chars** (not +/// bytes — a multi-byte char is never split). +// Consumed by the engine tasks (11-15) that build the `*Item` structs +// below, not by `render` itself: `render` treats each item's `headline` +// field as already-normalized text. +#[allow(dead_code)] +pub(crate) fn headline(body: &str) -> String { + let collapsed = body.split_whitespace().collect::>().join(" "); + collapsed.chars().take(120).collect() +} + +/// A ranked urgent item (spec §5.8, `%{urgent}` slot). +// Consumed by the engine tasks (11-15) that assemble `SlotInputs`; not yet +// constructed by production code in this task. +#[allow(dead_code)] +#[derive(Clone, Debug)] +pub(crate) struct UrgentItem { + /// Urgency score, rendered to one decimal place. + pub score: f32, + /// Pre-normalized headline text (see [`headline`]). + pub headline: String, + /// Admiralty reliability letter (`Reliability::letter`), A-E. + pub reliability: char, + /// Admiralty credibility digit (`Credibility::digit`), 1-6. + pub credibility: u8, + /// The claim key backing this item. + pub claim_key: String, +} + +/// An open-loop item (spec §5.8, `%{open_loops}` slot). +#[allow(dead_code)] +#[derive(Clone, Debug)] +pub(crate) struct LoopItem { + /// The open-loop kind (e.g. `"question"`), rendered uppercased. + pub kind: String, + /// Pre-normalized headline text (see [`headline`]). + pub headline: String, + /// The claim key backing this item. + pub claim_key: String, +} + +/// An entity summary item (spec §5.8, `%{entities}` slot). +#[allow(dead_code)] +#[derive(Clone, Debug)] +pub(crate) struct EntityItem { + /// The entity's display name. + pub display: String, + /// Summary lines for this entity, newest-first; joined with `"; "`. + pub summaries: Vec, +} + +/// A change since the last brief (spec §5.8, `%{changes}` slot). +#[allow(dead_code)] +#[derive(Clone, Debug)] +pub(crate) enum ChangeItem { + /// A newly surfaced headline, rendered `+ {headline}`. + Added(String), + /// A headline no longer present, rendered `- {headline}`. + Removed(String), +} + +/// All materialized data a single `render` call needs (spec §5.8). +/// +/// This is the engine-to-renderer contract: Tasks 11-15 build the +/// `Vec<*Item>` fields from the live views. +#[allow(dead_code)] +#[derive(Clone, Debug)] +pub(crate) struct SlotInputs { + /// The scope this document is rendered for. + pub scope: String, + /// The scope's revision counter at render time. + pub rev: u64, + /// The as-of timestamp (ms since Unix epoch, UTC) rendered by `header`. + pub as_of_ms: u64, + /// Ranked urgent items, in the order they should be numbered. + pub urgent: Vec, + /// Open-loop items. + pub open_loops: Vec, + /// Per-entity summaries. + pub entities: Vec, + /// Changes since the last brief. + pub changes: Vec, +} + +/// The rendered lines of one non-header slot, plus a count of items hidden +/// so far (by the slot's own `limit=`, by budget truncation, or both). +struct SlotState { + lines: Vec, + hidden: usize, +} + +impl SlotState { + /// Builds the state for a slot from its fully-formatted item lines and + /// the template's per-slot `limit`, capping `lines` and recording how + /// many items that cap hid (spec §5.8: "per-slot limit caps items + /// before budgeting"). + fn new(mut lines: Vec, limit: Option) -> Self { + let mut hidden = 0; + if let Some(n) = limit + && lines.len() > n + { + hidden = lines.len() - n; + lines.truncate(n); + } + Self { lines, hidden } + } + + /// Drops the last remaining item line (budget truncation never cuts + /// mid-item), incrementing the hidden count. Returns `false` if there + /// was nothing left to drop. + fn drop_one(&mut self) -> bool { + if self.lines.pop().is_some() { + self.hidden += 1; + true + } else { + false + } + } + + /// Renders this slot's block: `(none)` if genuinely empty, otherwise + /// its item lines joined by `\n`, with a trailing `… ({n} more)` + /// marker line if any items are hidden (spec §5.8). + fn render(&self) -> String { + if self.lines.is_empty() && self.hidden == 0 { + return "(none)".to_string(); + } + let mut lines = self.lines.clone(); + if self.hidden > 0 { + lines.push(format!("… ({} more)", self.hidden)); + } + lines.join("\n") + } +} + +/// Finds the `limit=` configured for the first occurrence of `name` in the +/// template, if any (`None` if the slot isn't present, or is present +/// without a `limit`). +fn configured_limit(t: &Template, name: SlotName) -> Option { + t.0.iter().find_map(|seg| match seg { + Segment::Slot { name: n, limit } if *n == name => *limit, + _ => None, + }) +} + +/// Renders `t` against `inputs`, then budgets the result down to +/// `budget_chars` **chars** (spec §5.8, tests U-TMPL-2/3). +/// +/// Whole items are dropped from the end of slots in reverse priority order +/// — `changes`, then `entities`, then `open_loops`, then `urgent` — until +/// the document fits, or nothing is left to drop. Truncation never cuts +/// mid-item; each truncated slot gets (or updates) a trailing +/// `… ({n} more)` marker with its total hidden count. +#[allow(dead_code)] +pub(crate) fn render(t: &Template, inputs: &SlotInputs, budget_chars: usize) -> String { + let urgent_lines: Vec = inputs + .urgent + .iter() + .enumerate() + .map(|(i, u)| { + format!( + "{}. ({:.1}) {} [{}/{}] ({})", + i + 1, + u.score, + u.headline, + u.reliability, + u.credibility, + u.claim_key + ) + }) + .collect(); + let open_loop_lines: Vec = inputs + .open_loops + .iter() + .map(|l| format!("- {} {} ({})", l.kind.to_uppercase(), l.headline, l.claim_key)) + .collect(); + let entity_lines: Vec = inputs + .entities + .iter() + .map(|e| format!("{}: {}", e.display, e.summaries.join("; "))) + .collect(); + let change_lines: Vec = inputs + .changes + .iter() + .map(|c| match c { + ChangeItem::Added(h) => format!("+ {h}"), + ChangeItem::Removed(h) => format!("- {h}"), + }) + .collect(); + + let mut urgent_state = SlotState::new(urgent_lines, configured_limit(t, SlotName::Urgent)); + let mut open_loops_state = SlotState::new(open_loop_lines, configured_limit(t, SlotName::OpenLoops)); + let mut entities_state = SlotState::new(entity_lines, configured_limit(t, SlotName::Entities)); + let mut changes_state = SlotState::new(change_lines, configured_limit(t, SlotName::Changes)); + + let header_line = format!("{} · rev {} · {}", inputs.scope, inputs.rev, rfc3339_utc(inputs.as_of_ms)); + + let build = |urgent: &SlotState, open_loops: &SlotState, entities: &SlotState, changes: &SlotState| -> String { + let mut out = String::new(); + for seg in &t.0 { + match seg { + Segment::Text(s) => out.push_str(s), + Segment::Slot { name, .. } => { + let block = match name { + SlotName::Header => header_line.clone(), + SlotName::Urgent => urgent.render(), + SlotName::OpenLoops => open_loops.render(), + SlotName::Entities => entities.render(), + SlotName::Changes => changes.render(), + }; + out.push_str(&block); + } + } + } + out + }; + + let mut current = build(&urgent_state, &open_loops_state, &entities_state, &changes_state); + + while current.chars().count() > budget_chars { + let dropped = changes_state.drop_one() + || entities_state.drop_one() + || open_loops_state.drop_one() + || urgent_state.drop_one(); + if !dropped { + break; + } + current = build(&urgent_state, &open_loops_state, &entities_state, &changes_state); + } + + current +} + +#[cfg(test)] +mod render_tests { + use super::*; + use crate::render::template::{parse, DEFAULT_TEMPLATE}; + + fn inputs() -> SlotInputs { + SlotInputs { + scope: "default".into(), + rev: 7, + as_of_ms: 86_400_000, + urgent: vec![ + UrgentItem { + score: 1.25, + headline: "Invoice 1042 overdue".into(), + reliability: 'B', + credibility: 2, + claim_key: "inv".into(), + }, + UrgentItem { + score: 0.5, + headline: "Kickoff moved".into(), + reliability: 'A', + credibility: 1, + claim_key: "kick".into(), + }, + ], + open_loops: vec![LoopItem { + kind: "question".into(), + headline: "Did we sign?".into(), + claim_key: "q1".into(), + }], + entities: vec![EntityItem { + display: "Halcyon".into(), + summaries: vec!["paid".into(), "kicked off".into()], + }], + changes: vec![ChangeItem::Added("Invoice 1042 overdue".into()), ChangeItem::Removed("old thing".into())], + } + } + + #[test] + fn u_tmpl_3_default_template_byte_stability() { + let out = render(&parse(DEFAULT_TEMPLATE).unwrap(), &inputs(), 6000); + let expected = "\ +# situation · scope: default · rev 7 · 1970-01-02T00:00:00Z + +## urgent +1. (1.2) Invoice 1042 overdue [B/2] (inv) +2. (0.5) Kickoff moved [A/1] (kick) + +## open loops +- QUESTION Did we sign? (q1) + +## entities +Halcyon: paid; kicked off + +## changes since last brief ++ Invoice 1042 overdue +- old thing +"; + assert_eq!(out, expected); + } + + #[test] + fn u_tmpl_2_budget_truncation_order() { + // budget small enough to force dropping all changes and one entity summary line + let t = parse(DEFAULT_TEMPLATE).unwrap(); + let full = render(&t, &inputs(), 6000); + // NOTE: the brief's original budget here was `full.len() - 1` + // (bytes). This renders with three middle dots (U+00B7, 2 bytes + // each), so `full.len()` (bytes) exceeds `full.chars().count()` + // by 3, and `render`'s budget check is char-based (spec §5.8: + // "total chars ... not bytes"). A byte-derived budget of + // `full.len() - 1` is therefore never tight enough to trigger any + // truncation at all, which would make every assertion below + // vacuous or false. Using `full.chars().count() - 1` restores the + // test's intent — a budget just barely under the full render — + // against a spec-correct char-counting budgeter. See + // task-9-report.md for the full note. + let tight = render(&t, &inputs(), full.chars().count() - 1); + // changes go first, replaced by the marker + assert!(tight.contains("… (") && tight.contains("more)")); + assert!(!tight.contains("- old thing")); + // urgent survives longest + assert!(tight.contains("1. (1.2)")); + // never over budget + assert!(tight.chars().count() < full.len() || tight.contains("more)")); + } + + #[test] + fn per_slot_limit_caps_items() { + let t = parse("%{urgent limit=1}").unwrap(); + let out = render(&t, &inputs(), 6000); + assert!(out.contains("1. (1.2)")); + assert!(!out.contains("Kickoff")); + assert!(out.contains("… (1 more)")); + } + + #[test] + fn headline_collapses_and_caps() { + assert_eq!(headline(" a\n\n b\tc "), "a b c"); + let long = "x".repeat(300); + assert_eq!(headline(&long).chars().count(), 120); + } + + #[test] + fn empty_slots_render_none() { + let t = parse("%{changes}").unwrap(); + let mut i = inputs(); + i.changes.clear(); + assert_eq!(render(&t, &i, 6000), "(none)"); + } +} From f12bac736c932a3e697a98665752ce6381193e18 Mon Sep 17 00:00:00 2001 From: hhff Date: Sat, 15 Aug 2026 18:56:35 -0700 Subject: [PATCH 14/28] feat(clog): engine contract, event and batch types --- examples/clog/src/engine/mod.rs | 171 ++++++++++++++++++++++++++++++ examples/clog/src/engine/naive.rs | 1 + examples/clog/src/lib.rs | 4 + 3 files changed, 176 insertions(+) create mode 100644 examples/clog/src/engine/mod.rs create mode 100644 examples/clog/src/engine/naive.rs diff --git a/examples/clog/src/engine/mod.rs b/examples/clog/src/engine/mod.rs new file mode 100644 index 0000000..5ef0811 --- /dev/null +++ b/examples/clog/src/engine/mod.rs @@ -0,0 +1,171 @@ +//! Engine contract: the WAL wire format (`Event`/`Batch`), the in-memory +//! materialized-view snapshot (`WorldViews`), and the `Engine` trait that +//! the naive engine (next task) and the WAL both build on (spec §5). +//! +//! `Event`/`Batch`/`StoredClaim` derive `Serialize`/`Deserialize`: these +//! *are* the WAL's on-disk wire format (postcard), so their shapes must stay +//! stable. `WorldViews` is not serialized in P1 (`EngineDump` is deferred to +//! a later phase) and derives `Clone` only. + +use std::collections::BTreeMap; + +use imbl::{OrdMap, OrdSet}; +use serde::{Deserialize, Serialize}; + +use crate::alias::{AliasMap, EntityKey}; +use crate::types::{Claim, Focus, JudgeSource, KindLabel, ObserverId, Rev}; + +mod naive; + +/// A claim as stored by the engine, alongside when clog recorded it. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub(crate) struct StoredClaim { + /// The observed claim. + pub claim: Claim, + /// When clog recorded this claim, in epoch millis. + pub recorded_at: u64, +} + +/// A single WAL-durable state transition. This is the write-ahead log's wire +/// format: every variant must round-trip losslessly through postcard. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub(crate) enum Event { + /// A claim was observed and recorded. + Observe(StoredClaim), + /// A claim was retracted by its `claim_key`. + Retract { + /// The retracted claim's key. + claim_key: String, + }, + /// All of an observer's claims were revoked. + Revoke { + /// The revoked observer. + observer: ObserverId, + }, + /// A scope's `Focus` was set or replaced. + SetFocus { + /// The scope whose focus changed. + scope: String, + /// The new focus. + focus: Focus, + }, + /// A kind judgment was recorded against a claim. + Judge { + /// The judged claim's key. + claim_key: String, + /// The asserted kind. + kind: String, + /// Confidence in `[0, 1]`. + confidence: f32, + /// The judge that produced this label. + source: JudgeSource, + }, + /// The internal clock advanced to `epoch`. + Tick { + /// The new epoch. + epoch: u64, + }, +} + +/// A batch of events committed together at a single revision. +// Not yet constructed by production code: the WAL (a later task) writes and +// reads these. Exercised directly by this module's tests in the meantime. +#[allow(dead_code)] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub(crate) struct Batch { + /// The revision this batch was committed at. + pub rev: Rev, + /// The events applied in this batch, in order. + pub events: Vec, +} + +/// The engine's in-memory materialized-view snapshot. +/// +/// `claims` holds **all** live claims, including reserved `clog:*` ones; +/// every view accessor and `select` filters reserved keys out (INV-8). +/// `believed` maps `subject_key` to the winning `claim_key` (`None` means +/// an all-floored group: every candidate fell below the belief threshold). +/// `urgent` vectors are sorted score-desc, tie `claim_key`-asc, truncated to +/// the scope's `top_k`. +// Most fields are not yet read by production code: WorldViews is populated +// and queried by the naive engine (Task 11) and later ranking/rendering +// tasks. Exercised directly by this module's tests in the meantime. +#[allow(dead_code)] +#[derive(Clone)] +pub(crate) struct WorldViews { + /// All live claims, keyed by `claim_key`, including reserved `clog:*` ones. + pub claims: OrdMap, + /// Kind classifications, keyed by `claim_key`. + pub kinds: OrdMap, + /// Claims with no kind classification yet, keyed by `claim_key`. + pub unclassified: OrdSet, + /// Claim keys grouped by `subject_key`. + pub by_subject: OrdMap>, + /// Claim keys grouped by the observer's inner string. + pub by_observer: OrdMap>, + /// Claim keys grouped by mentioned entity, after alias resolution. + pub by_entity: OrdMap>, + /// The current entity alias map. + pub aliases: AliasMap, + /// Display names for entities, keyed by resolved entity key, alongside + /// the epoch-millis timestamp the name was last set. + pub names: OrdMap, + /// The winning claim key per subject, or `None` for an all-floored group. + pub believed: OrdMap>, + /// Subject keys with an open (unresolved) loop kind. + pub open_loops: OrdSet, + /// Per-scope urgent rows: `(score, claim_key)`, sorted score-desc, tie + /// `claim_key`-asc, truncated to the scope's `top_k`. + pub urgent: OrdMap>, +} + +/// Whether an `Engine::apply` call had any observable effect. +/// +/// P1's naive re-render recomputes every scope's slot inputs per batch (an +/// auditable oracle; see the build design §5); `touched=false` short-circuits +/// when a batch applied zero effective events. Richer per-view diffs arrive +/// in P2 when wakes need them. +// Not yet constructed by production code: returned by `Engine::apply`, +// implemented by the naive engine in Task 11. +#[allow(dead_code)] +pub(crate) struct ApplyResult { + /// Whether the batch had any observable effect on `WorldViews`. + pub touched: bool, +} + +/// The engine contract: applies WAL events to the materialized views and +/// exposes the current snapshot for reading. +// Not yet implemented by production code: the naive engine (Task 11) is the +// first implementor. +#[allow(dead_code)] +pub(crate) trait Engine: Send { + /// Applies `events` to the materialized views, using `scopes` to + /// recompute per-scope derived state (e.g. `urgent`) and `now_ms` as the + /// clock for recency-sensitive computations. + fn apply(&mut self, events: &[Event], scopes: &BTreeMap, now_ms: u64) -> ApplyResult; + /// Borrows the current materialized-view snapshot. + fn views(&self) -> &WorldViews; +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::validate::tests_base_claim; + + #[test] + fn batch_round_trips_postcard() { + let b = Batch { + rev: 3, + events: vec![ + Event::Observe(StoredClaim { claim: tests_base_claim(), recorded_at: 9 }), + Event::Retract { claim_key: "k1".into() }, + Event::Judge { claim_key: "k1".into(), kind: "risk".into(), confidence: 1.0, source: crate::JudgeSource::Rule }, + Event::Tick { epoch: 4 }, + ], + }; + let bytes = postcard::to_allocvec(&b).unwrap(); + let b2: Batch = postcard::from_bytes(&bytes).unwrap(); + assert_eq!(b2.rev, 3); + assert_eq!(b2.events.len(), 4); + } +} diff --git a/examples/clog/src/engine/naive.rs b/examples/clog/src/engine/naive.rs new file mode 100644 index 0000000..13fb44e --- /dev/null +++ b/examples/clog/src/engine/naive.rs @@ -0,0 +1 @@ +//! Naive engine — implemented in the next task. diff --git a/examples/clog/src/lib.rs b/examples/clog/src/lib.rs index 8081a71..b7d7cff 100644 --- a/examples/clog/src/lib.rs +++ b/examples/clog/src/lib.rs @@ -30,3 +30,7 @@ pub(crate) mod kinds; /// Deterministic rendering: template parsing, RFC3339 timestamps, and (in /// a later task) the slot renderer itself (spec §5.8). pub(crate) mod render; + +/// Engine contract: the WAL wire format, the materialized-view snapshot, +/// and the `Engine` trait the naive engine and WAL build on (spec §5). +pub(crate) mod engine; From 895c4a63795f06ab9e8cedf6eb6c7adb55ec7c34 Mon Sep 17 00:00:00 2001 From: hhff Date: Sat, 15 Aug 2026 19:06:30 -0700 Subject: [PATCH 15/28] feat(clog): naive engine observe/retract/judge with belief and urgent views Co-Authored-By: Claude Fable 5 --- examples/clog/src/alias.rs | 7 +- examples/clog/src/belief.rs | 9 - examples/clog/src/engine/mod.rs | 18 +- examples/clog/src/engine/naive.rs | 477 +++++++++++++++++++++++++++++- examples/clog/src/score.rs | 12 - 5 files changed, 488 insertions(+), 35 deletions(-) diff --git a/examples/clog/src/alias.rs b/examples/clog/src/alias.rs index 588e63b..6900449 100644 --- a/examples/clog/src/alias.rs +++ b/examples/clog/src/alias.rs @@ -15,14 +15,10 @@ pub(crate) type EntityKey = (String, String); /// A depth-1 alias map from entity key to its canonical entity key. #[derive(Clone, Default)] -// Not yet called from production code: wired into the ranking engine by a -// later task. Exercised directly by this module's tests in the meantime. -#[allow(dead_code)] pub(crate) struct AliasMap { edges: OrdMap, } -#[allow(dead_code)] impl AliasMap { /// Resolves `k` to its canonical key: one hop, since edges are depth-1 /// by construction. Identity if `k` has no alias edge. @@ -69,6 +65,9 @@ impl AliasMap { } /// Iterates over the raw alias edges. + // Not yet called from production code: consumed by the entity-state + // renderer in a later task. Exercised by this module's tests meanwhile. + #[allow(dead_code)] pub(crate) fn iter(&self) -> impl Iterator { self.edges.iter() } diff --git a/examples/clog/src/belief.rs b/examples/clog/src/belief.rs index 110d198..c7f11f7 100644 --- a/examples/clog/src/belief.rs +++ b/examples/clog/src/belief.rs @@ -8,9 +8,6 @@ use crate::types::{Claim, Credibility}; /// One member of a subject group being resolved for belief: the claim plus /// when clog recorded it (used as a tiebreaker in the total order). -// Not yet called from production code: wired into the ranking engine by a -// later task. Exercised directly by this module's tests in the meantime. -#[allow(dead_code)] pub(crate) struct BeliefInput<'a> { /// The candidate claim. pub claim: &'a Claim, @@ -21,9 +18,6 @@ pub(crate) struct BeliefInput<'a> { /// The §5.3 total order as a max-key: later `occurred_at` wins; tie goes to /// better reliability, then better credibility, then later `recorded_at`, /// then the lexicographically larger `claim_key`. -// Not yet called from production code: wired into the ranking engine by a -// later task. Exercised directly by this module's tests in the meantime. -#[allow(dead_code)] pub(crate) fn belief_key(c: &BeliefInput) -> (u64, std::cmp::Reverse, std::cmp::Reverse, u64, String) { ( c.claim.occurred_at, @@ -42,9 +36,6 @@ pub(crate) fn belief_key(c: &BeliefInput) -> (u64, std::cmp::Reverse, std::c /// and the group has two or more members, nobody is believed and `None` is /// returned. Otherwise the winner is the max by [`belief_key`] among the /// eligible members. -// Not yet called from production code: wired into the ranking engine by a -// later task. Exercised directly by this module's tests in the meantime. -#[allow(dead_code)] pub(crate) fn resolve<'a>(group: &[BeliefInput<'a>], floor: Credibility) -> Option<&'a Claim> { let eligible: Vec<&BeliefInput<'a>> = group.iter().filter(|c| c.claim.credibility.rank() <= floor.rank()).collect(); diff --git a/examples/clog/src/engine/mod.rs b/examples/clog/src/engine/mod.rs index 5ef0811..b8e83b0 100644 --- a/examples/clog/src/engine/mod.rs +++ b/examples/clog/src/engine/mod.rs @@ -87,11 +87,11 @@ pub(crate) struct Batch { /// an all-floored group: every candidate fell below the belief threshold). /// `urgent` vectors are sorted score-desc, tie `claim_key`-asc, truncated to /// the scope's `top_k`. -// Most fields are not yet read by production code: WorldViews is populated -// and queried by the naive engine (Task 11) and later ranking/rendering -// tasks. Exercised directly by this module's tests in the meantime. +// Populated by the naive engine; most fields are not yet *read* by +// production code (select/render consume them in later tasks). Exercised +// directly by this module's and `naive`'s tests in the meantime. #[allow(dead_code)] -#[derive(Clone)] +#[derive(Clone, Default)] pub(crate) struct WorldViews { /// All live claims, keyed by `claim_key`, including reserved `clog:*` ones. pub claims: OrdMap, @@ -112,7 +112,7 @@ pub(crate) struct WorldViews { pub names: OrdMap, /// The winning claim key per subject, or `None` for an all-floored group. pub believed: OrdMap>, - /// Subject keys with an open (unresolved) loop kind. + /// Claim keys whose kind is one of the configured loop kinds (§5.7). pub open_loops: OrdSet, /// Per-scope urgent rows: `(score, claim_key)`, sorted score-desc, tie /// `claim_key`-asc, truncated to the scope's `top_k`. @@ -125,8 +125,8 @@ pub(crate) struct WorldViews { /// auditable oracle; see the build design §5); `touched=false` short-circuits /// when a batch applied zero effective events. Richer per-view diffs arrive /// in P2 when wakes need them. -// Not yet constructed by production code: returned by `Engine::apply`, -// implemented by the naive engine in Task 11. +// `touched` is not yet read by production code: the actor (a later task) +// short-circuits re-render on it. Exercised by `naive`'s tests meanwhile. #[allow(dead_code)] pub(crate) struct ApplyResult { /// Whether the batch had any observable effect on `WorldViews`. @@ -135,8 +135,8 @@ pub(crate) struct ApplyResult { /// The engine contract: applies WAL events to the materialized views and /// exposes the current snapshot for reading. -// Not yet implemented by production code: the naive engine (Task 11) is the -// first implementor. +// Not yet called from production code: the actor (a later task) drives the +// naive engine through this trait. Exercised by `naive`'s tests meanwhile. #[allow(dead_code)] pub(crate) trait Engine: Send { /// Applies `events` to the materialized views, using `scopes` to diff --git a/examples/clog/src/engine/naive.rs b/examples/clog/src/engine/naive.rs index 13fb44e..8a3b755 100644 --- a/examples/clog/src/engine/naive.rs +++ b/examples/clog/src/engine/naive.rs @@ -1 +1,476 @@ -//! Naive engine — implemented in the next task. +//! The naive engine: the semantic oracle (spec §5). +//! +//! This engine is deliberately the simplest thing that is *correct*. Its +//! behaviour **is** the specification of what every later, faster engine must +//! reproduce, so every recompute below is a small named function that can be +//! audited by eye (spec §6.2). Where a choice exists between "incremental and +//! clever" and "recompute from the live claims", this file always picks the +//! latter: alias re-keying, the name registry, and `urgent` are all full +//! rebuilds. Only [`imbl`] ordered structures are used, so every view is +//! deterministic (INV-11). +//! +//! Reserved-namespace claims (`clog:*`) live in `views.claims` like any other +//! claim, but are filtered out of `unclassified`, `open_loops`, `believed` +//! and `urgent` (INV-8). They still *drive* state: a merge claim's body is +//! the alias edge. +//! +//! **Merge claim wire format.** A merge claim's `claim_key` starts with +//! `clog:merge:` and its `body` is exactly four fields joined with the ASCII +//! unit separator (`U+001F`): +//! +//! ```text +//! alias.etype ␟ alias.id ␟ canonical.etype ␟ canonical.id +//! ``` +//! +//! Unit-separated rather than JSON so the engine needs no parser and no extra +//! dependency; §10 validation already rejects control characters in every +//! host-supplied field, so the separator cannot appear in an entity key. + +use std::collections::BTreeMap; + +use imbl::{OrdMap, OrdSet}; + +use crate::alias::EntityKey; +use crate::belief::{self, BeliefInput}; +use crate::engine::{ApplyResult, Engine, Event, StoredClaim, WorldViews}; +use crate::score::score_claim; +use crate::types::{Claim, Credibility, Focus, JudgeSource, KindLabel}; + +/// The namespace reserved for clog's own claims (INV-8). +const RESERVED_PREFIX: &str = "clog:"; +/// The key prefix identifying a merge (entity alias) claim. +const MERGE_PREFIX: &str = "clog:merge:"; +/// The field separator inside a merge claim's body (ASCII unit separator). +const MERGE_SEP: char = '\u{1f}'; + +/// Whether `claim_key` is in the reserved namespace (INV-8). +fn is_reserved(claim_key: &str) -> bool { + claim_key.starts_with(RESERVED_PREFIX) +} + +/// Parses the alias edge carried by a merge claim, or `None` if `claim` is +/// not a merge claim (or its body is malformed). +fn merge_edge(claim: &Claim) -> Option<(EntityKey, EntityKey)> { + if !claim.claim_key.starts_with(MERGE_PREFIX) { + return None; + } + let parts: Vec<&str> = claim.body.split(MERGE_SEP).collect(); + let [alias_etype, alias_id, canonical_etype, canonical_id] = parts[..] else { + return None; + }; + if [alias_etype, alias_id, canonical_etype, canonical_id].iter().any(|f| f.is_empty()) { + return None; + } + Some(( + (alias_etype.to_string(), alias_id.to_string()), + (canonical_etype.to_string(), canonical_id.to_string()), + )) +} + +/// Adds `claim_key` to the set indexed under `k`. +fn index_add(idx: &mut OrdMap>, k: K, claim_key: &str) { + let mut set = idx.get(&k).cloned().unwrap_or_default(); + set.insert(claim_key.to_string()); + idx.insert(k, set); +} + +/// Removes `claim_key` from the set indexed under `k`, dropping the entry +/// entirely once it is empty (so retraction leaves no husks; INV-3). +fn index_remove(idx: &mut OrdMap>, k: &K, claim_key: &str) { + let Some(set) = idx.get_mut(k) else { return }; + set.remove(claim_key); + if set.is_empty() { + idx.remove(k); + } +} + +/// The knobs the naive engine needs from `Config`, extracted so the engine +/// stays independent of the (larger, host-facing) `Config` type. +// Not yet constructed by production code: the actor (a later task) builds +// this from `Config`. Exercised directly by this module's tests meanwhile. +#[allow(dead_code)] +pub(crate) struct NaiveCfg { + /// Kinds treated as open loops. + pub loop_kinds: Vec, + /// Default `urgent` cap, used when a scope's focus sets no `top_k`. + pub top_k: usize, + /// Decay buckets per half-life, passed through to scoring. + pub buckets_per_half_life: u32, + /// The minimum credibility a claim needs to win belief. + pub belief_floor: Credibility, +} + +/// The reference engine: applies events to [`WorldViews`] the obvious way. +// Not yet constructed by production code: the actor (a later task) owns one. +// Exercised directly by this module's tests in the meantime. +#[allow(dead_code)] +pub(crate) struct NaiveEngine { + views: WorldViews, + cfg: NaiveCfg, +} + +#[allow(dead_code)] +impl NaiveEngine { + /// An engine over an empty world. + pub(crate) fn new(cfg: NaiveCfg) -> Self { + NaiveEngine { views: WorldViews::default(), cfg } + } + + // ---- events ------------------------------------------------------- + + /// Records a claim. Re-observing a live `claim_key` is a retraction of + /// the old version followed by an assertion of the new one (INV-4), so + /// the old version's indexes, kind and alias edge are dropped first. + fn observe(&mut self, sc: &StoredClaim) -> bool { + let key = sc.claim.claim_key.clone(); + let previous_subject = self.views.claims.get(&key).and_then(|old| old.claim.subject_key.clone()); + self.remove_live(&key); + + self.views.claims.insert(key.clone(), sc.clone()); + self.reindex_claim(&sc.claim); + if let Some((alias, canonical)) = merge_edge(&sc.claim) { + // A cycle can only reach the engine if the write path failed to + // reject it (U-ALIAS-2 rejects at `merge_entities`); the engine + // has no error channel, so a cycle simply writes no edge. + let _ = self.views.aliases.insert(alias, canonical); + self.rekey_by_entity(); + } + self.rebuild_names(); + // Both groups, because a replacement may move the claim between + // subjects: the old group's winner may change, and so may the new + // one's. Recomputing a group twice is idempotent. + if let Some(subject) = previous_subject { + self.recompute_belief(&subject); + } + if let Some(subject) = sc.claim.subject_key.clone() { + self.recompute_belief(&subject); + } + self.recompute_membership(&key); + true + } + + /// Drops a claim and everything derived from it. Returns whether a live + /// claim was actually removed. + fn retract(&mut self, claim_key: &str) -> bool { + self.remove_live(claim_key) + } + + /// Upserts a kind classification and moves the claim between the + /// `unclassified` and `open_loops` views. Judging a claim that is not + /// live is a no-op: a dead claim must leave no trace behind (INV-3). + fn judge(&mut self, claim_key: &str, kind: &str, confidence: f32, source: JudgeSource) -> bool { + if !self.views.claims.contains_key(claim_key) { + return false; + } + self.views.kinds.insert( + claim_key.to_string(), + KindLabel { kind: kind.to_string(), confidence, source }, + ); + self.recompute_membership(claim_key); + true + } + + /// The shared removal path for retraction and re-observation: unindexes + /// the claim, drops its kind and view memberships, un-merges its alias + /// edge if it was a merge claim (U-ALIAS-3), and heals the derived + /// registries. Returns whether a live claim was removed. + /// + /// Known limitation, inherited from depth-1 flattening (§5.2): removing + /// one merge edge does not un-flatten edges that edge re-pointed. After + /// `a -> b` then `b -> c` (which re-points `a` at `c`), retracting + /// `b -> c` leaves `a -> c`, not `a -> b`. Full retraction of every + /// merge still empties the map, so INV-3's retract-all case holds. + fn remove_live(&mut self, claim_key: &str) -> bool { + let Some(old) = self.views.claims.remove(claim_key) else { return false }; + self.unindex_claim(&old.claim); + self.views.kinds.remove(claim_key); + self.views.unclassified.remove(claim_key); + self.views.open_loops.remove(claim_key); + if let Some((alias, _)) = merge_edge(&old.claim) { + self.views.aliases.remove(&alias); + self.rekey_by_entity(); + } + self.rebuild_names(); + if let Some(subject) = old.claim.subject_key.clone() { + self.recompute_belief(&subject); + } + true + } + + // ---- indexes ------------------------------------------------------ + + /// Adds a claim to `by_subject`/`by_observer`/`by_entity`. Entity keys + /// are indexed post-alias, so `by_entity` is always keyed by canonical + /// entity. + fn reindex_claim(&mut self, claim: &Claim) { + if let Some(subject) = &claim.subject_key { + index_add(&mut self.views.by_subject, subject.clone(), &claim.claim_key); + } + index_add(&mut self.views.by_observer, claim.observer.0.clone(), &claim.claim_key); + for e in &claim.entities { + let canonical = self.views.aliases.resolve(&e.key()); + index_add(&mut self.views.by_entity, canonical, &claim.claim_key); + } + } + + /// The exact inverse of [`Self::reindex_claim`], using the alias map as + /// it stands *now* (callers that also change the alias map re-key + /// `by_entity` afterwards, so a stale resolution cannot survive). + fn unindex_claim(&mut self, claim: &Claim) { + if let Some(subject) = &claim.subject_key { + index_remove(&mut self.views.by_subject, subject, &claim.claim_key); + } + index_remove(&mut self.views.by_observer, &claim.observer.0, &claim.claim_key); + for e in &claim.entities { + let canonical = self.views.aliases.resolve(&e.key()); + index_remove(&mut self.views.by_entity, &canonical, &claim.claim_key); + } + } + + /// Rebuilds `by_entity` from the live claims under the current alias + /// map. Called whenever an alias edge is inserted or removed: that is + /// the expensive retraction the spec calls out in §5.2, and rebuilding + /// wholesale is the auditable way to guarantee every group is re-keyed. + fn rekey_by_entity(&mut self) { + let mut by_entity: OrdMap> = OrdMap::new(); + for (key, sc) in self.views.claims.iter() { + for e in &sc.claim.entities { + index_add(&mut by_entity, self.views.aliases.resolve(&e.key()), key); + } + } + self.views.by_entity = by_entity; + } + + /// Rebuilds the entity display-name registry from the live claims: the + /// latest `recorded_at` carrying a non-null name wins per canonical + /// entity (§5.2), ties going to the larger `claim_key` since claims are + /// visited in key order. Rebuilt rather than patched so a retraction + /// takes its names with it (INV-3). + fn rebuild_names(&mut self) { + let mut names: OrdMap = OrdMap::new(); + for sc in self.views.claims.values() { + for e in &sc.claim.entities { + let Some(name) = &e.name else { continue }; + let canonical = self.views.aliases.resolve(&e.key()); + let newer = names.get(&canonical).is_none_or(|(at, _)| sc.recorded_at >= *at); + if newer { + names.insert(canonical, (sc.recorded_at, name.clone())); + } + } + } + self.views.names = names; + } + + // ---- derived views ------------------------------------------------ + + /// Recomputes which claim is believed for one subject group (§5.3). + /// `None` means an all-floored group with two or more members: nobody is + /// believed. An empty (or entirely reserved) group drops the entry. + fn recompute_belief(&mut self, subject: &str) { + let members: Vec<&StoredClaim> = match self.views.by_subject.get(subject) { + Some(keys) => keys + .iter() + .filter(|k| !is_reserved(k)) + .filter_map(|k| self.views.claims.get(k)) + .collect(), + None => Vec::new(), + }; + if members.is_empty() { + self.views.believed.remove(subject); + return; + } + let group: Vec = members + .iter() + .map(|sc| BeliefInput { claim: &sc.claim, recorded_at: sc.recorded_at }) + .collect(); + let winner = belief::resolve(&group, self.cfg.belief_floor).map(|c| c.claim_key.clone()); + self.views.believed.insert(subject.to_string(), winner); + } + + /// Recomputes one claim's membership of `unclassified` and `open_loops`. + /// Both views hold only live, non-reserved claims (INV-8); `unclassified` + /// means "no kind yet" in P1, `open_loops` means "kind is a loop kind". + fn recompute_membership(&mut self, claim_key: &str) { + let visible = self.views.claims.contains_key(claim_key) && !is_reserved(claim_key); + let kind = self.views.kinds.get(claim_key).map(|l| l.kind.clone()); + + if visible && kind.is_none() { + self.views.unclassified.insert(claim_key.to_string()); + } else { + self.views.unclassified.remove(claim_key); + } + + let open = visible && kind.is_some_and(|k| self.cfg.loop_kinds.contains(&k)); + if open { + self.views.open_loops.insert(claim_key.to_string()); + } else { + self.views.open_loops.remove(claim_key); + } + } + + /// Rescores every live, non-reserved claim for every scope (§5.4) and + /// replaces `urgent` wholesale, so scopes that disappeared from `scopes` + /// leave no stale rows. Rows are sorted score-desc, ties broken by + /// `claim_key`-asc, then truncated to the scope's `top_k`. + fn recompute_urgent(&mut self, scopes: &BTreeMap, now_ms: u64) { + let mut urgent: OrdMap> = OrdMap::new(); + for (scope, focus) in scopes { + let mut rows: Vec<(f32, String)> = self + .views + .claims + .iter() + .filter(|(key, _)| !is_reserved(key)) + .map(|(key, sc)| { + let entities: Vec = + sc.claim.entities.iter().map(|e| self.views.aliases.resolve(&e.key())).collect(); + let kind = self.views.kinds.get(key).map(|l| l.kind.as_str()); + let score = + score_claim(&sc.claim, kind, focus, &entities, now_ms, self.cfg.buckets_per_half_life); + (score, key.clone()) + }) + .collect(); + // `total_cmp` rather than `partial_cmp`: a NaN score must still + // sort deterministically instead of panicking or silently + // reordering (INV-11). + rows.sort_by(|a, b| b.0.total_cmp(&a.0).then_with(|| a.1.cmp(&b.1))); + rows.truncate(focus.top_k.unwrap_or(self.cfg.top_k)); + urgent.insert(scope.clone(), rows); + } + self.views.urgent = urgent; + } +} + +impl Engine for NaiveEngine { + fn apply(&mut self, events: &[Event], scopes: &BTreeMap, now_ms: u64) -> ApplyResult { + let mut touched = false; + for event in events { + let effect = match event { + Event::Observe(sc) => self.observe(sc), + Event::Retract { claim_key } => self.retract(claim_key), + Event::Judge { claim_key, kind, confidence, source } => { + self.judge(claim_key, kind, *confidence, *source) + } + // Revoke lands in the next task; until then it is inert + // rather than silently half-applied. + Event::Revoke { .. } => false, + // The actor owns `scopes` and the clock, and only forwards a + // `Tick` when a claim actually crossed a decay bucket (§5.5), + // so both reach the engine as a rescore request. + Event::SetFocus { .. } | Event::Tick { .. } => true, + }; + touched |= effect; + } + self.recompute_urgent(scopes, now_ms); + ApplyResult { touched } + } + + fn views(&self) -> &WorldViews { + &self.views + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::engine::{Engine, Event, StoredClaim}; + use crate::types::*; + use crate::validate::tests_base_claim; + use std::collections::BTreeMap; + + fn cfg() -> NaiveCfg { + NaiveCfg { loop_kinds: vec!["question".into(), "risk".into(), "commitment".into()], + top_k: 12, buckets_per_half_life: 4, belief_floor: Credibility::Six } + } + fn scopes() -> BTreeMap { + BTreeMap::from([("default".to_string(), Focus::uniform())]) + } + fn obs(key: &str, subject: Option<&str>, body: &str, occ: u64, rec: u64) -> Event { + let mut c = tests_base_claim(); + c.claim_key = key.into(); + c.subject_key = subject.map(Into::into); + c.body = body.into(); + c.occurred_at = occ; c.observed_at = occ; + Event::Observe(StoredClaim { claim: c, recorded_at: rec }) + } + + #[test] + fn observe_retract_round_trip_inv3() { + let mut e = NaiveEngine::new(cfg()); + let empty = e.views().clone(); + e.apply(&[obs("a", Some("s1"), "hello", 10, 10)], &scopes(), 1000); + assert!(e.views().claims.contains_key("a")); + assert_eq!(e.views().believed.get("s1"), Some(&Some("a".to_string()))); + e.apply(&[Event::Retract { claim_key: "a".into() }], &scopes(), 1001); + // INV-3: identical to never having observed (view contents, not revs) + assert!(e.views().claims.is_empty()); + assert!(e.views().believed.is_empty()); + assert!(e.views().by_subject.is_empty()); + assert_eq!(e.views().urgent.get("default").map(Vec::len), empty.urgent.get("default").map(Vec::len).or(Some(0))); + } + + // Beyond the brief's five: INV-3 says retraction heals *everything*, and + // the round-trip test above only inspects claims/believed/by_subject. + #[test] + fn retraction_heals_every_index_inv3() { + let mut e = NaiveEngine::new(cfg()); + let mut c = tests_base_claim(); + c.claim_key = "a".into(); + c.subject_key = Some("s1".into()); + c.observer = ObserverId::from("gmail"); + c.entities = vec![EntityRef { etype: "p".into(), id: "sam".into(), name: Some("Sam".into()) }]; + e.apply(&[Event::Observe(StoredClaim { claim: c, recorded_at: 1 })], &scopes(), 100); + e.apply(&[Event::Judge { claim_key: "a".into(), kind: "risk".into(), confidence: 1.0, source: JudgeSource::Rule }], &scopes(), 101); + assert_eq!(e.views().names.get(&("p".to_string(), "sam".to_string())), Some(&(1, "Sam".to_string()))); + assert!(e.views().open_loops.contains("a")); + + e.apply(&[Event::Retract { claim_key: "a".into() }], &scopes(), 102); + assert!(e.views().by_entity.is_empty()); + assert!(e.views().by_observer.is_empty()); + assert!(e.views().names.is_empty()); + assert!(e.views().kinds.is_empty()); + assert!(e.views().open_loops.is_empty()); + assert!(e.views().unclassified.is_empty()); + } + + #[test] + fn belief_competition_and_flags() { + let mut e = NaiveEngine::new(cfg()); + e.apply(&[obs("old", Some("s1"), "invoice overdue", 100, 1), + obs("new", Some("s1"), "invoice paid", 200, 2)], &scopes(), 1000); + assert_eq!(e.views().believed.get("s1"), Some(&Some("new".to_string()))); + } + + #[test] + fn judge_moves_between_views() { + let mut e = NaiveEngine::new(cfg()); + e.apply(&[obs("a", None, "x", 10, 10)], &scopes(), 1000); + assert!(e.views().unclassified.contains("a")); + assert!(!e.views().open_loops.contains("a")); + e.apply(&[Event::Judge { claim_key: "a".into(), kind: "risk".into(), confidence: 1.0, source: JudgeSource::Rule }], &scopes(), 1001); + assert!(!e.views().unclassified.contains("a")); + assert!(e.views().open_loops.contains("a")); + assert_eq!(e.views().kinds.get("a").unwrap().kind, "risk"); + } + + #[test] + fn urgent_ranked_desc_tiebreak_key() { + let mut e = NaiveEngine::new(cfg()); + // same trust/recency -> equal scores -> claim_key asc breaks tie + e.apply(&[obs("b", None, "x", 100, 1), obs("a", None, "y", 100, 1)], &scopes(), 200); + let u = e.views().urgent.get("default").unwrap(); + assert_eq!(u.iter().map(|(_, k)| k.as_str()).collect::>(), vec!["a", "b"]); + } + + #[test] + fn reserved_claims_invisible_in_urgent_and_loops() { + let mut e = NaiveEngine::new(cfg()); + let mut c = tests_base_claim(); + c.claim_key = "clog:merge:p:a->p:b".into(); + c.body = ["p", "a", "p", "b"].join("\u{1f}"); + e.apply(&[Event::Observe(StoredClaim { claim: c, recorded_at: 1 })], &scopes(), 100); + assert!(e.views().urgent.get("default").unwrap().is_empty()); + assert!(e.views().unclassified.is_empty()); + // but the alias took effect + assert_eq!(e.views().aliases.resolve(&("p".into(), "a".into())), ("p".into(), "b".into())); + } +} diff --git a/examples/clog/src/score.rs b/examples/clog/src/score.rs index 7b8063a..77bc24b 100644 --- a/examples/clog/src/score.rs +++ b/examples/clog/src/score.rs @@ -13,9 +13,6 @@ const TRUST_TABLE: [f32; 6] = [1.00, 0.90, 0.75, 0.50, 0.25, 0.10]; /// Combines a source's reliability and a claim's credibility into a single /// trust multiplier in `(0, 1]`, per §5.4's fixed Admiralty table. -// Not yet called from production code: wired into the ranking engine by a -// later task. Exercised directly by this module's tests in the meantime. -#[allow(dead_code)] pub(crate) fn trust(r: Reliability, c: Credibility) -> f32 { TRUST_TABLE[r.rank() as usize] * TRUST_TABLE[c.rank() as usize] } @@ -26,9 +23,6 @@ pub(crate) fn trust(r: Reliability, c: Credibility) -> f32 { /// Bucket width is `half_life_days / buckets_per_half_life`. A negative age /// (an `occurred_at` in the future, after clamping) is treated as age zero, /// clamping to the first bucket's midpoint. -// Not yet called from production code: wired into the ranking engine by a -// later task. Exercised directly by this module's tests in the meantime. -#[allow(dead_code)] pub(crate) fn bucket_age_days(age_days: f32, half_life_days: f32, buckets_per_half_life: u32) -> f32 { let w = half_life_days / buckets_per_half_life as f32; if age_days < 0.0 { @@ -38,9 +32,6 @@ pub(crate) fn bucket_age_days(age_days: f32, half_life_days: f32, buckets_per_ha } /// Exponential recency decay: `0.5 ^ (bucket_age / half_life_days)`. -// Not yet called from production code: wired into the ranking engine by a -// later task. Exercised directly by this module's tests in the meantime. -#[allow(dead_code)] pub(crate) fn recency(bucket_age: f32, half_life_days: f32) -> f32 { 0.5f32.powf(bucket_age / half_life_days) } @@ -50,9 +41,6 @@ pub(crate) fn recency(bucket_age: f32, half_life_days: f32) -> f32 { /// the product of every focus boost whose entity key appears in /// `canonical_entities` (the claim's entity keys, post-alias-resolution, /// supplied by the caller). -// Not yet called from production code: wired into the ranking engine by a -// later task. Exercised directly by this module's tests in the meantime. -#[allow(dead_code)] pub(crate) fn score_claim( claim: &Claim, kind: Option<&str>, From 46f2c91cca00d1664c21b06a301f59ce3d93d7c2 Mon Sep 17 00:00:00 2001 From: hhff Date: Sat, 15 Aug 2026 19:13:17 -0700 Subject: [PATCH 16/28] fix(clog): entity control-char validation, reserved judge guard, scoped allow Co-Authored-By: Claude Fable 5 --- examples/clog/src/engine/naive.rs | 14 ++++++++++++-- examples/clog/src/validate.rs | 12 ++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/examples/clog/src/engine/naive.rs b/examples/clog/src/engine/naive.rs index 8a3b755..17327cb 100644 --- a/examples/clog/src/engine/naive.rs +++ b/examples/clog/src/engine/naive.rs @@ -109,9 +109,12 @@ pub(crate) struct NaiveEngine { cfg: NaiveCfg, } -#[allow(dead_code)] impl NaiveEngine { /// An engine over an empty world. + // Not yet called from production code: the actor (a later task) + // constructs one. Exercised by this module's tests in the meantime; + // every other method here is reachable through the `Engine` impl. + #[allow(dead_code)] pub(crate) fn new(cfg: NaiveCfg) -> Self { NaiveEngine { views: WorldViews::default(), cfg } } @@ -158,8 +161,10 @@ impl NaiveEngine { /// Upserts a kind classification and moves the claim between the /// `unclassified` and `open_loops` views. Judging a claim that is not /// live is a no-op: a dead claim must leave no trace behind (INV-3). + /// Judging a reserved claim is likewise a no-op: `clog:*` keys stay out + /// of every view, `kinds` included (INV-8). fn judge(&mut self, claim_key: &str, kind: &str, confidence: f32, source: JudgeSource) -> bool { - if !self.views.claims.contains_key(claim_key) { + if !self.views.claims.contains_key(claim_key) || is_reserved(claim_key) { return false; } self.views.kinds.insert( @@ -472,5 +477,10 @@ mod tests { assert!(e.views().unclassified.is_empty()); // but the alias took effect assert_eq!(e.views().aliases.resolve(&("p".into(), "a".into())), ("p".into(), "b".into())); + + // INV-8 covers `kinds` too: a reserved claim cannot be judged into a view. + e.apply(&[Event::Judge { claim_key: "clog:merge:p:a->p:b".into(), kind: "risk".into(), confidence: 1.0, source: JudgeSource::Rule }], &scopes(), 101); + assert!(e.views().kinds.is_empty()); + assert!(e.views().open_loops.is_empty()); } } diff --git a/examples/clog/src/validate.rs b/examples/clog/src/validate.rs index e63e876..5da7d34 100644 --- a/examples/clog/src/validate.rs +++ b/examples/clog/src/validate.rs @@ -102,12 +102,21 @@ pub(crate) fn validate_claim(index: usize, c: &Claim, allow_reserved: bool) -> R if e.etype.len() > 128 { return Err(invalid(index, "entity etype must be <= 128 bytes")); } + // Entity keys are joined with the ASCII unit separator in reserved + // merge-claim bodies (see `engine::naive`); banning control chars + // here is what makes that encoding unambiguous. + if has_control(&e.etype) { + return Err(invalid(index, "entity etype must not contain control characters")); + } if e.id.trim().is_empty() { return Err(invalid(index, "entity id must be non-empty")); } if e.id.len() > 128 { return Err(invalid(index, "entity id must be <= 128 bytes")); } + if has_control(&e.id) { + return Err(invalid(index, "entity id must not contain control characters")); + } } if c.occurred_at == 0 { @@ -200,6 +209,9 @@ mod tests { (Box::new(|c| c.entities = vec![EntityRef { etype: "x".repeat(129), id: "i".into(), name: None }]), false, "etype"), (Box::new(|c| c.entities = vec![EntityRef { etype: "p".into(), id: "x".repeat(129), name: None }]), false, "id"), (Box::new(|c| c.entities = vec![EntityRef { etype: "p".into(), id: "".into(), name: None }]), false, "id"), + (Box::new(|c| c.entities = vec![EntityRef { etype: "has\u{0007}bell".into(), id: "i".into(), name: None }]), false, "control"), + // U+001F is the merge-claim body separator: it must never reach an entity key. + (Box::new(|c| c.entities = vec![EntityRef { etype: "p".into(), id: "a\u{001f}b".into(), name: None }]), false, "control"), (Box::new(|c| c.occurred_at = 0), false, "occurred_at"), (Box::new(|c| c.observed_at = 0), false, "observed_at"), // occurred_at > observed_at is ALLOWED (predictions) From f245362803cc98763fdb2c763c7afb66f8ea1bb9 Mon Sep 17 00:00:00 2001 From: hhff Date: Sat, 15 Aug 2026 19:20:13 -0700 Subject: [PATCH 17/28] feat(clog): revoke expansion, alias re-keying, entity registry (U-ALIAS-3, INV-6) Co-Authored-By: Claude Fable 5 --- examples/clog/src/alias.rs | 6 + examples/clog/src/engine/naive.rs | 345 +++++++++++++++++++++++++++--- 2 files changed, 326 insertions(+), 25 deletions(-) diff --git a/examples/clog/src/alias.rs b/examples/clog/src/alias.rs index 6900449..62ce2a0 100644 --- a/examples/clog/src/alias.rs +++ b/examples/clog/src/alias.rs @@ -60,6 +60,12 @@ impl AliasMap { /// Removes any alias edge for `alias`, un-merging it back to its own /// identity. + // Not called from production code: the engine treats the alias map as a + // view and rebuilds it from the live merge claims rather than dropping + // single edges (§5.2), because a dropped edge cannot restore whatever + // write-time flattening overwrote. Kept as `insert`'s inverse and + // exercised by this module's tests. + #[allow(dead_code)] pub(crate) fn remove(&mut self, alias: &EntityKey) { self.edges.remove(alias); } diff --git a/examples/clog/src/engine/naive.rs b/examples/clog/src/engine/naive.rs index 17327cb..bc9a490 100644 --- a/examples/clog/src/engine/naive.rs +++ b/examples/clog/src/engine/naive.rs @@ -5,9 +5,10 @@ //! reproduce, so every recompute below is a small named function that can be //! audited by eye (spec §6.2). Where a choice exists between "incremental and //! clever" and "recompute from the live claims", this file always picks the -//! latter: alias re-keying, the name registry, and `urgent` are all full -//! rebuilds. Only [`imbl`] ordered structures are used, so every view is -//! deterministic (INV-11). +//! latter: the alias map, `by_entity`'s alias re-keying, the name registry +//! and `urgent` are all rebuilt wholesale from the live claims, so every view +//! is a pure function of `claims` and nothing can drift. Only [`imbl`] +//! ordered structures are used, so every view is deterministic (INV-11). //! //! Reserved-namespace claims (`clog:*`) live in `views.claims` like any other //! claim, but are filtered out of `unclassified`, `open_loops`, `believed` @@ -30,11 +31,11 @@ use std::collections::BTreeMap; use imbl::{OrdMap, OrdSet}; -use crate::alias::EntityKey; +use crate::alias::{AliasMap, EntityKey}; use crate::belief::{self, BeliefInput}; use crate::engine::{ApplyResult, Engine, Event, StoredClaim, WorldViews}; use crate::score::score_claim; -use crate::types::{Claim, Credibility, Focus, JudgeSource, KindLabel}; +use crate::types::{Claim, Credibility, Focus, JudgeSource, KindLabel, ObserverId}; /// The namespace reserved for clog's own claims (INV-8). const RESERVED_PREFIX: &str = "clog:"; @@ -42,6 +43,9 @@ const RESERVED_PREFIX: &str = "clog:"; const MERGE_PREFIX: &str = "clog:merge:"; /// The field separator inside a merge claim's body (ASCII unit separator). const MERGE_SEP: char = '\u{1f}'; +/// How many recent believed claims `entity_state` reports per entity +/// (spec §5.3's internal constant N). +const ENTITY_STATE_ROWS: usize = 8; /// Whether `claim_key` is in the reserved namespace (INV-8). fn is_reserved(claim_key: &str) -> bool { @@ -131,11 +135,8 @@ impl NaiveEngine { self.views.claims.insert(key.clone(), sc.clone()); self.reindex_claim(&sc.claim); - if let Some((alias, canonical)) = merge_edge(&sc.claim) { - // A cycle can only reach the engine if the write path failed to - // reject it (U-ALIAS-2 rejects at `merge_entities`); the engine - // has no error channel, so a cycle simply writes no edge. - let _ = self.views.aliases.insert(alias, canonical); + if merge_edge(&sc.claim).is_some() { + self.rebuild_aliases(); self.rekey_by_entity(); } self.rebuild_names(); @@ -175,24 +176,34 @@ impl NaiveEngine { true } - /// The shared removal path for retraction and re-observation: unindexes - /// the claim, drops its kind and view memberships, un-merges its alias - /// edge if it was a merge claim (U-ALIAS-3), and heals the derived - /// registries. Returns whether a live claim was removed. - /// - /// Known limitation, inherited from depth-1 flattening (§5.2): removing - /// one merge edge does not un-flatten edges that edge re-pointed. After - /// `a -> b` then `b -> c` (which re-points `a` at `c`), retracting - /// `b -> c` leaves `a -> c`, not `a -> b`. Full retraction of every - /// merge still empties the map, so INV-3's retract-all case holds. + /// Expands a `Revoke` into a retraction of every live claim of + /// `observer`, **including** its reserved `clog:*` claims (INV-6; §5.1). + /// `by_observer`'s sets are ordered, so the retractions run in + /// `claim_key` order and the resulting views are replay-deterministic + /// (INV-11). Returns whether any live claim was removed. + fn revoke(&mut self, observer: &ObserverId) -> bool { + let Some(keys) = self.views.by_observer.get(&observer.0) else { return false }; + let keys: Vec = keys.iter().cloned().collect(); + let mut touched = false; + for key in keys { + touched |= self.remove_live(&key); + } + touched + } + + /// The shared removal path for retraction, revocation and re-observation: + /// unindexes the claim, drops its kind and view memberships, un-merges it + /// (by rebuilding the alias map and re-keying `by_entity`) if it was a + /// merge claim (U-ALIAS-3), and heals the derived registries. Returns + /// whether a live claim was removed. fn remove_live(&mut self, claim_key: &str) -> bool { let Some(old) = self.views.claims.remove(claim_key) else { return false }; self.unindex_claim(&old.claim); self.views.kinds.remove(claim_key); self.views.unclassified.remove(claim_key); self.views.open_loops.remove(claim_key); - if let Some((alias, _)) = merge_edge(&old.claim) { - self.views.aliases.remove(&alias); + if merge_edge(&old.claim).is_some() { + self.rebuild_aliases(); self.rekey_by_entity(); } self.rebuild_names(); @@ -232,6 +243,38 @@ impl NaiveEngine { } } + /// Rebuilds the alias map from the live merge claims, so `aliases` is a + /// pure *view* of `claims` (§5.2: "the canonical map is a view") rather + /// than an incrementally patched cache. Called on every merge-claim + /// insert and removal. + /// + /// Patching (insert the edge on observe, drop it on retract) is wrong in + /// two ways that this rebuild fixes: + /// + /// 1. depth-1 flattening is lossy: after `a -> b` then `b -> c` the + /// stored edge for `a` points at `c`, so dropping `b -> c` would + /// leave `a -> c` even though the only surviving claim says `a -> b`; + /// 2. two live merge claims may name the same alias (`a -> b`, + /// `a -> c`): dropping one would drop the whole edge instead of + /// falling back to the other claim's. + /// + /// Claims are visited in `claim_key` order and edges are re-inserted + /// through [`AliasMap::insert`], so flattening still applies and the + /// result is a deterministic function of the live claims (INV-11). + /// Insert errors are ignored: the only error is `AliasCycle`, which the + /// write path rejects at `merge_entities` (U-ALIAS-2) and the engine has + /// no channel to report — a cycling edge simply loses to the lower + /// `claim_key` that already claimed the far end. + fn rebuild_aliases(&mut self) { + let mut aliases = AliasMap::default(); + for sc in self.views.claims.values() { + if let Some((alias, canonical)) = merge_edge(&sc.claim) { + let _ = aliases.insert(alias, canonical); + } + } + self.views.aliases = aliases; + } + /// Rebuilds `by_entity` from the live claims under the current alias /// map. Called whenever an alias edge is inserted or removed: that is /// the expensive retraction the spec calls out in §5.2, and rebuilding @@ -313,6 +356,18 @@ impl NaiveEngine { } } + /// A copy of `focus` whose boost entities are rewritten to their + /// canonical keys, leaving weights, half-life and `top_k` untouched. + fn resolve_focus(&self, focus: &Focus) -> Focus { + let mut resolved = focus.clone(); + for (entity, _) in resolved.boosts.iter_mut() { + let (etype, id) = self.views.aliases.resolve(&entity.key()); + entity.etype = etype; + entity.id = id; + } + resolved + } + /// Rescores every live, non-reserved claim for every scope (§5.4) and /// replaces `urgent` wholesale, so scopes that disappeared from `scopes` /// leave no stale rows. Rows are sorted score-desc, ties broken by @@ -320,6 +375,15 @@ impl NaiveEngine { fn recompute_urgent(&mut self, scopes: &BTreeMap, now_ms: u64) { let mut urgent: OrdMap> = OrdMap::new(); for (scope, focus) in scopes { + // A focus names entities the way the *host* knows them, and + // `score_claim` matches boosts against canonical keys, so the + // boosts are resolved through the alias map first: a boost on an + // entity that has since been merged away must still boost the + // claims that mention it (§5.2 — "every view that groups or + // filters by entity resolves through this map"). Boosts that + // collapse onto the same canonical entity stack multiplicatively, + // exactly as two distinct matching boosts already do (§5.4). + let focus = &self.resolve_focus(focus); let mut rows: Vec<(f32, String)> = self .views .claims @@ -345,6 +409,58 @@ impl NaiveEngine { } } +/// One [`entity_state`] row: a canonical entity, its display name, and the +/// believed claims of the subjects touching it, each paired with its +/// `subject_key`. +pub(crate) type EntityStateRow = (EntityKey, String, Vec<(String, StoredClaim)>); + +/// The `entity_state` view (§5.3, §5.7): one row per canonical entity, in +/// entity-key order, carrying the entity's display name and the believed +/// claims of every subject that touches it. +/// +/// The display name is the registry's latest-seen name for the canonical +/// entity, falling back to `"{etype}:{id}"` when no claim ever named it. +/// +/// A subject "touches" the entity when *its believed claim* mentions the +/// entity (post-alias) — so `by_entity`, which is already keyed canonically, +/// supplies the candidates and `believed` filters them. Losing claims and +/// subject-less claims produce no rows, and reserved claims are excluded +/// throughout (INV-8; `believed` already skips them). +/// +/// Rows are newest-first by `occurred_at`, ties broken by `claim_key` ascending +/// (the same tiebreak `urgent` uses), then capped at [`ENTITY_STATE_ROWS`]. +// Not yet called from production code: the renderer's `entities` slot (a +// later task) consumes this. Exercised by this module's tests meanwhile. +#[allow(dead_code)] +pub(crate) fn entity_state(views: &WorldViews) -> Vec { + let mut out = Vec::new(); + for (entity, keys) in views.by_entity.iter() { + let display = match views.names.get(entity) { + Some((_, name)) => name.clone(), + None => format!("{}:{}", entity.0, entity.1), + }; + let mut rows: Vec<(String, StoredClaim)> = keys + .iter() + .filter(|key| !is_reserved(key)) + .filter_map(|key| Some((key, views.claims.get(key)?))) + .filter_map(|(key, sc)| { + let subject = sc.claim.subject_key.clone()?; + // only the subject's *winner* earns a row + match views.believed.get(&subject) { + Some(Some(winner)) if winner == key => Some((subject, sc.clone())), + _ => None, + } + }) + .collect(); + rows.sort_by(|a, b| { + b.1.claim.occurred_at.cmp(&a.1.claim.occurred_at).then_with(|| a.1.claim.claim_key.cmp(&b.1.claim.claim_key)) + }); + rows.truncate(ENTITY_STATE_ROWS); + out.push((entity.clone(), display, rows)); + } + out +} + impl Engine for NaiveEngine { fn apply(&mut self, events: &[Event], scopes: &BTreeMap, now_ms: u64) -> ApplyResult { let mut touched = false; @@ -355,9 +471,7 @@ impl Engine for NaiveEngine { Event::Judge { claim_key, kind, confidence, source } => { self.judge(claim_key, kind, *confidence, *source) } - // Revoke lands in the next task; until then it is inert - // rather than silently half-applied. - Event::Revoke { .. } => false, + Event::Revoke { observer } => self.revoke(observer), // The actor owns `scopes` and the clock, and only forwards a // `Tick` when a claim actually crossed a decay bucket (§5.5), // so both reach the engine as a rescore request. @@ -483,4 +597,185 @@ mod tests { assert!(e.views().kinds.is_empty()); assert!(e.views().open_loops.is_empty()); } + + /// A merge claim carrying the edge `{alias} -> {canonical}`. + fn merge(alias: (&str, &str), canonical: (&str, &str)) -> Event { + let mut m = tests_base_claim(); + m.claim_key = format!("clog:merge:{}:{}->{}:{}", alias.0, alias.1, canonical.0, canonical.1); + m.observer = ObserverId::from("clog"); + m.body = [alias.0, alias.1, canonical.0, canonical.1].join("\u{1f}"); + Event::Observe(StoredClaim { claim: m, recorded_at: 1 }) + } + + #[test] + fn revoke_retracts_all_of_observer_inv6() { + let mut e = NaiveEngine::new(cfg()); + let mut c1 = tests_base_claim(); c1.claim_key = "a".into(); c1.observer = ObserverId::from("gmail"); + let mut c2 = tests_base_claim(); c2.claim_key = "b".into(); c2.observer = ObserverId::from("gmail"); + let mut c3 = tests_base_claim(); c3.claim_key = "c".into(); c3.observer = ObserverId::from("twist"); + e.apply(&[Event::Observe(StoredClaim { claim: c1, recorded_at: 1 }), + Event::Observe(StoredClaim { claim: c2, recorded_at: 1 }), + Event::Observe(StoredClaim { claim: c3, recorded_at: 1 })], &scopes(), 100); + e.apply(&[Event::Revoke { observer: ObserverId::from("gmail") }], &scopes(), 101); + assert!(!e.views().claims.contains_key("a")); + assert!(!e.views().claims.contains_key("b")); + assert!(e.views().claims.contains_key("c")); + assert!(e.views().by_observer.get("gmail").is_none()); + } + + // Beyond the brief: INV-6 says revoke takes the observer's *reserved* + // claims with it too, and a revoke of an unknown observer is inert. + #[test] + fn revoke_takes_reserved_claims_inv6() { + let mut e = NaiveEngine::new(cfg()); + let mut c = tests_base_claim(); + c.claim_key = "about-a".into(); + c.observer = ObserverId::from("clog"); + c.entities = vec![EntityRef { etype: "p".into(), id: "a".into(), name: None }]; + e.apply(&[Event::Observe(StoredClaim { claim: c, recorded_at: 1 }), merge(("p", "a"), ("p", "b"))], &scopes(), 100); + assert_eq!(e.views().aliases.resolve(&("p".into(), "a".into())), ("p".into(), "b".into())); + + assert!(!e.apply(&[Event::Revoke { observer: ObserverId::from("nobody") }], &scopes(), 101).touched); + e.apply(&[Event::Revoke { observer: ObserverId::from("clog") }], &scopes(), 102); + assert!(e.views().claims.is_empty()); + assert!(e.views().by_observer.is_empty()); + assert!(e.views().by_entity.is_empty()); + // the merge claim went with it, so the alias edge did too (INV-3) + assert_eq!(e.views().aliases.resolve(&("p".into(), "a".into())), ("p".into(), "a".into())); + } + + #[test] + fn u_alias_3_merge_retraction_rekeys_views() { + let mut e = NaiveEngine::new(cfg()); + let mut c = tests_base_claim(); + c.claim_key = "about-a".into(); + c.entities = vec![EntityRef { etype: "p".into(), id: "a".into(), name: Some("Aye".into()) }]; + e.apply(&[Event::Observe(StoredClaim { claim: c, recorded_at: 1 })], &scopes(), 100); + + let mut m = tests_base_claim(); + m.claim_key = "clog:merge:p:a->p:b".into(); + m.observer = ObserverId::from("clog"); + m.body = ["p", "a", "p", "b"].join("\u{1f}"); + e.apply(&[Event::Observe(StoredClaim { claim: m, recorded_at: 2 })], &scopes(), 101); + // grouped under canonical b now + assert!(e.views().by_entity.get(&("p".into(), "b".into())).unwrap().contains("about-a")); + assert!(e.views().by_entity.get(&("p".into(), "a".into())).is_none()); + + e.apply(&[Event::Retract { claim_key: "clog:merge:p:a->p:b".into() }], &scopes(), 102); + // un-merged: re-keyed back under a, name registry intact + assert!(e.views().by_entity.get(&("p".into(), "a".into())).unwrap().contains("about-a")); + let es = entity_state(e.views()); + let (_, display, rows) = es.iter().find(|(k, _, _)| k == &("p".to_string(), "a".to_string())).unwrap(); + assert_eq!(display, "Aye"); + assert_eq!(rows.len(), 0); // no subject_key -> no believed rows + } + + // Ruling A, case 1: the alias map is a *view* of the live merge claims, + // not an incrementally patched cache. After `a -> b` then `b -> c` + // (which write-time-flattens `a` onto `c`), retracting `b -> c` must + // restore `a -> b` — the edge its own live claim still asserts. + #[test] + fn u_alias_3_retracting_a_flattening_merge_restores_the_earlier_edge() { + let mut e = NaiveEngine::new(cfg()); + e.apply(&[merge(("p", "a"), ("p", "b")), merge(("p", "b"), ("p", "c"))], &scopes(), 100); + assert_eq!(e.views().aliases.resolve(&("p".into(), "a".into())), ("p".into(), "c".into())); + + e.apply(&[Event::Retract { claim_key: "clog:merge:p:b->p:c".into() }], &scopes(), 101); + assert_eq!(e.views().aliases.resolve(&("p".into(), "a".into())), ("p".into(), "b".into())); + assert_eq!(e.views().aliases.resolve(&("p".into(), "b".into())), ("p".into(), "b".into())); + } + + // Ruling A, case 2: two live merge claims can name the same alias. The + // last one applied owns the edge; retracting it must fall back to the + // other live claim's edge, not leave `a` unaliased. + #[test] + fn u_alias_3_retracting_one_of_two_merges_leaves_the_others_edge() { + let mut e = NaiveEngine::new(cfg()); + let mut c = tests_base_claim(); + c.claim_key = "about-a".into(); + c.entities = vec![EntityRef { etype: "p".into(), id: "a".into(), name: None }]; + e.apply(&[Event::Observe(StoredClaim { claim: c, recorded_at: 1 }), + merge(("p", "a"), ("p", "b")), merge(("p", "a"), ("p", "c"))], &scopes(), 100); + assert_eq!(e.views().aliases.resolve(&("p".into(), "a".into())), ("p".into(), "c".into())); + + e.apply(&[Event::Retract { claim_key: "clog:merge:p:a->p:c".into() }], &scopes(), 101); + assert_eq!(e.views().aliases.resolve(&("p".into(), "a".into())), ("p".into(), "b".into())); + assert!(e.views().by_entity.get(&("p".into(), "b".into())).unwrap().contains("about-a")); + assert!(e.views().by_entity.get(&("p".into(), "a".into())).is_none()); + } + + // Ruling B: focus boosts name entities the way the *host* knows them, so + // a boost on an alias must survive that alias being merged away. + #[test] + fn focus_boosts_resolve_through_aliases() { + let mut e = NaiveEngine::new(cfg()); + let mut c = tests_base_claim(); + c.claim_key = "about-a".into(); + c.entities = vec![EntityRef { etype: "p".into(), id: "a".into(), name: None }]; + let ent = |id: &str| EntityRef { etype: "p".into(), id: id.into(), name: None }; + let scopes = BTreeMap::from([ + ("plain".to_string(), Focus::uniform()), + ("alias".to_string(), Focus::uniform().boost(ent("a"), 3.0)), + ("canonical".to_string(), Focus::uniform().boost(ent("b"), 3.0)), + ]); + e.apply(&[Event::Observe(StoredClaim { claim: c, recorded_at: 1 }), merge(("p", "a"), ("p", "b"))], &scopes, 100); + + let score = |scope: &str| { + e.views().urgent.get(scope).unwrap().iter().find(|(_, k)| k == "about-a").unwrap().0 + }; + let plain = score("plain"); + assert!(plain > 0.0); + assert!((score("alias") - plain * 3.0).abs() < 1e-6, "{} vs {}", score("alias"), plain * 3.0); + assert!((score("canonical") - plain * 3.0).abs() < 1e-6); + } + + #[test] + fn entity_state_newest_first_capped() { + let mut e = NaiveEngine::new(cfg()); + let ent = EntityRef { etype: "proj".into(), id: "h".into(), name: None }; + let mut evs = vec![]; + for i in 0..10 { + let mut c = tests_base_claim(); + c.claim_key = format!("c{i}"); + c.subject_key = Some(format!("s{i}")); + c.occurred_at = 100 + i; + c.entities = vec![ent.clone()]; + evs.push(Event::Observe(StoredClaim { claim: c, recorded_at: 1 })); + } + e.apply(&evs, &scopes(), 1000); + let es = entity_state(e.views()); + let (_, display, rows) = &es[0]; + assert_eq!(display, "proj:h"); + assert_eq!(rows.len(), 8); + assert_eq!(rows[0].0, "s9"); // newest occurred_at first + } + + // Beyond the brief: entity_state only reports *believed* claims, and it + // reports them under the canonical entity after a merge. + #[test] + fn entity_state_reports_believed_rows_under_the_canonical_entity() { + let mut e = NaiveEngine::new(cfg()); + let ent = |id: &str| EntityRef { etype: "p".into(), id: id.into(), name: None }; + let mut old = tests_base_claim(); + old.claim_key = "old".into(); old.subject_key = Some("s1".into()); old.occurred_at = 100; + old.entities = vec![ent("a")]; + let mut new = tests_base_claim(); + new.claim_key = "new".into(); new.subject_key = Some("s1".into()); new.occurred_at = 200; + new.entities = vec![ent("a")]; + e.apply(&[Event::Observe(StoredClaim { claim: old, recorded_at: 1 }), + Event::Observe(StoredClaim { claim: new, recorded_at: 2 })], &scopes(), 1000); + let es = entity_state(e.views()); + assert_eq!(es.len(), 1); + assert_eq!(es[0].0, ("p".to_string(), "a".to_string())); + // one row per subject: the believed claim, not the losing one + assert_eq!(es[0].2.iter().map(|(s, sc)| (s.as_str(), sc.claim.claim_key.as_str())).collect::>(), + vec![("s1", "new")]); + + e.apply(&[merge(("p", "a"), ("p", "b"))], &scopes(), 1001); + let es = entity_state(e.views()); + assert_eq!(es.len(), 1); + assert_eq!(es[0].0, ("p".to_string(), "b".to_string())); + assert_eq!(es[0].1, "p:b"); + assert_eq!(es[0].2.len(), 1); + } } From 008098303d2d54a51dd4b1af6153d8e7b16435f0 Mon Sep 17 00:00:00 2001 From: hhff Date: Sat, 15 Aug 2026 19:28:24 -0700 Subject: [PATCH 18/28] feat(clog): postcard+crc32 WAL with torn-tail quarantine (R2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds wal.rs: an append-only, length-prefixed, CRC32-checked frame log that is clog's source of truth (spec §6.3, INV-11). open_dir replays every existing frame in order; a torn or corrupt tail (short read, absurd length, CRC mismatch, or a CRC-valid record that still fails to postcard-decode) is never applied, quarantined to wal.corrupt, and truncated from the log so reopening afterwards is clean (R2). append writes one frame and fsyncs per FsyncPolicy. --- examples/clog/src/lib.rs | 4 + examples/clog/src/wal.rs | 228 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 232 insertions(+) create mode 100644 examples/clog/src/wal.rs diff --git a/examples/clog/src/lib.rs b/examples/clog/src/lib.rs index b7d7cff..bdab32b 100644 --- a/examples/clog/src/lib.rs +++ b/examples/clog/src/lib.rs @@ -34,3 +34,7 @@ pub(crate) mod render; /// Engine contract: the WAL wire format, the materialized-view snapshot, /// and the `Engine` trait the naive engine and WAL build on (spec §5). pub(crate) mod engine; + +/// The append-only write-ahead log: clog's source of truth (spec §6.3, +/// INV-11, recovery test R2). +pub(crate) mod wal; diff --git a/examples/clog/src/wal.rs b/examples/clog/src/wal.rs new file mode 100644 index 0000000..3cb7120 --- /dev/null +++ b/examples/clog/src/wal.rs @@ -0,0 +1,228 @@ +//! The append-only write-ahead log: clog's source of truth (spec §6.3, +//! INV-11). Every committed batch is appended as one length-prefixed, +//! CRC32-checked frame; reopening replays every frame in order. A torn or +//! corrupt tail is never applied: it is quarantined to `wal.corrupt` and +//! truncated from the log (recovery test R2), and replay never panics. +//! +//! **Frame format:** `[len: u32 LE][crc32(payload): u32 LE][payload]`, +//! where `payload` is `postcard::to_allocvec(&batch)` and the CRC covers +//! only the payload bytes. + +use std::fs::{self, File, OpenOptions}; +use std::io::Write; +use std::path::Path; + +use crate::engine::Batch; +use crate::types::{ClogError, FsyncPolicy}; + +/// Header size in bytes: a `u32` length followed by a `u32` CRC32, both LE. +const HEADER_LEN: usize = 8; + +/// A handle to the open WAL log file, ready to append further batches. +// Not yet driven by production code: the actor (a later task) owns one and +// appends each committed batch before applying it to the engine. Exercised +// directly by this module's tests in the meantime. +#[allow(dead_code)] +pub(crate) struct Wal { + file: File, + fsync: FsyncPolicy, +} + +impl Wal { + /// Appends `batch` as one `[len][crc32][payload]` frame, fsyncing per + /// `self`'s policy afterwards (`OnCommit` calls `sync_data`; `Never` + /// does not sync). + // Not yet called from production code: the actor (a later task) appends + // every committed batch. Exercised directly by this module's tests. + #[allow(dead_code)] + pub(crate) fn append(&mut self, batch: &Batch) -> Result<(), ClogError> { + let payload = postcard::to_allocvec(batch) + .map_err(|e| ClogError::Corrupt { detail: format!("wal encode: {e}") })?; + let len = u32::try_from(payload.len()) + .map_err(|_| ClogError::Corrupt { detail: "wal record too large".to_string() })?; + let crc = crc32fast::hash(&payload); + + let mut frame = Vec::with_capacity(HEADER_LEN + payload.len()); + frame.extend_from_slice(&len.to_le_bytes()); + frame.extend_from_slice(&crc.to_le_bytes()); + frame.extend_from_slice(&payload); + + self.file.write_all(&frame)?; + match self.fsync { + FsyncPolicy::OnCommit => self.file.sync_data()?, + FsyncPolicy::Never => {} + } + Ok(()) + } +} + +/// Opens (creating if needed) the WAL under `dir/wal/log`, replaying every +/// existing record in order. A torn or corrupt tail (short read, an +/// absurd length, a CRC mismatch, or — defensively — a postcard record +/// that fails to decode despite a matching CRC) is never applied: the +/// offending tail bytes are appended to `dir/wal/wal.corrupt` and the log +/// is truncated to the last good frame boundary before replay stops (R2). +/// Reopening afterwards is clean. +// Not yet called from production code: the actor (a later task) opens the +// WAL on startup. Exercised directly by this module's tests in the +// meantime. +#[allow(dead_code)] +pub(crate) fn open_dir(dir: &Path, fsync: FsyncPolicy) -> Result<(Wal, Vec), ClogError> { + let wal_dir = dir.join("wal"); + fs::create_dir_all(&wal_dir)?; + let log_path = wal_dir.join("log"); + + let bytes = if log_path.exists() { fs::read(&log_path)? } else { Vec::new() }; + let (batches, good_len) = replay(&bytes); + + if good_len < bytes.len() { + quarantine(&wal_dir, &bytes[good_len..])?; + // Reopen for truncation: `append(true)` below would otherwise race + // a second writable handle against this one. + let trunc = OpenOptions::new().write(true).open(&log_path)?; + trunc.set_len(good_len as u64)?; + } + + let file = OpenOptions::new().create(true).append(true).open(&log_path)?; + Ok((Wal { file, fsync }, batches)) +} + +/// Replays length-prefixed, CRC32-checked frames from `bytes` in order, +/// stopping at the first torn or corrupt frame. Returns the decoded +/// batches and the byte offset one past the last good frame (`bytes.len()` +/// if every frame replayed cleanly). +fn replay(bytes: &[u8]) -> (Vec, usize) { + let mut batches = Vec::new(); + let mut offset = 0usize; + + while offset < bytes.len() { + if offset + HEADER_LEN > bytes.len() { + break; // torn header: not enough bytes for len+crc + } + let len_bytes: [u8; 4] = match bytes[offset..offset + 4].try_into() { + Ok(b) => b, + Err(_) => break, + }; + let crc_bytes: [u8; 4] = match bytes[offset + 4..offset + 8].try_into() { + Ok(b) => b, + Err(_) => break, + }; + let len = u32::from_le_bytes(len_bytes) as usize; + let crc = u32::from_le_bytes(crc_bytes); + + let payload_start = offset + HEADER_LEN; + let remaining = bytes.len() - payload_start; + if len > remaining { + break; // absurd length or torn payload + } + let payload = &bytes[payload_start..payload_start + len]; + if crc32fast::hash(payload) != crc { + break; // corrupt record: never applied + } + match postcard::from_bytes::(payload) { + Ok(batch) => { + batches.push(batch); + offset = payload_start + len; + } + // Defensive: a CRC-valid record that still fails to decode is + // treated the same as a corrupt tail. Should not happen. + Err(_) => break, + } + } + + (batches, offset) +} + +/// Appends `tail` to `wal_dir/wal.corrupt`, creating the file if needed. +fn quarantine(wal_dir: &Path, tail: &[u8]) -> Result<(), ClogError> { + let mut f = OpenOptions::new().create(true).append(true).open(wal_dir.join("wal.corrupt"))?; + f.write_all(tail)?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::engine::{Batch, Event}; + + fn batch(rev: u64) -> Batch { + Batch { rev, events: vec![Event::Tick { epoch: rev }] } + } + + #[test] + fn self_review_edge_cases() { + // empty file: replay finds nothing, no corruption. + let dir = tempfile::tempdir().unwrap(); + let (_, replayed) = open_dir(dir.path(), crate::FsyncPolicy::OnCommit).unwrap(); + assert!(replayed.is_empty()); + assert!(!dir.path().join("wal").join("wal.corrupt").exists()); + + // exactly an 8-byte header with no payload following: torn header + // is impossible here (8 bytes *is* a full header), but a payload + // of len=0 whose CRC matches empty bytes should still fail to + // decode as a Batch and be treated as corrupt (defensive path). + let log = dir.path().join("wal").join("log"); + let crc = crc32fast::hash(&[]); + let mut frame = Vec::new(); + frame.extend_from_slice(&0u32.to_le_bytes()); + frame.extend_from_slice(&crc.to_le_bytes()); + std::fs::write(&log, &frame).unwrap(); + assert_eq!(frame.len(), HEADER_LEN); + let (_, replayed) = open_dir(dir.path(), crate::FsyncPolicy::OnCommit).unwrap(); + assert!(replayed.is_empty()); + assert!(dir.path().join("wal").join("wal.corrupt").exists()); + assert_eq!(std::fs::metadata(&log).unwrap().len(), 0); + } + + #[test] + fn append_and_replay_round_trip() { + let dir = tempfile::tempdir().unwrap(); + { + let (mut w, replayed) = open_dir(dir.path(), crate::FsyncPolicy::OnCommit).unwrap(); + assert!(replayed.is_empty()); + w.append(&batch(1)).unwrap(); + w.append(&batch(2)).unwrap(); + } + let (_, replayed) = open_dir(dir.path(), crate::FsyncPolicy::OnCommit).unwrap(); + assert_eq!(replayed.iter().map(|b| b.rev).collect::>(), vec![1, 2]); + } + + #[test] + fn r2_torn_tail_truncated_and_quarantined() { + let dir = tempfile::tempdir().unwrap(); + { + let (mut w, _) = open_dir(dir.path(), crate::FsyncPolicy::OnCommit).unwrap(); + w.append(&batch(1)).unwrap(); + w.append(&batch(2)).unwrap(); + } + // tear the last record: chop 3 bytes off the file + let log = dir.path().join("wal").join("log"); + let len = std::fs::metadata(&log).unwrap().len(); + let f = std::fs::OpenOptions::new().write(true).open(&log).unwrap(); + f.set_len(len - 3).unwrap(); + let (_, replayed) = open_dir(dir.path(), crate::FsyncPolicy::OnCommit).unwrap(); + assert_eq!(replayed.iter().map(|b| b.rev).collect::>(), vec![1]); + assert!(dir.path().join("wal").join("wal.corrupt").exists()); + // reopening again is clean (tail already truncated) + let (_, replayed) = open_dir(dir.path(), crate::FsyncPolicy::OnCommit).unwrap(); + assert_eq!(replayed.len(), 1); + } + + #[test] + fn r2_corrupt_crc_never_applied_never_panics() { + let dir = tempfile::tempdir().unwrap(); + { + let (mut w, _) = open_dir(dir.path(), crate::FsyncPolicy::OnCommit).unwrap(); + w.append(&batch(1)).unwrap(); + w.append(&batch(2)).unwrap(); + } + let log = dir.path().join("wal").join("log"); + // flip a byte in the last record's payload + let mut bytes = std::fs::read(&log).unwrap(); + let n = bytes.len(); + bytes[n - 1] ^= 0xFF; + std::fs::write(&log, &bytes).unwrap(); + let (_, replayed) = open_dir(dir.path(), crate::FsyncPolicy::OnCommit).unwrap(); + assert_eq!(replayed.iter().map(|b| b.rev).collect::>(), vec![1]); + } +} From e42dc05ea2ec420079a459d85d9b02ab92ac386e Mon Sep 17 00:00:00 2001 From: hhff Date: Sat, 15 Aug 2026 19:44:32 -0700 Subject: [PATCH 19/28] feat(clog): actor, write path, WAL-backed open, situation reads (INV-1/3/4/5/9) Co-Authored-By: Claude Fable 5 --- examples/clog/README.md | 47 +- examples/clog/src/actor.rs | 621 +++++++++++++++++++++++++++ examples/clog/src/clock.rs | 111 +++++ examples/clog/src/engine/mod.rs | 19 +- examples/clog/src/engine/naive.rs | 13 - examples/clog/src/kinds.rs | 9 - examples/clog/src/lib.rs | 226 +++++++++- examples/clog/src/render/mod.rs | 12 - examples/clog/src/render/template.rs | 16 - examples/clog/src/render/time.rs | 4 - examples/clog/src/validate.rs | 10 - examples/clog/src/wal.rs | 21 +- examples/clog/tests/api.rs | 190 ++++++++ 13 files changed, 1207 insertions(+), 92 deletions(-) create mode 100644 examples/clog/src/actor.rs create mode 100644 examples/clog/src/clock.rs create mode 100644 examples/clog/tests/api.rs diff --git a/examples/clog/README.md b/examples/clog/README.md index 86c6154..3914ca1 100644 --- a/examples/clog/README.md +++ b/examples/clog/README.md @@ -4,8 +4,51 @@ Orientation engine for agentic systems, built on the BogKit workspace. Spec: `../../docs/clog-spec-v1.md`. Build design: `../../docs/superpowers/specs/2026-08-15-clog-build-design.md`. -Status: P1 in progress (pure core + naive engine + WAL). +Status: P1 in progress (pure core + naive engine + WAL + the writer actor). ## Public API (implemented so far) -(none yet) +```rust +pub struct Clog; // Clone + Send + Sync + +impl Clog { + pub fn open(cfg: Config) -> Result; + + // WRITE — one committed batch per call, at most one rev bump + pub fn observe(&self, claims: Vec, opts: ObserveOpts) -> Result; + pub fn retract(&self, claim_key: &str) -> Result; + + // READ — published snapshot only (INV-1) + pub fn situation(&self, scope: Option<&str>, template: Option<&str>) + -> Result; + + // TEST CLOCK — Manual mode only (INV-10) + pub fn advance(&self, ms: u64) -> Result<(), ClogError>; +} +``` + +Plus the data types in `types.rs` (`Claim`, `Config`, `Focus`, `Situation`, +`Ack`, `ObserveOpts`, `ClogError`, …), all serde round-trippable (INV-12). + +Still to land in P1: `select`, `revoke_observer`, `merge_entities`. + +## How it fits together + +- One writer thread owns the engine and the WAL. Every write is a command on + a bounded channel; the caller blocks for the `Ack`, so backpressure is just + a blocking send. +- **WAL append (and fsync) always precedes engine apply.** A crash can lose + the tail of the log; it can never leave the engine ahead of it. +- The WAL records *effects*, not intentions: a batch carries the caller's + events plus the classifier's derived `Judge` events, exactly as applied. + Replay is pure event application — the classifier never runs on replay — so + the same log bytes always rebuild the same world. +- After each batch the writer publishes an immutable `WorldSnapshot` through + `ArcSwap`. Reads never touch the writer. +- Dropping the last `Clog` handle shuts the writer down: it finishes the + in-flight batch, fsyncs and exits before `drop` returns. + +## Docs + +`cargo doc -p clog --open` for the rustdoc; the module docs carry the +spec-section references. diff --git a/examples/clog/src/actor.rs b/examples/clog/src/actor.rs new file mode 100644 index 0000000..cd59c8a --- /dev/null +++ b/examples/clog/src/actor.rs @@ -0,0 +1,621 @@ +//! The writer actor: the single thread that owns the engine and the WAL +//! (spec §5.1, §6.1; build design §4). +//! +//! Every write call in the public API becomes exactly one command on a +//! bounded channel and blocks on a one-shot reply, so writes are totally +//! ordered and backpressure is just a blocking send. Readers never touch +//! this thread: after each batch the writer publishes an immutable +//! [`WorldSnapshot`] through [`ArcSwap`], and `situation` (and, from Task +//! 15, `select`) read that snapshot and nothing else (INV-1). +//! +//! **Write-ahead ordering is mandatory** (§6.3): the WAL append (and its +//! fsync) completes *before* `engine.apply`, so a crash can only ever lose +//! the tail of the log, never leave the engine ahead of it (R1). +//! +//! **The WAL records effects, not intentions** (build design §4): a +//! committed batch carries the caller's events plus the classifier's +//! derived `Judge` events, exactly as applied. Replay is pure event +//! application — the classifier never runs on replay — so the same WAL +//! bytes always rebuild the same world (INV-10). + +use std::collections::BTreeMap; +use std::sync::Arc; +use std::thread::JoinHandle; + +use arc_swap::ArcSwap; +use crossbeam_channel::{Receiver, Sender, bounded}; +use imbl::OrdMap; + +use crate::clock::Clock; +use crate::engine::naive::{NaiveCfg, NaiveEngine, entity_state}; +use crate::engine::{Batch, Engine, Event, StoredClaim, WorldViews}; +use crate::kinds::{self, RuleSet}; +use crate::render::template::{DEFAULT_TEMPLATE, Template, parse}; +use crate::render::{ChangeItem, EntityItem, LoopItem, SlotInputs, UrgentItem, headline, render}; +use crate::types::{Ack, Claim, ClogError, Config, Focus, ObserveOpts, Rev, Situation}; +use crate::validate::{validate_claim, validate_focus}; +use crate::wal::{self, Wal}; + +/// The scope every instance always has (build design §9): a uniform focus, +/// used whenever `situation(None, ..)` is called. +pub(crate) const DEFAULT_SCOPE: &str = "default"; + +// ---- commands ------------------------------------------------------------- + +/// One unit of work for the writer thread. +pub(crate) enum Cmd { + /// A write that commits (at most) one batch and replies with an `Ack`. + Write(WriteReq), + /// Move the manual clock, replying once the new time is published. + Advance(u64, Sender<()>), + /// Stop the loop: flush the WAL and return so the handle can join. + Shutdown, +} + +/// A write command plus the one-shot channel its `Ack` goes back on. +pub(crate) struct WriteReq { + /// What to write. + pub op: WriteOp, + /// Where the result goes. + pub reply: Sender>, +} + +/// The write operations the public API exposes in P1. +pub(crate) enum WriteOp { + /// `Clog::observe`. + Observe { + /// The claims to record, in caller order. + claims: Vec, + /// Per-call options (currently just `return_situation`). + opts: ObserveOpts, + }, + /// `Clog::retract`. + Retract { + /// The key to retract. + claim_key: String, + }, +} + +// ---- published state ------------------------------------------------------ + +/// Everything a scope's rendered document is made of, kept alongside the +/// document itself so a custom-template read is pure string assembly (INV-1: +/// reads never compute views). +/// +/// `membership` is the `urgent ∪ open_loops` key set **as of the last +/// render**, mapped to the headline each key had then. It is what the +/// `changes` slot diffs against; the headlines are stored (rather than +/// looked up later) because a removed claim is, by definition, no longer +/// live to look up. +#[derive(Clone)] +pub(crate) struct SituationState { + /// The rendered document. + pub situation: Situation, + /// The slot inputs it was rendered from. + pub inputs: SlotInputs, + /// `claim_key -> headline` for everything the document listed. + pub membership: OrdMap, +} + +/// The immutable world as of one committed batch, published atomically. +/// +/// Every field is part of the published contract (spec §6.1), but P1's only +/// reader is `situation`, which needs `situations` alone: a document already +/// carries its own rev and as_of. The rest is read by `select` (Task 15) and +/// by wake evaluation (P2); it is published now so readers never have to ask +/// the writer a question. +pub(crate) struct WorldSnapshot { + /// The global rev this snapshot reflects. + #[allow(dead_code)] + pub rev: Rev, + /// The clock reading this snapshot was published at. + #[allow(dead_code)] + pub as_of: u64, + /// The engine's materialized views. + // Read by `select` (Task 15); the situation path reads `situations`. + #[allow(dead_code)] + pub views: WorldViews, + /// The scopes in force, `"default"` always present. + // Read by `set_focus`/`select` (Tasks 15+). + #[allow(dead_code)] + pub scopes: BTreeMap, + /// The rendered document per scope. + pub situations: BTreeMap, +} + +// ---- crash injection ------------------------------------------------------ + +/// Aborts the process immediately after the WAL append of batch `rev`, if +/// `CLOG_CRASH_AFTER_WAL` names that rev. +/// +/// Compiled only under `--features test-crash`; the R1 recovery harness +/// (Task 18) re-execs itself as a child that dies here, then reopens and +/// asserts the replayed world matches. +#[cfg(feature = "test-crash")] +fn maybe_crash_after_wal(rev: Rev) { + if let Ok(n) = std::env::var("CLOG_CRASH_AFTER_WAL") + && n.parse::() == Ok(rev) + { + std::process::abort(); + } +} + +// ---- startup -------------------------------------------------------------- + +/// The handles `Clog` keeps after the writer thread is running. +pub(crate) struct Spawned { + /// The command channel. + pub tx: Sender, + /// The published snapshot cell. + pub snapshot: Arc>, + /// The writer thread, joined on shutdown. + pub join: JoinHandle<()>, + /// The clock, shared with the writer. + pub clock: Clock, + /// The render budget, needed by custom-template reads. + pub budget_chars: usize, +} + +/// Opens the WAL, rebuilds the world from it, publishes the first snapshot +/// and starts the writer thread. +/// +/// Everything that can fail happens on the caller's thread, so `Clog::open` +/// reports it: bad regexes in the taxonomy, an invalid `Focus`, an +/// unreadable WAL directory. Replay applies each batch's events straight to +/// the engine (no validation, no classifier — the events *are* the effects) +/// and re-renders after each batch exactly as a live commit does, so scope +/// documents and the global rev come back reproducibly (INV-9). +/// +/// `Config::rebuild_on_open` is accepted and, in P1, changes nothing: there +/// is no engine-state cache to drop yet (build design §5 defers snapshot +/// files to M5), so every open is already a full WAL rebuild. +pub(crate) fn spawn(cfg: Config) -> Result { + let clock = Clock::new(cfg.tick.mode); + let scopes = resolve_scopes(&cfg)?; + let rules = kinds::compile(&cfg.kinds)?; + let template = parse(DEFAULT_TEMPLATE)?; + let (wal, batches) = wal::open_dir(&cfg.path, cfg.wal_fsync)?; + + let engine = NaiveEngine::new(NaiveCfg { + loop_kinds: cfg.loop_kinds.clone(), + top_k: cfg.top_k, + buckets_per_half_life: cfg.decay_buckets_per_half_life, + belief_floor: cfg.belief_min_credibility, + }); + let snapshot = Arc::new(ArcSwap::from_pointee(WorldSnapshot { + rev: 0, + as_of: 0, + views: WorldViews::default(), + scopes: scopes.clone(), + situations: BTreeMap::new(), + })); + + let mut writer = Writer { + engine, + wal, + rules, + clock: clock.clone(), + template, + budget_chars: cfg.budget_chars, + scopes, + rev: 0, + situations: BTreeMap::new(), + snapshot: Arc::clone(&snapshot), + }; + writer.rebuild(&batches); + + let (tx, rx) = bounded(cfg.write_queue.max(1)); + let join = std::thread::Builder::new() + .name("clog-writer".to_string()) + .spawn(move || run(writer, rx))?; + + Ok(Spawned { tx, snapshot, join, clock, budget_chars: cfg.budget_chars }) +} + +/// The configured scopes with `"default"` injected if the host did not +/// declare it (build design §9), every focus validated against the +/// taxonomy (§10) before the instance is allowed to open. +fn resolve_scopes(cfg: &Config) -> Result, ClogError> { + let mut scopes = cfg.scopes.clone(); + scopes.entry(DEFAULT_SCOPE.to_string()).or_insert_with(Focus::uniform); + for focus in scopes.values() { + validate_focus(focus, &cfg.kinds)?; + } + Ok(scopes) +} + +/// The writer thread's loop: one command at a time, one `match`, no shared +/// mutable state anywhere else in the crate. +fn run(mut writer: Writer, rx: Receiver) { + while let Ok(cmd) = rx.recv() { + match cmd { + Cmd::Write(req) => { + let result = writer.write(req.op); + // A caller that hung up between sending and replying is not + // an error: the batch is already committed and durable. + let _ = req.reply.send(result); + } + Cmd::Advance(ms, reply) => { + writer.advance(ms); + let _ = reply.send(()); + } + Cmd::Shutdown => break, + } + } + writer.shutdown(); +} + +// ---- the writer ----------------------------------------------------------- + +/// The writer thread's state. Owned by one thread; never shared. +struct Writer { + engine: NaiveEngine, + wal: Wal, + rules: RuleSet, + clock: Clock, + template: Template, + budget_chars: usize, + scopes: BTreeMap, + rev: Rev, + situations: BTreeMap, + snapshot: Arc>, +} + +impl Writer { + /// Rebuilds the world from the replayed WAL, then publishes it. + /// + /// The rev-0 render happens *before* replay so that a reopened instance + /// walks exactly the same render sequence a fresh one did: empty world + /// at rev 0, then one render per batch. Every replay render uses a + /// single clock reading (P1 persists no per-batch clock), so `as_of` + /// values are as-of-open rather than as-of-original-commit; the rendered + /// content and the global rev are reproduced exactly. + fn rebuild(&mut self, batches: &[Batch]) { + let now = self.clock.now_ms(); + self.engine.apply(&[], &self.scopes, now); + self.render_all(now); + for batch in batches { + self.rev = batch.rev; + self.engine.apply(&batch.events, &self.scopes, now); + self.render_all(now); + } + self.publish(now); + } + + /// Dispatches one write command. + fn write(&mut self, op: WriteOp) -> Result { + match op { + WriteOp::Observe { claims, opts } => self.observe(claims, opts), + WriteOp::Retract { claim_key } => self.retract(claim_key), + } + } + + /// `Clog::observe`: steps 1-4 of the write path, then [`Self::commit`]. + fn observe(&mut self, claims: Vec, opts: ObserveOpts) -> Result { + // 1. Validate the whole batch before anything else. The first + // failure rejects every claim in it (§10, atomic batches). + for (index, claim) in claims.iter().enumerate() { + validate_claim(index, claim, false)?; + } + // An unknown `return_situation` scope is a malformed request, so it + // is rejected here rather than after committing: a caller that gets + // an `Err` back must be able to assume nothing was written. + if let Some(scope) = &opts.return_situation + && !self.situations.contains_key(scope) + { + return Err(ClogError::UnknownScope); + } + // 2. One clock read for the whole batch. + let now = self.clock.now_ms(); + // 3 + 4. + let events = self.expand(&claims, now); + self.commit(events, opts.return_situation.as_deref(), now) + } + + /// `Clog::retract`. An unknown (or already retracted) key commits + /// nothing at all — no batch, no rev bump, no WAL record. + /// + /// Reserved `clog:*` keys *are* retractable here: retracting the merge + /// claim is how a merge is undone (spec §4, §5.2). + fn retract(&mut self, claim_key: String) -> Result { + if !self.engine.views().claims.contains_key(&claim_key) { + return Err(ClogError::UnknownClaim); + } + let now = self.clock.now_ms(); + self.commit(vec![Event::Retract { claim_key }], None, now) + } + + /// Write-path step 3 (upsert expansion) and step 4 (rules tier). + /// + /// Expansion compares each claim against the version that is live *at + /// that point in the batch*: an identical claim is skipped entirely + /// (INV-5 — no rev bump, no WAL record, invisible), a different one + /// becomes `Retract(old)` + `Observe(new)` (INV-4). The comparison is + /// full structural equality of the `Claim`; `recorded_at` is not part + /// of a claim, so a re-send with a later arrival time is still a + /// duplicate. + /// + /// The rules tier then classifies everything the batch actually + /// observes and appends the resulting `Judge` events *after* all claim + /// events, so a judgment never precedes the claim it judges. + fn expand(&self, claims: &[Claim], now: u64) -> Vec { + let mut events = Vec::new(); + // What each key holds so far *within this batch*, so a batch that + // names the same key twice behaves like two consecutive batches. + let mut pending: BTreeMap<&str, &Claim> = BTreeMap::new(); + + for claim in claims { + let live = pending + .get(claim.claim_key.as_str()) + .copied() + .or_else(|| self.engine.views().claims.get(&claim.claim_key).map(|sc| &sc.claim)); + match live { + Some(old) if old == claim => continue, + Some(_) => events.push(Event::Retract { claim_key: claim.claim_key.clone() }), + None => {} + } + events.push(Event::Observe(StoredClaim { claim: claim.clone(), recorded_at: now })); + pending.insert(claim.claim_key.as_str(), claim); + } + + let judgments: Vec = events + .iter() + .filter_map(|event| { + let Event::Observe(stored) = event else { return None }; + let label = kinds::classify(&self.rules, &stored.claim)?; + Some(Event::Judge { + claim_key: stored.claim.claim_key.clone(), + kind: label.kind, + confidence: label.confidence, + source: label.source, + }) + }) + .collect(); + events.extend(judgments); + events + } + + /// Write-path steps 5-9: commit one batch, or nothing. + /// + /// An empty event list is *not* a batch: it takes no rev, writes no WAL + /// record and re-renders nothing, so a wholly duplicate `observe` is + /// invisible to every reader (INV-5). + fn commit(&mut self, events: Vec, want: Option<&str>, now: u64) -> Result { + // 5. Nothing to do. + if events.is_empty() { + return Ok(self.ack(want)); + } + // 6. WAL first, engine second — always (§6.3, R1). `rev` advances + // only once the record is durable, so a failed append leaves the + // world exactly where it was. + let batch = Batch { rev: self.rev + 1, events }; + self.wal.append(&batch)?; + #[cfg(feature = "test-crash")] + maybe_crash_after_wal(batch.rev); + self.rev = batch.rev; + self.engine.apply(&batch.events, &self.scopes, now); + // 7 + 8. + self.render_all(now); + self.publish(now); + // 9. + Ok(self.ack(want)) + } + + /// Moves the manual clock (spec §5.5 without the tick driver). + /// + /// P1 emits no `Tick` events, so this commits no batch and takes no rev + /// — an immaterial clock move must never bump a rev (INV-5, B4). It + /// does re-score and re-render: the clock is a material input to both + /// recency decay and the header's `as_of`, and readers only ever see + /// the published snapshot, so leaving it stale would report a time that + /// has passed. + fn advance(&mut self, ms: u64) { + let now = self.clock.advance(ms); + self.engine.apply(&[], &self.scopes, now); + self.render_all(now); + self.publish(now); + } + + /// Flushes the WAL on the way out (spec §6.1: clean shutdown fsyncs). + fn shutdown(&mut self) { + let _ = self.wal.sync(); + } + + /// The reply for a completed (or skipped) write. `want` names the scope + /// whose document to return; it was checked against the live scope set + /// before the batch was assembled. + fn ack(&self, want: Option<&str>) -> Ack { + Ack { + rev: self.rev, + situation: want.and_then(|scope| self.situations.get(scope)).map(|s| s.situation.clone()), + } + } + + /// Write-path step 7: recompute and re-render every scope. + /// + /// P1 recomputes all slot inputs from the views wholesale rather than + /// tracking which scopes a batch dirtied — the naive engine is the + /// auditable oracle (build design §5), and targeted re-render is a P2 + /// optimization that must reproduce this result exactly. + fn render_all(&mut self, now: u64) { + let scopes: Vec = self.scopes.keys().cloned().collect(); + for scope in scopes { + self.render_scope(&scope, now); + } + } + + /// Renders one scope's default-template document, replacing the stored + /// one **only if the text actually changed** — that is what keeps + /// `Situation.rev` meaning "the rev at which this scope's text last + /// changed" (§5.10) instead of just tracking the global rev. + fn render_scope(&mut self, scope: &str, now: u64) { + let mut inputs = slot_inputs(self.engine.views(), scope, self.rev, now); + let membership: OrdMap = inputs + .urgent + .iter() + .map(|u| (u.claim_key.clone(), u.headline.clone())) + .chain(inputs.open_loops.iter().map(|l| (l.claim_key.clone(), l.headline.clone()))) + .collect(); + + let previous = self.situations.get(scope); + inputs.changes = changes_since(previous.map(|s| &s.membership), &membership); + let text = render(&self.template, &inputs, self.budget_chars); + if previous.is_some_and(|s| s.situation.text == text) { + return; + } + + self.situations.insert( + scope.to_string(), + SituationState { + situation: Situation { scope: scope.to_string(), text, rev: self.rev, as_of: now }, + inputs, + membership, + }, + ); + } + + /// Write-path step 8: publish the new snapshot. This is the moment the + /// batch becomes visible to readers — everything before it is invisible + /// and everything after it is committed (INV-1). + fn publish(&self, as_of: u64) { + self.snapshot.store(Arc::new(WorldSnapshot { + rev: self.rev, + as_of, + views: self.engine.views().clone(), + scopes: self.scopes.clone(), + situations: self.situations.clone(), + })); + } +} + +// ---- slot assembly -------------------------------------------------------- + +/// Builds one scope's slot inputs from the materialized views (§5.7, §5.8). +/// +/// `changes` is left empty: only the writer knows the previous render's +/// membership, so it fills that slot in [`Writer::render_scope`]. +fn slot_inputs(views: &WorldViews, scope: &str, rev: Rev, as_of_ms: u64) -> SlotInputs { + let urgent = views + .urgent + .get(scope) + .into_iter() + .flatten() + .filter_map(|(score, key)| { + let stored = views.claims.get(key)?; + Some(UrgentItem { + score: *score, + headline: headline(&stored.claim.body), + reliability: stored.claim.reliability.letter(), + credibility: stored.claim.credibility.digit(), + claim_key: key.clone(), + }) + }) + .collect(); + + // `open_loops` is an `OrdSet`, so this is claim_key order. + let open_loops = views + .open_loops + .iter() + .filter_map(|key| { + let stored = views.claims.get(key)?; + let label = views.kinds.get(key)?; + Some(LoopItem { + kind: label.kind.clone(), + headline: headline(&stored.claim.body), + claim_key: key.clone(), + }) + }) + .collect(); + + // Entity rows are already (entity-key asc, then believed claims + // newest-first). An entity nobody believes anything about contributes + // no summaries, and a bare "Name: " line says nothing, so it is dropped + // rather than rendered empty. + let entities = entity_state(views) + .into_iter() + .filter(|(_, _, rows)| !rows.is_empty()) + .map(|(_, display, rows)| EntityItem { + display, + summaries: rows.iter().map(|(_, stored)| headline(&stored.claim.body)).collect(), + }) + .collect(); + + SlotInputs { scope: scope.to_string(), rev, as_of_ms, urgent, open_loops, entities, changes: Vec::new() } +} + +/// The `changes` slot: the membership delta between the last two rendered +/// revs of a scope (§5.7). +/// +/// Additions first, then removals, each in `claim_key` order (both maps are +/// ordered, so iteration gives that for free). A key present in both renders +/// produces nothing even if its headline changed: this slot tracks +/// membership, not content. Removed items take their headline from the +/// *previous* render, because a removed claim is no longer live to read. +fn changes_since(previous: Option<&OrdMap>, current: &OrdMap) -> Vec { + let empty = OrdMap::new(); + let previous = previous.unwrap_or(&empty); + let mut changes: Vec = current + .iter() + .filter(|(key, _)| !previous.contains_key(*key)) + .map(|(_, headline)| ChangeItem::Added(headline.clone())) + .collect(); + changes.extend( + previous + .iter() + .filter(|(key, _)| !current.contains_key(*key)) + .map(|(_, headline)| ChangeItem::Removed(headline.clone())), + ); + changes +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::{ClockMode, KindTaxonomy, TickConfig}; + + fn manual_cfg(dir: &std::path::Path) -> Config { + let mut cfg = Config::default_for(dir); + cfg.tick = TickConfig { mode: ClockMode::Manual, interval_ms: 60_000 }; + cfg + } + + #[test] + fn default_scope_injected_and_focus_validated() { + let dir = tempfile::tempdir().unwrap(); + let scopes = resolve_scopes(&manual_cfg(dir.path())).unwrap(); + assert_eq!(scopes.keys().collect::>(), vec![DEFAULT_SCOPE]); + + let mut cfg = manual_cfg(dir.path()); + cfg.scopes.insert("bad".into(), Focus::uniform().weight("no-such-kind", 2.0)); + assert!(matches!(resolve_scopes(&cfg), Err(ClogError::UnknownKind))); + } + + #[test] + fn bad_taxonomy_regex_fails_open() { + let dir = tempfile::tempdir().unwrap(); + let mut cfg = manual_cfg(dir.path()); + cfg.kinds = KindTaxonomy::default_taxonomy(); + cfg.kinds.kinds[0].rules.push(crate::types::Rule { + any_of: vec![crate::types::Matcher::BodyRegex("(".into())], + }); + assert!(matches!(spawn(cfg), Err(ClogError::Corrupt { .. }))); + } + + #[test] + fn changes_are_adds_then_removes_each_key_ordered() { + let previous: OrdMap = + [("b".to_string(), "bee".to_string()), ("c".to_string(), "cee".to_string())].into_iter().collect(); + let current: OrdMap = + [("a".to_string(), "ay".to_string()), ("c".to_string(), "cee2".to_string())].into_iter().collect(); + let rendered: Vec = changes_since(Some(&previous), ¤t) + .iter() + .map(|c| match c { + ChangeItem::Added(h) => format!("+{h}"), + ChangeItem::Removed(h) => format!("-{h}"), + }) + .collect(); + // "c" is in both: a changed headline is not a membership change. + assert_eq!(rendered, vec!["+ay".to_string(), "-bee".to_string()]); + assert!(changes_since(None, ¤t).len() == 2); + } +} diff --git a/examples/clog/src/clock.rs b/examples/clog/src/clock.rs new file mode 100644 index 0000000..d5a798d --- /dev/null +++ b/examples/clog/src/clock.rs @@ -0,0 +1,111 @@ +//! The clock port (spec §5.5): the **only** wall-clock read in the crate. +//! +//! Every timestamp clog assigns — `recorded_at`, `Situation.as_of`, the +//! `now_ms` handed to scoring — comes from here, so a `Manual` clock makes +//! the whole engine a pure function of its inputs (INV-10). Tests always +//! run `Manual`; hosts default to `System`. + +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use crate::types::ClockMode; + +/// clog's clock: either the system clock or a manually driven counter. +/// +/// Cloning a `Manual` clock shares the same counter, so the handle and the +/// writer thread always agree on the current time. +#[derive(Clone)] +pub(crate) enum Clock { + /// Reads `SystemTime::now()`. + System, + /// Reads a shared counter advanced only by `Clog::advance`. + Manual(Arc), +} + +impl Clock { + /// Builds the clock a `ClockMode` asks for. A `Manual` clock starts at 0. + pub(crate) fn new(mode: ClockMode) -> Clock { + match mode { + ClockMode::System => Clock::System, + ClockMode::Manual => Clock::Manual(Arc::new(AtomicU64::new(0))), + } + } + + /// The current time in milliseconds since the Unix epoch. + /// + /// A `System` clock reading before the epoch (only possible with a + /// grossly misconfigured host clock) reads as 0 rather than panicking: + /// clog never lets a clock read fail a write. + pub(crate) fn now_ms(&self) -> u64 { + match self { + Clock::System => SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0), + Clock::Manual(counter) => counter.load(Ordering::SeqCst), + } + } + + /// Advances a `Manual` clock by `ms`, returning the new reading. + /// Saturating: a manual clock never wraps back into the past. + /// + /// A no-op on a `System` clock — `Clog::advance` rejects that case with + /// `ManualClockRequired` before ever reaching here. + pub(crate) fn advance(&self, ms: u64) -> u64 { + match self { + Clock::System => self.now_ms(), + Clock::Manual(counter) => { + // `fetch_update` rather than `fetch_add` so the saturation is + // atomic too: two concurrent advances can never wrap. + counter + .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |now| Some(now.saturating_add(ms))) + .unwrap_or(0) + .saturating_add(ms) + } + } + } + + /// Whether this is a `Manual` clock. + pub(crate) fn is_manual(&self) -> bool { + matches!(self, Clock::Manual(_)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn manual_starts_at_zero_and_advances() { + let c = Clock::new(ClockMode::Manual); + assert!(c.is_manual()); + assert_eq!(c.now_ms(), 0); + assert_eq!(c.advance(1_000), 1_000); + assert_eq!(c.now_ms(), 1_000); + assert_eq!(c.advance(500), 1_500); + } + + #[test] + fn manual_advance_saturates() { + let c = Clock::new(ClockMode::Manual); + c.advance(u64::MAX); + assert_eq!(c.advance(10), u64::MAX); + } + + #[test] + fn manual_clone_shares_the_counter() { + let a = Clock::new(ClockMode::Manual); + let b = a.clone(); + a.advance(42); + assert_eq!(b.now_ms(), 42); + } + + #[test] + fn system_clock_reads_wall_time() { + let c = Clock::new(ClockMode::System); + assert!(!c.is_manual()); + // 2020-01-01 in ms; any sane host clock is past this. + assert!(c.now_ms() > 1_577_836_800_000); + } +} diff --git a/examples/clog/src/engine/mod.rs b/examples/clog/src/engine/mod.rs index b8e83b0..607dc95 100644 --- a/examples/clog/src/engine/mod.rs +++ b/examples/clog/src/engine/mod.rs @@ -15,7 +15,7 @@ use serde::{Deserialize, Serialize}; use crate::alias::{AliasMap, EntityKey}; use crate::types::{Claim, Focus, JudgeSource, KindLabel, ObserverId, Rev}; -mod naive; +pub(crate) mod naive; /// A claim as stored by the engine, alongside when clog recorded it. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -68,9 +68,6 @@ pub(crate) enum Event { } /// A batch of events committed together at a single revision. -// Not yet constructed by production code: the WAL (a later task) writes and -// reads these. Exercised directly by this module's tests in the meantime. -#[allow(dead_code)] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub(crate) struct Batch { /// The revision this batch was committed at. @@ -87,10 +84,6 @@ pub(crate) struct Batch { /// an all-floored group: every candidate fell below the belief threshold). /// `urgent` vectors are sorted score-desc, tie `claim_key`-asc, truncated to /// the scope's `top_k`. -// Populated by the naive engine; most fields are not yet *read* by -// production code (select/render consume them in later tasks). Exercised -// directly by this module's and `naive`'s tests in the meantime. -#[allow(dead_code)] #[derive(Clone, Default)] pub(crate) struct WorldViews { /// All live claims, keyed by `claim_key`, including reserved `clog:*` ones. @@ -125,8 +118,11 @@ pub(crate) struct WorldViews { /// auditable oracle; see the build design §5); `touched=false` short-circuits /// when a batch applied zero effective events. Richer per-view diffs arrive /// in P2 when wakes need them. -// `touched` is not yet read by production code: the actor (a later task) -// short-circuits re-render on it. Exercised by `naive`'s tests meanwhile. +// `touched` is not read by production code yet: P1's actor re-renders every +// scope after every committed batch (and a batch with no effective events is +// never committed at all, so `touched` would always be true there). The +// short-circuit earns its keep in P2, when a tick can apply zero effective +// events. Exercised by `naive`'s tests meanwhile. #[allow(dead_code)] pub(crate) struct ApplyResult { /// Whether the batch had any observable effect on `WorldViews`. @@ -135,9 +131,6 @@ pub(crate) struct ApplyResult { /// The engine contract: applies WAL events to the materialized views and /// exposes the current snapshot for reading. -// Not yet called from production code: the actor (a later task) drives the -// naive engine through this trait. Exercised by `naive`'s tests meanwhile. -#[allow(dead_code)] pub(crate) trait Engine: Send { /// Applies `events` to the materialized views, using `scopes` to /// recompute per-scope derived state (e.g. `urgent`) and `now_ms` as the diff --git a/examples/clog/src/engine/naive.rs b/examples/clog/src/engine/naive.rs index bc9a490..f643d93 100644 --- a/examples/clog/src/engine/naive.rs +++ b/examples/clog/src/engine/naive.rs @@ -90,9 +90,6 @@ fn index_remove(idx: &mut OrdMap>, k: &K, clai /// The knobs the naive engine needs from `Config`, extracted so the engine /// stays independent of the (larger, host-facing) `Config` type. -// Not yet constructed by production code: the actor (a later task) builds -// this from `Config`. Exercised directly by this module's tests meanwhile. -#[allow(dead_code)] pub(crate) struct NaiveCfg { /// Kinds treated as open loops. pub loop_kinds: Vec, @@ -105,9 +102,6 @@ pub(crate) struct NaiveCfg { } /// The reference engine: applies events to [`WorldViews`] the obvious way. -// Not yet constructed by production code: the actor (a later task) owns one. -// Exercised directly by this module's tests in the meantime. -#[allow(dead_code)] pub(crate) struct NaiveEngine { views: WorldViews, cfg: NaiveCfg, @@ -115,10 +109,6 @@ pub(crate) struct NaiveEngine { impl NaiveEngine { /// An engine over an empty world. - // Not yet called from production code: the actor (a later task) - // constructs one. Exercised by this module's tests in the meantime; - // every other method here is reachable through the `Engine` impl. - #[allow(dead_code)] pub(crate) fn new(cfg: NaiveCfg) -> Self { NaiveEngine { views: WorldViews::default(), cfg } } @@ -429,9 +419,6 @@ pub(crate) type EntityStateRow = (EntityKey, String, Vec<(String, StoredClaim)>) /// /// Rows are newest-first by `occurred_at`, ties broken by `claim_key` ascending /// (the same tiebreak `urgent` uses), then capped at [`ENTITY_STATE_ROWS`]. -// Not yet called from production code: the renderer's `entities` slot (a -// later task) consumes this. Exercised by this module's tests meanwhile. -#[allow(dead_code)] pub(crate) fn entity_state(views: &WorldViews) -> Vec { let mut out = Vec::new(); for (entity, keys) in views.by_entity.iter() { diff --git a/examples/clog/src/kinds.rs b/examples/clog/src/kinds.rs index 8e68a40..f09f3ff 100644 --- a/examples/clog/src/kinds.rs +++ b/examples/clog/src/kinds.rs @@ -51,9 +51,6 @@ impl CompiledRule { /// Holds `(kind_name, rules)` pairs in config order, with every /// `Matcher::BodyRegex` already compiled so `classify` never re-parses a /// regex on the hot path. -// Not yet consumed by production code: wired into the cascade classifier by -// a later task. Exercised directly by this module's tests in the meantime. -#[allow(dead_code)] pub(crate) struct RuleSet { kinds: Vec<(String, Vec)>, } @@ -69,9 +66,6 @@ pub(crate) struct RuleSet { /// variant for "state clog was handed is unusable" and config counts as /// state clog must trust. Revisit in P3 if a dedicated `InvalidConfig` /// variant earns its place. -// Not yet consumed by production code: wired into the cascade classifier by -// a later task. Exercised directly by this module's tests in the meantime. -#[allow(dead_code)] pub(crate) fn compile(tax: &KindTaxonomy) -> Result { fn compile_matcher(m: &Matcher) -> Result { Ok(match m { @@ -109,9 +103,6 @@ pub(crate) fn compile(tax: &KindTaxonomy) -> Result { /// /// A rule matches when any of its matchers match (§5.6 `any_of`). Matches /// are always confidence `1.0` from `JudgeSource::Rule`. -// Not yet consumed by production code: wired into the cascade classifier by -// a later task. Exercised directly by this module's tests in the meantime. -#[allow(dead_code)] pub(crate) fn classify(rs: &RuleSet, c: &Claim) -> Option { for (kind, rules) in &rs.kinds { for rule in rules { diff --git a/examples/clog/src/lib.rs b/examples/clog/src/lib.rs index bdab32b..33ea5cb 100644 --- a/examples/clog/src/lib.rs +++ b/examples/clog/src/lib.rs @@ -6,6 +6,17 @@ #![deny(missing_docs)] #![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used))] +use std::sync::Arc; +use std::thread::JoinHandle; + +use arc_swap::ArcSwap; +use crossbeam_channel::{Sender, bounded}; + +use crate::actor::{Cmd, DEFAULT_SCOPE, WorldSnapshot, WriteOp, WriteReq}; +use crate::clock::Clock; +use crate::render::render; +use crate::render::template::parse; + /// Public API types: the serde-only contract every later task builds on. pub mod types; pub use types::*; @@ -27,8 +38,8 @@ pub(crate) mod alias; /// cascade classifier (spec §5.6). pub(crate) mod kinds; -/// Deterministic rendering: template parsing, RFC3339 timestamps, and (in -/// a later task) the slot renderer itself (spec §5.8). +/// Deterministic rendering: template parsing, RFC3339 timestamps, and the +/// slot renderer itself (spec §5.8). pub(crate) mod render; /// Engine contract: the WAL wire format, the materialized-view snapshot, @@ -38,3 +49,214 @@ pub(crate) mod engine; /// The append-only write-ahead log: clog's source of truth (spec §6.3, /// INV-11, recovery test R2). pub(crate) mod wal; + +/// The clock port: the only wall-clock read in the crate (spec §5.5). +pub(crate) mod clock; + +/// The writer actor: the single thread owning the engine and the WAL, +/// and the immutable snapshot readers see (spec §5.1, §6.1). +pub(crate) mod actor; + +/// A handle to a running clog instance. +/// +/// Cheap to clone (`Clone + Send + Sync`): every clone talks to the same +/// writer thread and reads the same published snapshot. Writes are totally +/// ordered — each call commits at most one batch and takes at most one rev +/// — while reads never touch the writer at all, returning whatever snapshot +/// was current when they were called (INV-1). +/// +/// Dropping the last handle shuts the instance down: the writer finishes +/// any in-flight batch, fsyncs the WAL and exits before `drop` returns. +/// +/// ```no_run +/// use clog::*; +/// +/// let handle = Clog::open(Config::default_for("/var/lib/my-agent/clog"))?; +/// handle.observe( +/// vec![Claim { +/// claim_key: "halcyon:inv-1042".into(), +/// subject_key: Some("halcyon:inv-1042:status".into()), +/// source_ref: "gmail:msg/123".into(), +/// observer: ObserverId::from("gmail-v3"), +/// schema_v: 1, +/// occurred_at: 1_786_752_000_000, +/// observed_at: 1_786_752_000_000, +/// reliability: Reliability::B, +/// credibility: Credibility::Two, +/// entities: vec![], +/// body: "Invoice 1042 is 30 days overdue".into(), +/// }], +/// ObserveOpts::default(), +/// )?; +/// println!("{}", handle.situation(None, None)?.text); +/// # Ok::<(), ClogError>(()) +/// ``` +#[derive(Clone)] +pub struct Clog { + inner: Arc, +} + +/// The shared guts of a `Clog`, and the shutdown latch: the writer thread is +/// joined when the last handle drops this. +struct Inner { + tx: Sender, + snapshot: Arc>, + clock: Clock, + budget_chars: usize, + writer: Option>, +} + +impl Drop for Inner { + fn drop(&mut self) { + // A send error means the writer already died; either way the join + // below is what guarantees the WAL is flushed before we return. + let _ = self.tx.send(Cmd::Shutdown); + if let Some(writer) = self.writer.take() { + let _ = writer.join(); + } + } +} + +impl Clog { + /// Opens (creating if needed) the instance rooted at `cfg.path`. + /// + /// Replays the write-ahead log into a fresh engine, rebuilds every + /// scope's situation document batch by batch so revisions come back + /// exactly as they were written (INV-9), publishes the first snapshot + /// and starts the writer thread. The `"default"` scope is injected if + /// the config does not declare it. + /// + /// # Errors + /// + /// - `ClogError::Storage` if the WAL directory cannot be created, read + /// or opened; + /// - `ClogError::Corrupt` if a classification rule's regex fails to + /// compile; + /// - `ClogError::UnknownKind` / `ClogError::InvalidFilter` if a + /// configured `Focus` is invalid (spec §10). + /// + /// A torn or corrupt WAL tail is *not* an error: it is quarantined and + /// truncated, and the surviving prefix is replayed (recovery test R2). + pub fn open(cfg: Config) -> Result { + let spawned = actor::spawn(cfg)?; + Ok(Clog { + inner: Arc::new(Inner { + tx: spawned.tx, + snapshot: spawned.snapshot, + clock: spawned.clock, + budget_chars: spawned.budget_chars, + writer: Some(spawned.join), + }), + }) + } + + /// Records a batch of claims, returning the revision it committed at. + /// + /// The batch is atomic: if any claim fails validation (spec §10) the + /// whole call is rejected and nothing is written. Re-observing a key + /// with an identical claim is invisible — no revision, no log record + /// (INV-5); re-observing it with any difference supersedes the previous + /// version (INV-4). Claims that a configured rule matches are + /// classified as part of the same batch. + /// + /// A batch in which *every* claim was a duplicate commits nothing and + /// returns the current revision, so `Ack.rev` is unchanged from the + /// previous call. + /// + /// # Errors + /// + /// - `ClogError::InvalidClaim` / `ClogError::ReservedNamespace` if any + /// claim is invalid or uses the reserved `clog:` namespace; + /// - `ClogError::UnknownScope` if `opts.return_situation` names a scope + /// that does not exist (nothing is written in that case); + /// - `ClogError::Storage` / `ClogError::Corrupt` if the log append + /// fails; + /// - `ClogError::ShuttingDown` if the instance is stopping. + pub fn observe(&self, claims: Vec, opts: ObserveOpts) -> Result { + self.write(WriteOp::Observe { claims, opts }) + } + + /// Retracts a claim, healing every view that mentioned it (INV-3). + /// + /// Retracting the reserved claim a merge wrote un-merges those entities + /// (spec §5.2). + /// + /// # Errors + /// + /// - `ClogError::UnknownClaim` if the key is not live — including a key + /// that was already retracted. Nothing is written in that case, so + /// idempotent hosts can ignore the error; + /// - `ClogError::Storage` / `ClogError::Corrupt` if the log append + /// fails; + /// - `ClogError::ShuttingDown` if the instance is stopping. + pub fn retract(&self, claim_key: &str) -> Result { + self.write(WriteOp::Retract { claim_key: claim_key.to_string() }) + } + + /// Reads a scope's situation document from the current snapshot. + /// + /// `scope` defaults to `"default"`. `template` defaults to the built-in + /// template (spec §5.8); passing one renders the *same* materialized + /// slot inputs through it — a read never recomputes a view, so two + /// reads at the same revision always agree (INV-1). Templates are + /// per-call: clog stores none. + /// + /// The returned `rev` is the revision at which this scope's text last + /// changed, which may lag the global revision — that skew is meaningful + /// (spec §5.10): it says the writes in between did not affect this + /// scope. + /// + /// # Errors + /// + /// - `ClogError::UnknownScope` if `scope` names a scope that does not + /// exist; + /// - `ClogError::TemplateError` if `template` does not parse. That + /// rejects the call only; nothing is written and no stored document + /// is affected. + pub fn situation(&self, scope: Option<&str>, template: Option<&str>) -> Result { + let snapshot = self.inner.snapshot.load(); + let scope = scope.unwrap_or(DEFAULT_SCOPE); + let state = snapshot.situations.get(scope).ok_or(ClogError::UnknownScope)?; + match template { + None => Ok(state.situation.clone()), + Some(source) => { + let template = parse(source)?; + let text = render(&template, &state.inputs, self.inner.budget_chars); + Ok(Situation { text, ..state.situation.clone() }) + } + } + } + + /// Moves the manual clock forward by `ms` milliseconds. + /// + /// This is the test handle for time (INV-10): with `ClockMode::Manual` + /// nothing else advances the clock, so recency decay, `recorded_at` and + /// every rendered `as_of` are entirely under the caller's control. + /// + /// P1 emits no tick events, so advancing commits no batch and takes no + /// revision. It does re-score and re-publish, so the situation you read + /// afterwards reflects the new time — including a freshly computed + /// `changes` slot, which is always the delta since the previous render. + /// + /// # Errors + /// + /// - `ClogError::ManualClockRequired` if the instance was configured + /// with `ClockMode::System`; + /// - `ClogError::ShuttingDown` if the instance is stopping. + pub fn advance(&self, ms: u64) -> Result<(), ClogError> { + if !self.inner.clock.is_manual() { + return Err(ClogError::ManualClockRequired); + } + let (reply, done) = bounded(1); + self.inner.tx.send(Cmd::Advance(ms, reply)).map_err(|_| ClogError::ShuttingDown)?; + done.recv().map_err(|_| ClogError::ShuttingDown) + } + + /// Sends one write to the writer thread and blocks for its reply. A + /// full queue blocks here: backpressure is the point (spec §6.1). + fn write(&self, op: WriteOp) -> Result { + let (reply, ack) = bounded(1); + self.inner.tx.send(Cmd::Write(WriteReq { op, reply })).map_err(|_| ClogError::ShuttingDown)?; + ack.recv().map_err(|_| ClogError::ShuttingDown)? + } +} diff --git a/examples/clog/src/render/mod.rs b/examples/clog/src/render/mod.rs index 4399233..1004261 100644 --- a/examples/clog/src/render/mod.rs +++ b/examples/clog/src/render/mod.rs @@ -16,19 +16,12 @@ use time::rfc3339_utc; /// Splits `body` on any whitespace run (`split_whitespace`) and rejoins /// with single spaces, then truncates to the first 120 **chars** (not /// bytes — a multi-byte char is never split). -// Consumed by the engine tasks (11-15) that build the `*Item` structs -// below, not by `render` itself: `render` treats each item's `headline` -// field as already-normalized text. -#[allow(dead_code)] pub(crate) fn headline(body: &str) -> String { let collapsed = body.split_whitespace().collect::>().join(" "); collapsed.chars().take(120).collect() } /// A ranked urgent item (spec §5.8, `%{urgent}` slot). -// Consumed by the engine tasks (11-15) that assemble `SlotInputs`; not yet -// constructed by production code in this task. -#[allow(dead_code)] #[derive(Clone, Debug)] pub(crate) struct UrgentItem { /// Urgency score, rendered to one decimal place. @@ -44,7 +37,6 @@ pub(crate) struct UrgentItem { } /// An open-loop item (spec §5.8, `%{open_loops}` slot). -#[allow(dead_code)] #[derive(Clone, Debug)] pub(crate) struct LoopItem { /// The open-loop kind (e.g. `"question"`), rendered uppercased. @@ -56,7 +48,6 @@ pub(crate) struct LoopItem { } /// An entity summary item (spec §5.8, `%{entities}` slot). -#[allow(dead_code)] #[derive(Clone, Debug)] pub(crate) struct EntityItem { /// The entity's display name. @@ -66,7 +57,6 @@ pub(crate) struct EntityItem { } /// A change since the last brief (spec §5.8, `%{changes}` slot). -#[allow(dead_code)] #[derive(Clone, Debug)] pub(crate) enum ChangeItem { /// A newly surfaced headline, rendered `+ {headline}`. @@ -79,7 +69,6 @@ pub(crate) enum ChangeItem { /// /// This is the engine-to-renderer contract: Tasks 11-15 build the /// `Vec<*Item>` fields from the live views. -#[allow(dead_code)] #[derive(Clone, Debug)] pub(crate) struct SlotInputs { /// The scope this document is rendered for. @@ -166,7 +155,6 @@ fn configured_limit(t: &Template, name: SlotName) -> Option { /// the document fits, or nothing is left to drop. Truncation never cuts /// mid-item; each truncated slot gets (or updates) a trailing /// `… ({n} more)` marker with its total hidden count. -#[allow(dead_code)] pub(crate) fn render(t: &Template, inputs: &SlotInputs, budget_chars: usize) -> String { let urgent_lines: Vec = inputs .urgent diff --git a/examples/clog/src/render/template.rs b/examples/clog/src/render/template.rs index 71e5bb9..cc5464e 100644 --- a/examples/clog/src/render/template.rs +++ b/examples/clog/src/render/template.rs @@ -15,9 +15,6 @@ use crate::types::ClogError; /// A slot name in the template grammar (spec §5.8). -// Not yet consumed by production code: the renderer body lands in the -// next task. Exercised directly by this module's tests in the meantime. -#[allow(dead_code)] #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum SlotName { /// `%{header}` — `{scope} · rev {rev} · {as_of RFC3339}`. @@ -33,9 +30,6 @@ pub(crate) enum SlotName { } /// One parsed unit of a template: literal text, or a slot to be rendered. -// Not yet consumed by production code: the renderer body lands in the -// next task. Exercised directly by this module's tests in the meantime. -#[allow(dead_code)] #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum Segment { /// Literal text, emitted verbatim. @@ -50,19 +44,12 @@ pub(crate) enum Segment { } /// A parsed template: an ordered sequence of segments. -// Not yet consumed by production code: the renderer body lands in the -// next task. Exercised directly by this module's tests in the meantime. -#[allow(dead_code)] #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct Template(pub Vec); /// The default template (spec §5.8), used when `template == None`. Frozen /// byte-for-byte: the §11.4 golden tests (U-TMPL-3) depend on this exact /// string, including its trailing newline. -// Not yet consumed by production code: wired in as the renderer's default -// by a later task. Exercised directly by this module's tests in the -// meantime. -#[allow(dead_code)] pub(crate) const DEFAULT_TEMPLATE: &str = "\ # situation · scope: %{header} @@ -94,9 +81,6 @@ pub(crate) const DEFAULT_TEMPLATE: &str = "\ /// fails to parse as a `usize` (including an empty value). Never panics /// on any input — malformed byte sequences produce an `Err`, not a panic /// (a fuzz target covers this in a later milestone). -// Not yet consumed by production code: the renderer body lands in the -// next task. Exercised directly by this module's tests in the meantime. -#[allow(dead_code)] pub(crate) fn parse(src: &str) -> Result { let mut segments = Vec::new(); let mut rest = src; diff --git a/examples/clog/src/render/time.rs b/examples/clog/src/render/time.rs index c53f652..554bda2 100644 --- a/examples/clog/src/render/time.rs +++ b/examples/clog/src/render/time.rs @@ -9,10 +9,6 @@ /// crate) to go from a day count to a calendar date; this is exact for /// every `u64` millisecond value representable here (no leap seconds are /// modeled, matching Unix time). -// Not yet consumed by production code: used by the renderer's `header` -// slot in a later task. Exercised directly by this module's tests in the -// meantime. -#[allow(dead_code)] pub(crate) fn rfc3339_utc(ms: u64) -> String { let secs = ms / 1000; let (days, rem) = (secs / 86_400, secs % 86_400); diff --git a/examples/clog/src/validate.rs b/examples/clog/src/validate.rs index 5da7d34..21616d4 100644 --- a/examples/clog/src/validate.rs +++ b/examples/clog/src/validate.rs @@ -35,9 +35,6 @@ pub(crate) fn tests_base_claim() -> Claim { /// `ClogError::InvalidClaim` for host-side error reporting. `allow_reserved` /// is set only by internal writers (e.g. merge claims), which are permitted /// to use the `clog:` namespace reserved from hosts by INV-8. -// Not yet called from production code: wired into `Clog::observe` by a -// later task. Exercised directly by this module's tests in the meantime. -#[allow(dead_code)] pub(crate) fn validate_claim(index: usize, c: &Claim, allow_reserved: bool) -> Result<(), ClogError> { fn has_control(s: &str) -> bool { s.chars().any(|ch| ch.is_control()) @@ -135,10 +132,6 @@ pub(crate) fn validate_claim(index: usize, c: &Claim, allow_reserved: bool) -> R /// Every weight key must name a defined kind; weight values and boost /// factors must be finite and strictly positive; `half_life_days` must lie /// in the open interval `(0.01, 3650)`. -// Not yet called from production code: wired into `Clog::focus`/`observe` -// by a later task. Exercised directly by this module's tests in the -// meantime. -#[allow(dead_code)] pub(crate) fn validate_focus(f: &Focus, taxonomy: &KindTaxonomy) -> Result<(), ClogError> { fn valid_factor(v: f32) -> bool { v.is_finite() && v > 0.0 @@ -169,9 +162,6 @@ pub(crate) fn validate_focus(f: &Focus, taxonomy: &KindTaxonomy) -> Result<(), C /// Clamps a timestamp for scoring purposes only (§10): values more than 24h /// beyond `now` are clamped to `now`. Storage always keeps the verbatim /// value; only scoring consumes this clamped result. -// Not yet called from production code: consumed by `score.rs` (Task 4). -// Exercised directly by this module's tests in the meantime. -#[allow(dead_code)] pub(crate) fn scoring_clamp(ts: u64, now: u64) -> u64 { if ts > now + 86_400_000 { now diff --git a/examples/clog/src/wal.rs b/examples/clog/src/wal.rs index 3cb7120..06b2141 100644 --- a/examples/clog/src/wal.rs +++ b/examples/clog/src/wal.rs @@ -19,10 +19,6 @@ use crate::types::{ClogError, FsyncPolicy}; const HEADER_LEN: usize = 8; /// A handle to the open WAL log file, ready to append further batches. -// Not yet driven by production code: the actor (a later task) owns one and -// appends each committed batch before applying it to the engine. Exercised -// directly by this module's tests in the meantime. -#[allow(dead_code)] pub(crate) struct Wal { file: File, fsync: FsyncPolicy, @@ -32,9 +28,6 @@ impl Wal { /// Appends `batch` as one `[len][crc32][payload]` frame, fsyncing per /// `self`'s policy afterwards (`OnCommit` calls `sync_data`; `Never` /// does not sync). - // Not yet called from production code: the actor (a later task) appends - // every committed batch. Exercised directly by this module's tests. - #[allow(dead_code)] pub(crate) fn append(&mut self, batch: &Batch) -> Result<(), ClogError> { let payload = postcard::to_allocvec(batch) .map_err(|e| ClogError::Corrupt { detail: format!("wal encode: {e}") })?; @@ -54,6 +47,16 @@ impl Wal { } Ok(()) } + + /// Fsyncs the log unconditionally, whatever the policy says. + /// + /// Called once by the writer thread on clean shutdown (spec §6.1), so + /// that `FsyncPolicy::Never` still means "no fsync *per commit*" rather + /// than "no fsync ever". + pub(crate) fn sync(&mut self) -> Result<(), ClogError> { + self.file.sync_data()?; + Ok(()) + } } /// Opens (creating if needed) the WAL under `dir/wal/log`, replaying every @@ -63,10 +66,6 @@ impl Wal { /// offending tail bytes are appended to `dir/wal/wal.corrupt` and the log /// is truncated to the last good frame boundary before replay stops (R2). /// Reopening afterwards is clean. -// Not yet called from production code: the actor (a later task) opens the -// WAL on startup. Exercised directly by this module's tests in the -// meantime. -#[allow(dead_code)] pub(crate) fn open_dir(dir: &Path, fsync: FsyncPolicy) -> Result<(Wal, Vec), ClogError> { let wal_dir = dir.join("wal"); fs::create_dir_all(&wal_dir)?; diff --git a/examples/clog/tests/api.rs b/examples/clog/tests/api.rs new file mode 100644 index 0000000..38cf5fa --- /dev/null +++ b/examples/clog/tests/api.rs @@ -0,0 +1,190 @@ +//! Public-API integration tests for the `Clog` handle (Task 14). +//! +//! Everything here goes through the public surface only: no crate internals, +//! no test-only back doors. The clock is always `Manual` (INV-10). + +use clog::*; + +fn cfg(dir: &std::path::Path) -> Config { + let mut c = Config::default_for(dir); + c.tick = TickConfig { mode: ClockMode::Manual, interval_ms: 60_000 }; + c +} + +fn claim(key: &str, body: &str, occ: u64) -> Claim { + Claim { + claim_key: key.into(), + subject_key: None, + source_ref: "t:1".into(), + observer: ObserverId::from("test"), + schema_v: 1, + occurred_at: occ, + observed_at: occ, + reliability: Reliability::B, + credibility: Credibility::Two, + entities: vec![], + body: body.into(), + } +} + +/// Normalizes a situation's text for equality comparisons that are about +/// *world state*, not about counters or the render-diff log. +/// +/// Two things are stripped: +/// 1. the `rev {n}` marker in the header — INV-3 is about content, not revs; +/// 2. the whole `## changes since last brief` section (the default +/// template's last slot) — the controller's INV-3 ruling: the spec +/// self-conflicts, since INV-3 demands post-retraction text identical to +/// an empty world while §5.7 defines `changes` as the membership delta +/// between the last two rendered revs, so a retraction legitimately +/// echoes there exactly once. INV-3 text equality excludes that slot. +fn norm(s: &Situation) -> String { + let mut t = s.text.replace(&format!("rev {}", s.rev), "rev _"); + if let Some(i) = t.find("\n## changes since last brief\n") { + t.truncate(i); + } + t +} + +/// The handle is a cheap-clone, thread-safe port (spec §6.1). +#[test] +fn clog_handle_is_clone_send_sync() { + fn is() {} + is::(); +} + +#[test] +fn observe_bumps_rev_and_renders() { + let dir = tempfile::tempdir().unwrap(); + let c = Clog::open(cfg(dir.path())).unwrap(); + c.advance(1_000_000).unwrap(); + let ack = c.observe(vec![claim("a", "first thing", 500_000)], ObserveOpts::default()).unwrap(); + assert_eq!(ack.rev, 1); + let s = c.situation(None, None).unwrap(); + assert_eq!(s.scope, "default"); + assert_eq!(s.rev, 1); + assert!(s.text.contains("first thing")); +} + +#[test] +fn inv5_duplicate_observe_is_invisible() { + let dir = tempfile::tempdir().unwrap(); + let c = Clog::open(cfg(dir.path())).unwrap(); + c.advance(1_000_000).unwrap(); + let a1 = c.observe(vec![claim("a", "x", 500_000)], ObserveOpts::default()).unwrap(); + let s1 = c.situation(None, None).unwrap(); + let a2 = c.observe(vec![claim("a", "x", 500_000)], ObserveOpts::default()).unwrap(); + let s2 = c.situation(None, None).unwrap(); + assert_eq!(a2.rev, a1.rev, "duplicate batch must not commit"); + assert_eq!(s1.rev, s2.rev); + assert_eq!(s1.text, s2.text); +} + +#[test] +fn inv4_upsert_supersedes() { + let dir = tempfile::tempdir().unwrap(); + let c = Clog::open(cfg(dir.path())).unwrap(); + c.advance(1_000_000).unwrap(); + c.observe(vec![claim("a", "old body", 500_000)], ObserveOpts::default()).unwrap(); + c.observe(vec![claim("a", "new body", 600_000)], ObserveOpts::default()).unwrap(); + let s = c.situation(None, None).unwrap(); + assert!(s.text.contains("new body")); + assert!(!s.text.contains("old body")); +} + +#[test] +fn inv3_retraction_heals_text() { + let dir = tempfile::tempdir().unwrap(); + let c = Clog::open(cfg(dir.path())).unwrap(); + c.advance(1_000_000).unwrap(); + let empty = c.situation(None, None).unwrap(); + c.observe(vec![claim("a", "temp", 500_000)], ObserveOpts::default()).unwrap(); + c.retract("a").unwrap(); + let healed = c.situation(None, None).unwrap(); + assert_eq!(norm(&healed), norm(&empty)); + assert!(matches!(c.retract("a"), Err(ClogError::UnknownClaim))); +} + +#[test] +fn inv9_rev_survives_reopen() { + let dir = tempfile::tempdir().unwrap(); + { + let c = Clog::open(cfg(dir.path())).unwrap(); + c.advance(1_000_000).unwrap(); + c.observe(vec![claim("a", "x", 500_000)], ObserveOpts::default()).unwrap(); + c.observe(vec![claim("b", "y", 500_000)], ObserveOpts::default()).unwrap(); + } // drop -> clean shutdown + let c = Clog::open(cfg(dir.path())).unwrap(); + c.advance(2_000_000).unwrap(); + let ack = c.observe(vec![claim("c", "z", 500_000)], ObserveOpts::default()).unwrap(); + assert_eq!(ack.rev, 3); + let s = c.situation(None, None).unwrap(); + assert!(s.text.contains('x') && s.text.contains('z')); +} + +#[test] +fn reserved_namespace_rejected_and_batch_atomic() { + let dir = tempfile::tempdir().unwrap(); + let c = Clog::open(cfg(dir.path())).unwrap(); + c.advance(1_000_000).unwrap(); + let r = c.observe( + vec![claim("ok", "fine", 500_000), claim("clog:sneaky", "no", 500_000)], + ObserveOpts::default(), + ); + assert!(r.is_err()); + // atomic: the valid claim must not have landed either + assert!(!c.situation(None, None).unwrap().text.contains("fine")); +} + +#[test] +fn rules_tier_classifies_at_commit() { + let dir = tempfile::tempdir().unwrap(); + let mut config = cfg(dir.path()); + for kd in &mut config.kinds.kinds { + if kd.name == "risk" { + kd.rules.push(clog::Rule { any_of: vec![clog::Matcher::BodyContains("overdue".into())] }); + } + } + let c = Clog::open(config).unwrap(); + c.advance(1_000_000).unwrap(); + c.observe(vec![claim("inv", "invoice 1042 is overdue", 500_000)], ObserveOpts::default()).unwrap(); + let s = c.situation(None, None).unwrap(); + assert!(s.text.contains("- RISK invoice 1042"), "open loops slot should show it:\n{}", s.text); +} + +#[test] +fn custom_template_and_errors() { + let dir = tempfile::tempdir().unwrap(); + let c = Clog::open(cfg(dir.path())).unwrap(); + c.advance(1_000_000).unwrap(); + c.observe(vec![claim("a", "hello world", 500_000)], ObserveOpts::default()).unwrap(); + let s = c.situation(None, Some("URGENT ONLY\n%{urgent limit=1}")).unwrap(); + assert!(s.text.starts_with("URGENT ONLY\n1."), "{}", s.text); + assert!(matches!(c.situation(None, Some("%{bogus}")), Err(ClogError::TemplateError(_)))); + assert!(matches!(c.situation(Some("nope"), None), Err(ClogError::UnknownScope))); +} + +// Beyond the brief: nothing above renders the `entities` slot, and it is the +// one slot whose inputs come from belief resolution rather than a flat view. +#[test] +fn entities_slot_shows_believed_summaries_newest_first() { + let dir = tempfile::tempdir().unwrap(); + let c = Clog::open(cfg(dir.path())).unwrap(); + c.advance(1_000_000).unwrap(); + let entity = EntityRef { etype: "project".into(), id: "halcyon".into(), name: Some("Halcyon".into()) }; + let mut old = claim("old", "invoice 1042 overdue", 100_000); + old.subject_key = Some("halcyon:inv-1042:status".into()); + old.entities = vec![entity.clone()]; + let mut new = claim("new", "invoice 1042 paid", 200_000); + new.subject_key = Some("halcyon:inv-1042:status".into()); + new.entities = vec![entity.clone()]; + let mut other = claim("kick", "kickoff moved to may", 150_000); + other.subject_key = Some("halcyon:kickoff".into()); + other.entities = vec![entity]; + c.observe(vec![old, new, other], ObserveOpts::default()).unwrap(); + + let text = c.situation(None, None).unwrap().text; + // display name from the registry; only believed claims; newest occurred_at first + assert!(text.contains("Halcyon: invoice 1042 paid; kickoff moved to may"), "{text}"); + assert!(!text.contains("Halcyon: invoice 1042 overdue"), "{text}"); +} From 2e0260c73bfdd19c12dcf0b7587dbb6dc50a5218 Mon Sep 17 00:00:00 2001 From: hhff Date: Sat, 15 Aug 2026 20:00:48 -0700 Subject: [PATCH 20/28] fix(clog): fresh slot inputs, surviving-claim judgments, batch as_of, masked change detection Co-Authored-By: Claude Fable 5 --- examples/clog/README.md | 10 ++- examples/clog/src/actor.rs | 147 ++++++++++++++++++++------------ examples/clog/src/engine/mod.rs | 10 +++ examples/clog/src/lib.rs | 22 +++-- examples/clog/src/wal.rs | 68 +++++++++++++-- examples/clog/tests/api.rs | 103 ++++++++++++++++++++-- 6 files changed, 285 insertions(+), 75 deletions(-) diff --git a/examples/clog/README.md b/examples/clog/README.md index 3914ca1..ea0914a 100644 --- a/examples/clog/README.md +++ b/examples/clog/README.md @@ -40,9 +40,13 @@ Still to land in P1: `select`, `revoke_observer`, `merge_entities`. - **WAL append (and fsync) always precedes engine apply.** A crash can lose the tail of the log; it can never leave the engine ahead of it. - The WAL records *effects*, not intentions: a batch carries the caller's - events plus the classifier's derived `Judge` events, exactly as applied. - Replay is pure event application — the classifier never runs on replay — so - the same log bytes always rebuild the same world. + events plus the classifier's derived `Judge` events, exactly as applied, + plus the clock reading it was committed at. Replay is pure event + application — the classifier never runs on replay — so the same log bytes + always rebuild the same world, down to byte-identical situation text. +- A scope's `rev` and `as_of` move only when its text *materially* changes; + the rev skew against the global rev is the signal that the writes in + between did not affect that scope. - After each batch the writer publishes an immutable `WorldSnapshot` through `ArcSwap`. Reads never touch the writer. - Dropping the last `Clog` handle shuts the writer down: it finishes the diff --git a/examples/clog/src/actor.rs b/examples/clog/src/actor.rs index cd59c8a..3d2e033 100644 --- a/examples/clog/src/actor.rs +++ b/examples/clog/src/actor.rs @@ -31,6 +31,7 @@ use crate::engine::naive::{NaiveCfg, NaiveEngine, entity_state}; use crate::engine::{Batch, Engine, Event, StoredClaim, WorldViews}; use crate::kinds::{self, RuleSet}; use crate::render::template::{DEFAULT_TEMPLATE, Template, parse}; +use crate::render::time::rfc3339_utc; use crate::render::{ChangeItem, EntityItem, LoopItem, SlotInputs, UrgentItem, headline, render}; use crate::types::{Ack, Claim, ClogError, Config, Focus, ObserveOpts, Rev, Situation}; use crate::validate::{validate_claim, validate_focus}; @@ -82,11 +83,16 @@ pub(crate) enum WriteOp { /// document itself so a custom-template read is pure string assembly (INV-1: /// reads never compute views). /// -/// `membership` is the `urgent ∪ open_loops` key set **as of the last -/// render**, mapped to the headline each key had then. It is what the -/// `changes` slot diffs against; the headlines are stored (rather than -/// looked up later) because a removed claim is, by definition, no longer -/// live to look up. +/// `inputs` and `membership` are refreshed on **every** render, even when +/// the rendered text is retained unchanged: a change confined to items the +/// default template's `limit=` caps hide is invisible in `text` but must +/// still reach a custom-template read and the next `changes` diff. Only +/// `situation` (text, rev, as_of) is held back when nothing material moved. +/// +/// `membership` is the `urgent ∪ open_loops` key set as of the last render, +/// mapped to the headline each key had then. It is what the `changes` slot +/// diffs against; the headlines are stored (rather than looked up later) +/// because a removed claim is, by definition, no longer live to look up. #[derive(Clone)] pub(crate) struct SituationState { /// The rendered document. @@ -119,8 +125,10 @@ pub(crate) struct WorldSnapshot { // Read by `set_focus`/`select` (Tasks 15+). #[allow(dead_code)] pub scopes: BTreeMap, - /// The rendered document per scope. - pub situations: BTreeMap, + /// The rendered document per scope. Behind `Arc` so publishing a + /// snapshot copies one pointer per scope rather than deep-cloning every + /// document and its slot inputs. + pub situations: BTreeMap>, } // ---- crash injection ------------------------------------------------------ @@ -257,7 +265,7 @@ struct Writer { budget_chars: usize, scopes: BTreeMap, rev: Rev, - situations: BTreeMap, + situations: BTreeMap>, snapshot: Arc>, } @@ -266,20 +274,22 @@ impl Writer { /// /// The rev-0 render happens *before* replay so that a reopened instance /// walks exactly the same render sequence a fresh one did: empty world - /// at rev 0, then one render per batch. Every replay render uses a - /// single clock reading (P1 persists no per-batch clock), so `as_of` - /// values are as-of-open rather than as-of-original-commit; the rendered - /// content and the global rev are reproduced exactly. + /// at rev 0, then one render per batch. Each replayed batch re-renders + /// against **its own** recorded clock reading (`Batch.as_of`), not the + /// reopen time, so scores, headers and therefore every scope's text and + /// rev come back byte-identical to the original commit (INV-9, INV-10). fn rebuild(&mut self, batches: &[Batch]) { let now = self.clock.now_ms(); self.engine.apply(&[], &self.scopes, now); self.render_all(now); + let mut as_of = now; for batch in batches { self.rev = batch.rev; - self.engine.apply(&batch.events, &self.scopes, now); - self.render_all(now); + as_of = batch.as_of; + self.engine.apply(&batch.events, &self.scopes, as_of); + self.render_all(as_of); } - self.publish(now); + self.publish(as_of); } /// Dispatches one write command. @@ -335,9 +345,13 @@ impl Writer { /// of a claim, so a re-send with a later arrival time is still a /// duplicate. /// - /// The rules tier then classifies everything the batch actually - /// observes and appends the resulting `Judge` events *after* all claim - /// events, so a judgment never precedes the claim it judges. + /// The rules tier then classifies the **surviving** version of each key + /// — the one still live when the batch finishes — and appends the + /// resulting `Judge` events after all claim events, so a judgment never + /// precedes the claim it judges and never describes a version the batch + /// already superseded. Judging every `Observe` would durably mislabel + /// the survivor whenever a batch carries two versions of one key and + /// only the earlier one matched a rule. fn expand(&self, claims: &[Claim], now: u64) -> Vec { let mut events = Vec::new(); // What each key holds so far *within this batch*, so a batch that @@ -358,13 +372,16 @@ impl Writer { pending.insert(claim.claim_key.as_str(), claim); } - let judgments: Vec = events - .iter() - .filter_map(|event| { - let Event::Observe(stored) = event else { return None }; - let label = kinds::classify(&self.rules, &stored.claim)?; + // `pending` holds exactly the survivors: one entry per key the batch + // observes, carrying the last version of it. Iteration is + // `claim_key` order, so the event list is caller-order-independent + // and replay-deterministic (INV-11). + let judgments: Vec = pending + .values() + .filter_map(|claim| { + let label = kinds::classify(&self.rules, claim)?; Some(Event::Judge { - claim_key: stored.claim.claim_key.clone(), + claim_key: claim.claim_key.clone(), kind: label.kind, confidence: label.confidence, source: label.source, @@ -387,8 +404,9 @@ impl Writer { } // 6. WAL first, engine second — always (§6.3, R1). `rev` advances // only once the record is durable, so a failed append leaves the - // world exactly where it was. - let batch = Batch { rev: self.rev + 1, events }; + // world exactly where it was. The clock reading rides along in + // the record so replay can reproduce this batch's render. + let batch = Batch { rev: self.rev + 1, as_of: now, events }; self.wal.append(&batch)?; #[cfg(feature = "test-crash")] maybe_crash_after_wal(batch.rev); @@ -401,19 +419,17 @@ impl Writer { Ok(self.ack(want)) } - /// Moves the manual clock (spec §5.5 without the tick driver). + /// Moves the manual clock, and nothing else (spec §5.5 without the tick + /// driver). /// - /// P1 emits no `Tick` events, so this commits no batch and takes no rev - /// — an immaterial clock move must never bump a rev (INV-5, B4). It - /// does re-score and re-render: the clock is a material input to both - /// recency decay and the header's `as_of`, and readers only ever see - /// the published snapshot, so leaving it stale would report a time that - /// has passed. - fn advance(&mut self, ms: u64) { - let now = self.clock.advance(ms); - self.engine.apply(&[], &self.scopes, now); - self.render_all(now); - self.publish(now); + /// P1 emits no `Tick` events, so this commits no batch, takes no rev, + /// re-scores nothing and re-renders nothing: an immaterial clock move + /// must leave every scope exactly as it was (INV-5, B4). Time reaches + /// the views at the next committed batch, which re-scores against the + /// new reading. It still travels through the writer's queue so that it + /// is ordered against in-flight writes. + fn advance(&self, ms: u64) { + self.clock.advance(ms); } /// Flushes the WAL on the way out (spec §6.1: clean shutdown fsyncs). @@ -444,10 +460,17 @@ impl Writer { } } - /// Renders one scope's default-template document, replacing the stored - /// one **only if the text actually changed** — that is what keeps - /// `Situation.rev` meaning "the rev at which this scope's text last - /// changed" (§5.10) instead of just tracking the global rev. + /// Renders one scope's default-template document. + /// + /// The slot inputs and the membership map are always replaced — they are + /// what custom-template reads assemble from and what the next `changes` + /// diff measures against, and both must track the views even when the + /// document does not. Only the `Situation` itself (text, rev, as_of) is + /// held back, and only when nothing *material* moved: the comparison + /// masks the header's rev and timestamp, so a document that says the + /// same thing keeps the rev and as_of at which it last actually changed + /// (§5.10 — rev skew between a scope and the global rev is the signal + /// that the writes in between did not touch that scope). fn render_scope(&mut self, scope: &str, now: u64) { let mut inputs = slot_inputs(self.engine.views(), scope, self.rev, now); let membership: OrdMap = inputs @@ -460,18 +483,25 @@ impl Writer { let previous = self.situations.get(scope); inputs.changes = changes_since(previous.map(|s| &s.membership), &membership); let text = render(&self.template, &inputs, self.budget_chars); - if previous.is_some_and(|s| s.situation.text == text) { - return; - } - self.situations.insert( - scope.to_string(), - SituationState { - situation: Situation { scope: scope.to_string(), text, rev: self.rev, as_of: now }, - inputs, - membership, - }, - ); + // Unchanged in substance? Then keep the whole previous document, + // header included — a fresh timestamp on identical content would + // claim a change that did not happen. + let retained = previous + .filter(|s| { + mask_header(&s.situation.text, s.situation.rev, s.situation.as_of) + == mask_header(&text, self.rev, now) + }) + .map(|s| s.situation.clone()); + let situation = retained.unwrap_or(Situation { scope: scope.to_string(), text, rev: self.rev, as_of: now }); + + // Keep the stored inputs' header fields in step with the document + // they belong to, so a custom `%{header}` agrees with `Situation.rev`. + inputs.rev = situation.rev; + inputs.as_of_ms = situation.as_of; + + self.situations + .insert(scope.to_string(), Arc::new(SituationState { situation, inputs, membership })); } /// Write-path step 8: publish the new snapshot. This is the moment the @@ -490,6 +520,17 @@ impl Writer { // ---- slot assembly -------------------------------------------------------- +/// Blanks the rev and timestamp the `header` slot renders, so two documents +/// can be compared for *material* difference (§5.10). +/// +/// The header slot is `{scope} · rev {rev} · {as_of RFC3339}` (§5.8), and +/// the default template opens with it, so replacing the first occurrence of +/// the `rev {n} · {ts}` pair blanks exactly the header and nothing else — +/// a claim body that happened to contain the same bytes sits after it. +fn mask_header(text: &str, rev: Rev, as_of: u64) -> String { + text.replacen(&format!("rev {rev} · {}", rfc3339_utc(as_of)), "rev _ · _", 1) +} + /// Builds one scope's slot inputs from the materialized views (§5.7, §5.8). /// /// `changes` is left empty: only the writer knows the previous render's diff --git a/examples/clog/src/engine/mod.rs b/examples/clog/src/engine/mod.rs index 607dc95..684a820 100644 --- a/examples/clog/src/engine/mod.rs +++ b/examples/clog/src/engine/mod.rs @@ -72,6 +72,14 @@ pub(crate) enum Event { pub(crate) struct Batch { /// The revision this batch was committed at. pub rev: Rev, + /// The clock reading (epoch millis) the batch was committed at. + /// + /// Persisted because rendering is time-sensitive: recency decay and the + /// header's `as_of` both read the clock, so replaying a batch against + /// the *reopen* time would rebuild a different document from the same + /// events. With the reading in the record, replay re-renders every + /// batch byte-identically to the original commit (INV-9, INV-10). + pub as_of: u64, /// The events applied in this batch, in order. pub events: Vec, } @@ -149,6 +157,7 @@ mod tests { fn batch_round_trips_postcard() { let b = Batch { rev: 3, + as_of: 1_700_000_000_000, events: vec![ Event::Observe(StoredClaim { claim: tests_base_claim(), recorded_at: 9 }), Event::Retract { claim_key: "k1".into() }, @@ -159,6 +168,7 @@ mod tests { let bytes = postcard::to_allocvec(&b).unwrap(); let b2: Batch = postcard::from_bytes(&bytes).unwrap(); assert_eq!(b2.rev, 3); + assert_eq!(b2.as_of, 1_700_000_000_000); assert_eq!(b2.events.len(), 4); } } diff --git a/examples/clog/src/lib.rs b/examples/clog/src/lib.rs index 33ea5cb..125130e 100644 --- a/examples/clog/src/lib.rs +++ b/examples/clog/src/lib.rs @@ -202,9 +202,10 @@ impl Clog { /// per-call: clog stores none. /// /// The returned `rev` is the revision at which this scope's text last - /// changed, which may lag the global revision — that skew is meaningful - /// (spec §5.10): it says the writes in between did not affect this - /// scope. + /// *materially* changed, which may lag the global revision — that skew + /// is meaningful (spec §5.10): it says the writes in between did not + /// affect what this scope has to say. `as_of` moves with it, so an + /// unchanged document never carries a freshened timestamp. /// /// # Errors /// @@ -214,7 +215,10 @@ impl Clog { /// rejects the call only; nothing is written and no stored document /// is affected. pub fn situation(&self, scope: Option<&str>, template: Option<&str>) -> Result { - let snapshot = self.inner.snapshot.load(); + // `load_full` rather than `load`: parsing and rendering a custom + // template is unbounded caller-supplied work, and an `ArcSwap` guard + // must not be held across it. + let snapshot = self.inner.snapshot.load_full(); let scope = scope.unwrap_or(DEFAULT_SCOPE); let state = snapshot.situations.get(scope).ok_or(ClogError::UnknownScope)?; match template { @@ -233,10 +237,12 @@ impl Clog { /// nothing else advances the clock, so recency decay, `recorded_at` and /// every rendered `as_of` are entirely under the caller's control. /// - /// P1 emits no tick events, so advancing commits no batch and takes no - /// revision. It does re-score and re-publish, so the situation you read - /// afterwards reflects the new time — including a freshly computed - /// `changes` slot, which is always the delta since the previous render. + /// P1 emits no tick events, so advancing is pure clock movement: it + /// commits no batch, takes no revision, and re-renders nothing. An + /// immaterial clock move must leave every scope exactly as it was. The + /// new time reaches the views at the next committed batch, which + /// re-scores against it. Advancing is still ordered against in-flight + /// writes, so a write submitted before it always sees the earlier time. /// /// # Errors /// diff --git a/examples/clog/src/wal.rs b/examples/clog/src/wal.rs index 06b2141..67141eb 100644 --- a/examples/clog/src/wal.rs +++ b/examples/clog/src/wal.rs @@ -66,13 +66,21 @@ impl Wal { /// offending tail bytes are appended to `dir/wal/wal.corrupt` and the log /// is truncated to the last good frame boundary before replay stops (R2). /// Reopening afterwards is clean. +/// +/// # Errors +/// +/// A tail that is merely torn or corrupt is *not* an error (see above), but +/// a structurally impossible log is: `ClogError::Corrupt` if the replayed +/// revisions are not strictly increasing from 1. That cannot happen by +/// truncation — only by a bug or by two writers sharing one log — so it is +/// never silently repaired. pub(crate) fn open_dir(dir: &Path, fsync: FsyncPolicy) -> Result<(Wal, Vec), ClogError> { let wal_dir = dir.join("wal"); fs::create_dir_all(&wal_dir)?; let log_path = wal_dir.join("log"); let bytes = if log_path.exists() { fs::read(&log_path)? } else { Vec::new() }; - let (batches, good_len) = replay(&bytes); + let (batches, good_len) = replay(&bytes)?; if good_len < bytes.len() { quarantine(&wal_dir, &bytes[good_len..])?; @@ -83,16 +91,41 @@ pub(crate) fn open_dir(dir: &Path, fsync: FsyncPolicy) -> Result<(Wal, Vec (Vec, usize) { - let mut batches = Vec::new(); +/// +/// Revisions must be strictly increasing from 1: the writer assigns +/// `rev + 1` per committed batch and appends under an exclusive handle, so +/// a repeated or out-of-order rev means the log is not what it claims to be +/// (two writers, or a bug) rather than merely truncated. That is +/// `ClogError::Corrupt`, not a tail to quarantine. +fn replay(bytes: &[u8]) -> Result<(Vec, usize), ClogError> { + let mut batches: Vec = Vec::new(); let mut offset = 0usize; + let mut last_rev = 0u64; while offset < bytes.len() { if offset + HEADER_LEN > bytes.len() { @@ -120,6 +153,12 @@ fn replay(bytes: &[u8]) -> (Vec, usize) { } match postcard::from_bytes::(payload) { Ok(batch) => { + if batch.rev <= last_rev { + return Err(ClogError::Corrupt { + detail: format!("wal rev not strictly increasing: {} after {last_rev}", batch.rev), + }); + } + last_rev = batch.rev; batches.push(batch); offset = payload_start + len; } @@ -129,7 +168,7 @@ fn replay(bytes: &[u8]) -> (Vec, usize) { } } - (batches, offset) + Ok((batches, offset)) } /// Appends `tail` to `wal_dir/wal.corrupt`, creating the file if needed. @@ -145,7 +184,7 @@ mod tests { use crate::engine::{Batch, Event}; fn batch(rev: u64) -> Batch { - Batch { rev, events: vec![Event::Tick { epoch: rev }] } + Batch { rev, as_of: 1_000 * rev, events: vec![Event::Tick { epoch: rev }] } } #[test] @@ -207,6 +246,25 @@ mod tests { assert_eq!(replayed.len(), 1); } + #[test] + fn non_increasing_rev_is_corrupt_not_a_torn_tail() { + let dir = tempfile::tempdir().unwrap(); + { + let (mut w, _) = open_dir(dir.path(), crate::FsyncPolicy::OnCommit).unwrap(); + w.append(&batch(1)).unwrap(); + w.append(&batch(1)).unwrap(); // same rev twice: impossible for one writer + } + assert!(matches!(open_dir(dir.path(), crate::FsyncPolicy::OnCommit), Err(ClogError::Corrupt { .. }))); + + // rev 0 is likewise impossible: the first committed batch is rev 1. + let dir = tempfile::tempdir().unwrap(); + { + let (mut w, _) = open_dir(dir.path(), crate::FsyncPolicy::OnCommit).unwrap(); + w.append(&batch(0)).unwrap(); + } + assert!(matches!(open_dir(dir.path(), crate::FsyncPolicy::OnCommit), Err(ClogError::Corrupt { .. }))); + } + #[test] fn r2_corrupt_crc_never_applied_never_panics() { let dir = tempfile::tempdir().unwrap(); diff --git a/examples/clog/tests/api.rs b/examples/clog/tests/api.rs index 38cf5fa..ef7842e 100644 --- a/examples/clog/tests/api.rs +++ b/examples/clog/tests/api.rs @@ -28,10 +28,11 @@ fn claim(key: &str, body: &str, occ: u64) -> Claim { } /// Normalizes a situation's text for equality comparisons that are about -/// *world state*, not about counters or the render-diff log. +/// *world state*, not about counters, clocks or the render-diff log. /// /// Two things are stripped: -/// 1. the `rev {n}` marker in the header — INV-3 is about content, not revs; +/// 1. the entire header line — it carries the rev and the `as_of` +/// timestamp, both of which are bookkeeping rather than content; /// 2. the whole `## changes since last brief` section (the default /// template's last slot) — the controller's INV-3 ruling: the spec /// self-conflicts, since INV-3 demands post-retraction text identical to @@ -39,11 +40,11 @@ fn claim(key: &str, body: &str, occ: u64) -> Claim { /// between the last two rendered revs, so a retraction legitimately /// echoes there exactly once. INV-3 text equality excludes that slot. fn norm(s: &Situation) -> String { - let mut t = s.text.replace(&format!("rev {}", s.rev), "rev _"); - if let Some(i) = t.find("\n## changes since last brief\n") { - t.truncate(i); + let body = s.text.split_once('\n').map_or("", |(_, rest)| rest); + match body.find("\n## changes since last brief\n") { + Some(i) => body[..i].to_string(), + None => body.to_string(), } - t } /// The handle is a cheap-clone, thread-safe port (spec §6.1). @@ -122,6 +123,29 @@ fn inv9_rev_survives_reopen() { assert!(s.text.contains('x') && s.text.contains('z')); } +// Beyond the brief: INV-9 above checks the rev survives a reopen; the +// document has to survive it too, byte for byte, which is why each batch +// carries the clock reading it was committed at. +#[test] +fn reopen_reproduces_situation_text_byte_identically() { + let dir = tempfile::tempdir().unwrap(); + let before = { + let c = Clog::open(cfg(dir.path())).unwrap(); + c.advance(1_000_000).unwrap(); + c.observe(vec![claim("a", "first", 500_000)], ObserveOpts::default()).unwrap(); + // a different clock reading for the second batch: replaying both + // against one reopen-time reading would render a different header + c.advance(500_000).unwrap(); + c.observe(vec![claim("b", "second", 900_000)], ObserveOpts::default()).unwrap(); + c.situation(None, None).unwrap() + }; + let c = Clog::open(cfg(dir.path())).unwrap(); + let after = c.situation(None, None).unwrap(); + assert_eq!(after.text, before.text); + assert_eq!(after.rev, before.rev); + assert_eq!(after.as_of, before.as_of); +} + #[test] fn reserved_namespace_rejected_and_batch_atomic() { let dir = tempfile::tempdir().unwrap(); @@ -152,6 +176,34 @@ fn rules_tier_classifies_at_commit() { assert!(s.text.contains("- RISK invoice 1042"), "open loops slot should show it:\n{}", s.text); } +// Beyond the brief: a batch may carry two versions of one key. Only the +// version that survives the batch may be classified — judging every observed +// version would durably label the survivor with a superseded version's kind. +#[test] +fn only_the_surviving_version_of_a_key_is_classified() { + let dir = tempfile::tempdir().unwrap(); + let mut config = cfg(dir.path()); + for kd in &mut config.kinds.kinds { + if kd.name == "risk" { + kd.rules.push(clog::Rule { any_of: vec![clog::Matcher::BodyContains("overdue".into())] }); + } + } + let c = Clog::open(config).unwrap(); + c.advance(1_000_000).unwrap(); + // one batch, two versions of "inv": only the first matches the risk rule + c.observe( + vec![claim("inv", "invoice 1042 is overdue", 500_000), claim("inv", "invoice 1042 is paid", 600_000)], + ObserveOpts::default(), + ) + .unwrap(); + + let s = c.situation(None, None).unwrap(); + assert!(s.text.contains("invoice 1042 is paid"), "{}", s.text); + // the survivor never matched the rule, so it is unclassified: no open loop + assert!(!s.text.contains("RISK"), "surviving claim must not inherit the superseded version's kind:\n{}", s.text); + assert!(s.text.contains("## open loops\n(none)"), "{}", s.text); +} + #[test] fn custom_template_and_errors() { let dir = tempfile::tempdir().unwrap(); @@ -164,6 +216,45 @@ fn custom_template_and_errors() { assert!(matches!(c.situation(Some("nope"), None), Err(ClogError::UnknownScope))); } +// Beyond the brief: a change confined to items the template's `limit=` hides +// is invisible in the document but must still refresh what a custom-template +// read assembles from — and must not move the scope's rev (spec §5.10). +#[test] +fn hidden_item_change_keeps_the_document_but_refreshes_its_inputs() { + let dir = tempfile::tempdir().unwrap(); + let c = Clog::open(cfg(dir.path())).unwrap(); + c.advance(1_000_000).unwrap(); + // Ten claims with identical trust and occurred_at: equal scores, so the + // claim_key tiebreak orders them k00..k09 and the default template's + // `%{urgent limit=8}` hides the last two. + let batch: Vec = + (0..10).map(|i| claim(&format!("k{i:02}"), &format!("body {i:02}"), 500_000)).collect(); + c.observe(batch, ObserveOpts::default()).unwrap(); + // Rewrite one hidden item, so that this render and the next both sit on + // an unchanged membership and an empty `changes` slot — isolating the + // hidden-item edit as the only difference between them. + let settled = c.observe(vec![claim("k09", "rewritten tail", 500_000)], ObserveOpts::default()).unwrap(); + let before = c.situation(None, None).unwrap(); + assert!(before.text.contains("… (2 more)"), "{}", before.text); + + let ack = c.observe(vec![claim("k08", "second rewrite", 500_000)], ObserveOpts::default()).unwrap(); + assert_eq!(ack.rev, settled.rev + 1, "the upsert did commit a batch"); + + let after = c.situation(None, None).unwrap(); + assert_eq!(after.text, before.text, "a hidden item's body never reaches the document"); + assert_eq!(after.rev, before.rev, "unchanged text must keep its rev (rev skew is the signal)"); + assert_eq!(after.as_of, before.as_of, "and its as_of: nothing material changed"); + assert!(after.rev < ack.rev, "the scope's rev now lags the global rev, as it should"); + + // The stored slot inputs did move, though: lift the cap and both new + // bodies are there, under the same rev the default document reports. + let wide = c.situation(None, Some("%{header}\n%{urgent limit=10}")).unwrap(); + assert!(wide.text.contains("rewritten tail"), "{}", wide.text); + assert!(wide.text.contains("second rewrite"), "{}", wide.text); + assert!(!wide.text.contains("body 08") && !wide.text.contains("body 09"), "{}", wide.text); + assert!(wide.text.starts_with(&format!("default · rev {}", after.rev)), "{}", wide.text); +} + // Beyond the brief: nothing above renders the `entities` slot, and it is the // one slot whose inputs come from belief resolution rather than a flat view. #[test] From e30ebf2661609620be5da09556763ef0f65bf792 Mon Sep 17 00:00:00 2001 From: hhff Date: Sat, 15 Aug 2026 20:19:45 -0700 Subject: [PATCH 21/28] feat(clog): select, merge_entities, revoke_observer (INV-6/8) Co-Authored-By: Claude Fable 5 --- examples/clog/src/actor.rs | 306 ++++++++++++++++++++++++++++-- examples/clog/src/engine/naive.rs | 2 +- examples/clog/src/lib.rs | 114 +++++++++++ examples/clog/tests/api.rs | 204 ++++++++++++++++++++ 4 files changed, 611 insertions(+), 15 deletions(-) diff --git a/examples/clog/src/actor.rs b/examples/clog/src/actor.rs index 3d2e033..2afdea1 100644 --- a/examples/clog/src/actor.rs +++ b/examples/clog/src/actor.rs @@ -5,8 +5,8 @@ //! bounded channel and blocks on a one-shot reply, so writes are totally //! ordered and backpressure is just a blocking send. Readers never touch //! this thread: after each batch the writer publishes an immutable -//! [`WorldSnapshot`] through [`ArcSwap`], and `situation` (and, from Task -//! 15, `select`) read that snapshot and nothing else (INV-1). +//! [`WorldSnapshot`] through [`ArcSwap`], and `situation` and [`select`] +//! read that snapshot and nothing else (INV-1). //! //! **Write-ahead ordering is mandatory** (§6.3): the WAL append (and its //! fsync) completes *before* `engine.apply`, so a crash can only ever lose @@ -18,7 +18,7 @@ //! application — the classifier never runs on replay — so the same WAL //! bytes always rebuild the same world (INV-10). -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::sync::Arc; use std::thread::JoinHandle; @@ -26,14 +26,18 @@ use arc_swap::ArcSwap; use crossbeam_channel::{Receiver, Sender, bounded}; use imbl::OrdMap; +use crate::alias::EntityKey; use crate::clock::Clock; -use crate::engine::naive::{NaiveCfg, NaiveEngine, entity_state}; +use crate::engine::naive::{NaiveCfg, NaiveEngine, entity_state, is_reserved}; use crate::engine::{Batch, Engine, Event, StoredClaim, WorldViews}; use crate::kinds::{self, RuleSet}; use crate::render::template::{DEFAULT_TEMPLATE, Template, parse}; use crate::render::time::rfc3339_utc; use crate::render::{ChangeItem, EntityItem, LoopItem, SlotInputs, UrgentItem, headline, render}; -use crate::types::{Ack, Claim, ClogError, Config, Focus, ObserveOpts, Rev, Situation}; +use crate::types::{ + Ack, Claim, ClogError, Config, Credibility, EntityRef, Filter, Focus, ObserveOpts, ObserverId, Reliability, Rev, + Row, Situation, View, +}; use crate::validate::{validate_claim, validate_focus}; use crate::wal::{self, Wal}; @@ -41,6 +45,18 @@ use crate::wal::{self, Wal}; /// used whenever `situation(None, ..)` is called. pub(crate) const DEFAULT_SCOPE: &str = "default"; +/// The observer every claim clog writes about itself carries (INV-8). +const CLOG_OBSERVER: &str = "clog"; +/// The `source_ref` of a merge claim. +const MERGE_SOURCE_REF: &str = "clog:merge"; +/// The field separator inside a merge claim's body (ASCII unit separator); +/// see `engine::naive` for the wire format this half writes. +const MERGE_SEP: &str = "\u{1f}"; +/// `select`'s row cap when the caller names none. +const DEFAULT_LIMIT: usize = 50; +/// The largest row cap `select` will honour, whatever the caller asks for. +const MAX_LIMIT: usize = 500; + // ---- commands ------------------------------------------------------------- /// One unit of work for the writer thread. @@ -75,6 +91,18 @@ pub(crate) enum WriteOp { /// The key to retract. claim_key: String, }, + /// `Clog::revoke_observer`. + RevokeObserver { + /// The observer whose every claim is withdrawn. + observer: ObserverId, + }, + /// `Clog::merge_entities`. + Merge { + /// The entity being merged away. + alias: EntityRef, + /// The entity it becomes. + canonical: EntityRef, + }, } // ---- published state ------------------------------------------------------ @@ -105,11 +133,10 @@ pub(crate) struct SituationState { /// The immutable world as of one committed batch, published atomically. /// -/// Every field is part of the published contract (spec §6.1), but P1's only -/// reader is `situation`, which needs `situations` alone: a document already -/// carries its own rev and as_of. The rest is read by `select` (Task 15) and -/// by wake evaluation (P2); it is published now so readers never have to ask -/// the writer a question. +/// Every field is part of the published contract (spec §6.1). `situation` +/// reads `situations`, `select` reads `views` and `scopes`; `rev` and `as_of` +/// are published for wake evaluation (P2) so readers never have to ask the +/// writer a question — a rendered document already carries its own pair. pub(crate) struct WorldSnapshot { /// The global rev this snapshot reflects. #[allow(dead_code)] @@ -118,12 +145,8 @@ pub(crate) struct WorldSnapshot { #[allow(dead_code)] pub as_of: u64, /// The engine's materialized views. - // Read by `select` (Task 15); the situation path reads `situations`. - #[allow(dead_code)] pub views: WorldViews, /// The scopes in force, `"default"` always present. - // Read by `set_focus`/`select` (Tasks 15+). - #[allow(dead_code)] pub scopes: BTreeMap, /// The rendered document per scope. Behind `Arc` so publishing a /// snapshot copies one pointer per scope rather than deep-cloning every @@ -297,6 +320,8 @@ impl Writer { match op { WriteOp::Observe { claims, opts } => self.observe(claims, opts), WriteOp::Retract { claim_key } => self.retract(claim_key), + WriteOp::RevokeObserver { observer } => self.revoke_observer(observer), + WriteOp::Merge { alias, canonical } => self.merge_entities(&alias, &canonical), } } @@ -335,6 +360,87 @@ impl Writer { self.commit(vec![Event::Retract { claim_key }], None, now) } + /// `Clog::revoke_observer`: **one** `Revoke` event, so however many + /// claims the observer had, they all go in a single batch at a single + /// rev (INV-6). The engine expands the event into the individual + /// retractions in `claim_key` order, so replay is deterministic. + /// + /// An observer with nothing live commits nothing at all — no batch, no + /// rev, no WAL record — and returns the current rev, exactly as a wholly + /// duplicate `observe` does (INV-5). Revoking is therefore idempotent: + /// the second call is invisible rather than an error. + fn revoke_observer(&mut self, observer: ObserverId) -> Result { + if !self.engine.views().by_observer.contains_key(&observer.0) { + return Ok(self.ack(None)); + } + let now = self.clock.now_ms(); + self.commit(vec![Event::Revoke { observer }], None, now) + } + + /// `Clog::merge_entities`: writes the reserved claim that *is* the alias + /// edge (spec §5.2, §4). + /// + /// The cycle pre-check runs here, against the engine's own alias map, + /// rather than on the caller's snapshot: the writer is the only thread + /// that can change that map, so a check made here cannot be stale by the + /// time the batch commits. A cycle rejects the call and commits nothing. + /// + /// The claim is validated with `allow_reserved`, and validated *with* + /// the two entity refs attached so §10's entity rules (non-empty, length, + /// no control characters — including the unit separator this body is + /// joined with) actually run over them. It is then committed with + /// `entities` **empty**: a reserved claim must not enter `by_entity` or + /// the display-name registry, or merging would invent a mention that no + /// host ever made (INV-8). + /// + /// Re-merging the same pair is a no-op under INV-5 only while the claim + /// it would write is byte-identical to the live one. `occurred_at` and + /// `observed_at` are the writer's clock reading, so a re-merge under a + /// moving (`System`) clock does supersede the live claim and take a rev; + /// the alias map it rebuilds from is unchanged either way. + fn merge_entities(&mut self, alias: &EntityRef, canonical: &EntityRef) -> Result { + let (alias_key, canonical_key) = (alias.key(), canonical.key()); + // U-ALIAS-2: the edge is inserted flattened, so it cycles exactly + // when the flattened target is the alias itself (`b -> a` after + // `a -> b`, or the self-loop `a -> a`). + if self.engine.views().aliases.flatten_target(&canonical_key) == alias_key { + return Err(ClogError::AliasCycle); + } + + let now = self.clock.now_ms(); + let mut claim = Claim { + claim_key: format!("clog:merge:{}:{}->{}:{}", alias_key.0, alias_key.1, canonical_key.0, canonical_key.1), + subject_key: None, + source_ref: MERGE_SOURCE_REF.to_string(), + observer: ObserverId::from(CLOG_OBSERVER), + schema_v: 1, + occurred_at: now, + observed_at: now, + reliability: Reliability::A, + credibility: Credibility::One, + entities: vec![alias.clone(), canonical.clone()], + body: [&alias_key.0, &alias_key.1, &canonical_key.0, &canonical_key.1] + .map(String::as_str) + .join(MERGE_SEP), + }; + validate_claim(0, &claim, true)?; + claim.entities.clear(); + + // The same upsert expansion `observe` uses, minus the rules tier: a + // reserved claim is never classified (INV-8), so running the + // classifier could only ever write a `Judge` the engine discards. + let live = self.engine.views().claims.get(&claim.claim_key).map(|sc| &sc.claim); + let events = match live { + Some(old) if *old == claim => Vec::new(), + Some(_) => vec![ + Event::Retract { claim_key: claim.claim_key.clone() }, + Event::Observe(StoredClaim { claim, recorded_at: now }), + ], + None => vec![Event::Observe(StoredClaim { claim, recorded_at: now })], + }; + self.commit(events, None, now) + } + /// Write-path step 3 (upsert expansion) and step 4 (rules tier). /// /// Expansion compares each claim against the version that is live *at @@ -518,6 +624,178 @@ impl Writer { } } +// ---- select --------------------------------------------------------------- + +/// `Clog::select`, run entirely on one published snapshot (INV-1). +/// +/// Nothing here touches the writer: the view's own ordering is already +/// materialized, so selecting is iterate → filter → cap → hydrate, and two +/// selects over the same snapshot always agree. +/// +/// Each view supplies its own order — `Live`/`OpenLoops`/`Unclassified` by +/// `claim_key` ascending (they are ordered maps and sets), `Urgent` by rank, +/// `EntityState` by (canonical entity, subject). Reserved `clog:*` claims are +/// skipped by every one of them (INV-8): a merge claim is clog's own +/// bookkeeping and is not a row a host may read. +/// +/// Filters are all optional and AND-composed. `min_score` is the exception +/// to "optional": it only *means* anything where a score exists, so pairing +/// it with any view but `Urgent` is a malformed request rather than a filter +/// that silently matches everything. +pub(crate) fn select(snapshot: &WorldSnapshot, view: View, filter: Filter) -> Result, ClogError> { + if filter.min_score.is_some() && !matches!(view, View::Urgent { .. }) { + return Err(ClogError::InvalidFilter { + reason: "min_score is only meaningful for View::Urgent".into(), + }); + } + let views = &snapshot.views; + let limit = filter.limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT); + // Resolved once, and the claim side is resolved per row below: a filter + // may name an entity the way the host knows it while the claim names the + // one it was merged into, or vice versa (§5.2 — every view that filters + // by entity resolves through the alias map). + let wanted: Option> = filter + .entities + .as_ref() + .map(|entities| entities.iter().map(|e| views.aliases.resolve(&e.key())).collect()); + + // `(claim_key, stored claim, score)` in the view's order. Scores exist + // only in `urgent`, which is the only view that ranks. + let ordered: Vec<(&str, &StoredClaim, Option)> = match &view { + View::Live => views + .claims + .iter() + .filter(|(key, _)| !is_reserved(key)) + .map(|(key, stored)| (key.as_str(), stored, None)) + .collect(), + View::OpenLoops => by_key(views, views.open_loops.iter()), + View::Unclassified => by_key(views, views.unclassified.iter()), + View::Urgent { scope } => { + // Checked against `scopes` rather than against `urgent`, so an + // unknown scope is an error even in the (unreachable) case of a + // scope with no ranked rows at all. + if !snapshot.scopes.contains_key(scope) { + return Err(ClogError::UnknownScope); + } + views + .urgent + .get(scope) + .into_iter() + .flatten() + .filter(|(_, key)| !is_reserved(key)) + .filter_map(|(score, key)| Some((key.as_str(), views.claims.get(key.as_str())?, Some(*score)))) + .collect() + } + // `entity_state` orders its rows newest-first for the renderer; a + // selected row set is addressed by key, so subjects are re-sorted + // ascending here. One claim can be the believed answer for an entity + // under several entities at once, and then it appears once per + // entity — the row set is (entity, subject) shaped, not a claim set. + View::EntityState => { + let mut rows = Vec::new(); + for (_, _, mut believed) in entity_state(views) { + believed.sort_by(|a, b| a.0.cmp(&b.0)); + for (_, stored) in believed { + // Re-borrowed out of the snapshot so the row keeps the + // snapshot's lifetime rather than `entity_state`'s clone. + if let Some(stored) = views.claims.get(stored.claim.claim_key.as_str()) { + rows.push((stored.claim.claim_key.as_str(), stored, None)); + } + } + } + rows + } + }; + + Ok(ordered + .into_iter() + .filter(|(key, stored, score)| matches_filter(views, key, stored, *score, &filter, wanted.as_ref())) + .take(limit) + .map(|(key, stored, score)| hydrate(views, key, stored, score)) + .collect()) +} + +/// Looks a view's ordered `claim_key`s up in `claims`, dropping reserved +/// ones. Keys that name no live claim cannot occur (every view is healed on +/// retraction, INV-3); they are skipped rather than panicked on. +fn by_key<'a>( + views: &'a WorldViews, + keys: impl Iterator, +) -> Vec<(&'a str, &'a StoredClaim, Option)> { + keys.filter(|key| !is_reserved(key)) + .filter_map(|key| Some((key.as_str(), views.claims.get(key.as_str())?, None))) + .collect() +} + +/// Whether one row survives every filter the caller set (AND-composed). +/// +/// A filter a row *cannot* answer excludes it: an unclassified claim never +/// matches a `kinds` filter, and a claim with no `subject_key` never matches +/// a `subject_prefix`. `occurred_after` is strict. +fn matches_filter( + views: &WorldViews, + key: &str, + stored: &StoredClaim, + score: Option, + filter: &Filter, + wanted: Option<&BTreeSet>, +) -> bool { + let claim = &stored.claim; + if let Some(kinds) = &filter.kinds + && !views.kinds.get(key).is_some_and(|label| kinds.contains(&label.kind)) + { + return false; + } + if let Some(wanted) = wanted + && !claim.entities.iter().any(|e| wanted.contains(&views.aliases.resolve(&e.key()))) + { + return false; + } + if let Some(observer) = &filter.observer + && claim.observer != *observer + { + return false; + } + if let Some(prefix) = &filter.subject_prefix + && !claim.subject_key.as_ref().is_some_and(|s| s.starts_with(prefix.as_str())) + { + return false; + } + if let Some(after) = filter.occurred_after + && claim.occurred_at <= after + { + return false; + } + if let Some(min) = filter.min_score + && !score.is_some_and(|s| s >= min) + { + return false; + } + true +} + +/// Builds one [`Row`] from the snapshot: the claim and its arrival time as +/// stored, its kind if it has one, its rank score if the view ranks, and +/// whether it is the believed answer for its subject. +/// +/// `believed` is `None` — "not applicable" — for a claim with no +/// `subject_key`, because belief is resolved per subject group and a claim +/// outside every group is neither believed nor disbelieved. Within a group, +/// the losers are `Some(false)`, as is every member of an all-floored group. +fn hydrate(views: &WorldViews, key: &str, stored: &StoredClaim, score: Option) -> Row { + Row { + claim: stored.claim.clone(), + recorded_at: stored.recorded_at, + kind: views.kinds.get(key).cloned(), + score, + believed: stored + .claim + .subject_key + .as_deref() + .map(|subject| matches!(views.believed.get(subject), Some(Some(winner)) if winner == key)), + } +} + // ---- slot assembly -------------------------------------------------------- /// Blanks the rev and timestamp the `header` slot renders, so two documents diff --git a/examples/clog/src/engine/naive.rs b/examples/clog/src/engine/naive.rs index f643d93..2faf4b3 100644 --- a/examples/clog/src/engine/naive.rs +++ b/examples/clog/src/engine/naive.rs @@ -48,7 +48,7 @@ const MERGE_SEP: char = '\u{1f}'; const ENTITY_STATE_ROWS: usize = 8; /// Whether `claim_key` is in the reserved namespace (INV-8). -fn is_reserved(claim_key: &str) -> bool { +pub(crate) fn is_reserved(claim_key: &str) -> bool { claim_key.starts_with(RESERVED_PREFIX) } diff --git a/examples/clog/src/lib.rs b/examples/clog/src/lib.rs index 125130e..92747d2 100644 --- a/examples/clog/src/lib.rs +++ b/examples/clog/src/lib.rs @@ -193,6 +193,120 @@ impl Clog { self.write(WriteOp::Retract { claim_key: claim_key.to_string() }) } + /// Withdraws everything an observer ever said, in one batch. + /// + /// This is the integration kill switch (spec §5.1, INV-6): a misbehaving + /// or decommissioned source is removed wholesale, and every view it + /// appeared in heals as if it had never written (INV-3). However many + /// claims it had, the revocation is a single revision — hosts can rely + /// on there being no intermediate state in which the observer is + /// half-gone. Its reserved `clog:*` claims go with it, so a merge that + /// observer's writes caused is undone too. + /// + /// An observer with nothing live commits nothing at all and returns the + /// current revision, so revoking twice is invisible rather than an error. + /// + /// ```no_run + /// # use clog::*; + /// # let handle = Clog::open(Config::default_for("/var/lib/my-agent/clog"))?; + /// handle.revoke_observer(&ObserverId::from("gmail-v2"))?; + /// # Ok::<(), ClogError>(()) + /// ``` + /// + /// # Errors + /// + /// - `ClogError::Storage` / `ClogError::Corrupt` if the log append + /// fails; + /// - `ClogError::ShuttingDown` if the instance is stopping. + pub fn revoke_observer(&self, observer: &ObserverId) -> Result { + self.write(WriteOp::RevokeObserver { observer: observer.clone() }) + } + + /// Declares that `alias` and `canonical` are the same entity: every view + /// that groups or filters by entity now reports them as one (spec §5.2). + /// + /// The merge is itself a claim, written in the reserved namespace under + /// the key `clog:merge:{alias.etype}:{alias.id}->{canonical.etype}:{canonical.id}`. + /// That claim is invisible to `select` and to every rendered document + /// (INV-8), but it is a real, logged, retractable claim: passing its key + /// to [`Clog::retract`] un-merges the pair and re-keys every view back. + /// + /// Alias edges are depth-1 — merging onto an entity that is itself + /// merged away points at the far end instead — so no chain ever forms + /// and resolution is always a single hop. Merging the same pair twice + /// commits nothing new. + /// + /// ```no_run + /// # use clog::*; + /// # let handle = Clog::open(Config::default_for("/var/lib/my-agent/clog"))?; + /// let dup = EntityRef { etype: "person".into(), id: "sam.b".into(), name: None }; + /// let real = EntityRef { etype: "person".into(), id: "sam".into(), name: None }; + /// handle.merge_entities(&dup, &real)?; + /// // ...and back again + /// handle.retract("clog:merge:person:sam.b->person:sam")?; + /// # Ok::<(), ClogError>(()) + /// ``` + /// + /// # Errors + /// + /// - `ClogError::AliasCycle` if the merge would close a loop — including + /// merging an entity onto itself, or reversing an existing merge + /// without retracting it first. Nothing is written in that case; + /// - `ClogError::InvalidClaim` if either entity ref breaks spec §10 (an + /// empty or over-long `etype`/`id`, or a control character in one); + /// - `ClogError::Storage` / `ClogError::Corrupt` if the log append + /// fails; + /// - `ClogError::ShuttingDown` if the instance is stopping. + pub fn merge_entities(&self, alias: &EntityRef, canonical: &EntityRef) -> Result { + self.write(WriteOp::Merge { alias: alias.clone(), canonical: canonical.clone() }) + } + + /// Reads rows out of one materialized view, filtered. + /// + /// A pure read of the current snapshot: it never touches the writer, so + /// it neither blocks behind in-flight writes nor sees a partially + /// applied batch, and two calls at the same revision always agree + /// (INV-1). + /// + /// Each view brings its own order — `Live`, `OpenLoops` and + /// `Unclassified` by `claim_key` ascending, `Urgent` by descending rank + /// within the named scope, `EntityState` by (canonical entity, subject). + /// `EntityState` reports only *believed* claims and reports one per + /// (entity, subject), so a claim about several entities appears once for + /// each. Reserved `clog:*` claims never appear in any view (INV-8). + /// + /// Every filter is optional and they are AND-composed; `kinds` and + /// `entities` match any of their values. Entity filters resolve through + /// the merge map, so filtering on either half of a merged pair finds the + /// same rows. `occurred_after` is strict. `limit` defaults to 50 and is + /// capped at 500. + /// + /// ```no_run + /// # use clog::*; + /// # let handle = Clog::open(Config::default_for("/var/lib/my-agent/clog"))?; + /// let rows = handle.select( + /// View::Urgent { scope: "default".into() }, + /// Filter { kinds: Some(vec!["risk".into()]), min_score: Some(0.25), ..Filter::default() }, + /// )?; + /// for row in &rows { + /// println!("{:?} {}", row.score, row.claim.body); + /// } + /// # Ok::<(), ClogError>(()) + /// ``` + /// + /// # Errors + /// + /// - `ClogError::InvalidFilter` if `min_score` is set on any view but + /// `View::Urgent`, where no row has a score to compare; + /// - `ClogError::UnknownScope` if `View::Urgent` names a scope that does + /// not exist. + pub fn select(&self, view: View, filter: Filter) -> Result, ClogError> { + // `load_full` rather than `load`: hydrating and filtering rows is + // caller-sized work, and an `ArcSwap` guard must not be held across + // it (the same reason `situation` takes a full load). + actor::select(&self.inner.snapshot.load_full(), view, filter) + } + /// Reads a scope's situation document from the current snapshot. /// /// `scope` defaults to `"default"`. `template` defaults to the built-in diff --git a/examples/clog/tests/api.rs b/examples/clog/tests/api.rs index ef7842e..d7c777e 100644 --- a/examples/clog/tests/api.rs +++ b/examples/clog/tests/api.rs @@ -279,3 +279,207 @@ fn entities_slot_shows_believed_summaries_newest_first() { assert!(text.contains("Halcyon: invoice 1042 paid; kickoff moved to may"), "{text}"); assert!(!text.contains("Halcyon: invoice 1042 overdue"), "{text}"); } + +#[test] +fn select_live_with_filters() { + let dir = tempfile::tempdir().unwrap(); + let c = Clog::open(cfg(dir.path())).unwrap(); + c.advance(1_000_000).unwrap(); + let mut a = claim("a", "alpha", 100_000); + a.observer = ObserverId::from("gmail"); + let mut b = claim("b", "beta", 900_000); + b.observer = ObserverId::from("twist"); + c.observe(vec![a, b], ObserveOpts::default()).unwrap(); + + let all = c.select(View::Live, Filter::default()).unwrap(); + assert_eq!(all.iter().map(|r| r.claim.claim_key.as_str()).collect::>(), vec!["a", "b"]); + assert!(all[0].recorded_at >= 1_000_000); + + let f = Filter { observer: Some(ObserverId::from("twist")), ..Filter::default() }; + assert_eq!(c.select(View::Live, f).unwrap().len(), 1); + + let f = Filter { occurred_after: Some(500_000), ..Filter::default() }; + assert_eq!(c.select(View::Live, f).unwrap()[0].claim.claim_key, "b"); + + let f = Filter { min_score: Some(0.1), ..Filter::default() }; + assert!(matches!(c.select(View::Live, f), Err(ClogError::InvalidFilter { .. }))); + + let rows = c.select(View::Urgent { scope: "default".into() }, Filter::default()).unwrap(); + assert!(rows[0].score.is_some()); +} + +#[test] +fn inv6_revoke_observer_one_batch() { + let dir = tempfile::tempdir().unwrap(); + let c = Clog::open(cfg(dir.path())).unwrap(); + c.advance(1_000_000).unwrap(); + let mut a = claim("a", "alpha", 100_000); a.observer = ObserverId::from("gmail"); + let mut b = claim("b", "beta", 100_000); b.observer = ObserverId::from("gmail"); + c.observe(vec![a, b], ObserveOpts::default()).unwrap(); + let ack = c.revoke_observer(&ObserverId::from("gmail")).unwrap(); + assert_eq!(ack.rev, 2); // one batch, one rev + assert!(c.select(View::Live, Filter::default()).unwrap().is_empty()); +} + +#[test] +fn p7_shape_merge_round_trip_via_api() { + let dir = tempfile::tempdir().unwrap(); + let c = Clog::open(cfg(dir.path())).unwrap(); + c.advance(1_000_000).unwrap(); + let mut cl = claim("about-a", "note about a", 100_000); + cl.entities = vec![EntityRef { etype: "p".into(), id: "a".into(), name: None }]; + c.observe(vec![cl], ObserveOpts::default()).unwrap(); + let before = c.situation(None, None).unwrap(); + + let a = EntityRef { etype: "p".into(), id: "a".into(), name: None }; + let b = EntityRef { etype: "p".into(), id: "b".into(), name: None }; + c.merge_entities(&a, &b).unwrap(); + // entity filter follows the alias + let f = Filter { entities: Some(vec![b.clone()]), ..Filter::default() }; + assert_eq!(c.select(View::Live, f).unwrap().len(), 1); + // cycle rejected + assert!(matches!(c.merge_entities(&b, &a), Err(ClogError::AliasCycle))); + // merge claim is invisible (INV-8) + assert!(c.select(View::Live, Filter::default()).unwrap().iter().all(|r| !r.claim.claim_key.starts_with("clog:"))); + // un-merge by retracting the reserved key + c.retract("clog:merge:p:a->p:b").unwrap(); + let after = c.situation(None, None).unwrap(); + // `norm` rather than the brief's rev-replace: the header also carries + // `as_of`, which the merge/un-merge batches moved, and the `changes` + // slot legitimately echoes them (see `norm`'s doc comment). + assert_eq!(norm(&before), norm(&after)); +} + +// Beyond the brief: the filters above are each exercised alone. They are +// AND-composed, they exclude rows that *cannot* answer them, and `limit` +// has a default and a ceiling. +#[test] +fn select_filters_and_compose_and_limit_is_bounded() { + let dir = tempfile::tempdir().unwrap(); + let mut config = cfg(dir.path()); + for kd in &mut config.kinds.kinds { + if kd.name == "risk" { + kd.rules.push(clog::Rule { any_of: vec![clog::Matcher::BodyContains("overdue".into())] }); + } + } + let c = Clog::open(config).unwrap(); + c.advance(1_000_000).unwrap(); + + // One row passes everything, and one row fails each clause on its own, + // so dropping any single conjunct must let exactly one more through. + let row = |key: &str, body: &str, observer: &str, subject: &str, occ: u64| { + let mut c = claim(key, body, occ); + c.observer = ObserverId::from(observer); + c.subject_key = Some(subject.into()); + c + }; + c.observe( + vec![ + row("hit", "invoice overdue", "gmail", "inv:status", 900_000), + row("no-kind", "invoice settled", "gmail", "inv:note", 900_000), + row("wrong-observer", "invoice overdue", "twist", "inv:other", 900_000), + row("wrong-subject", "rent overdue", "gmail", "rent:status", 900_000), + row("too-old", "invoice overdue", "gmail", "inv:history", 100_000), + claim("no-subject", "chatter overdue", 900_000), + ], + ObserveOpts::default(), + ) + .unwrap(); + + let all = Filter { + kinds: Some(vec!["risk".into()]), + observer: Some(ObserverId::from("gmail")), + subject_prefix: Some("inv:".into()), + occurred_after: Some(500_000), + ..Filter::default() + }; + let rows = c.select(View::Live, all.clone()).unwrap(); + assert_eq!(rows.iter().map(|r| r.claim.claim_key.as_str()).collect::>(), vec!["hit"]); + assert_eq!(c.select(View::Live, Filter { kinds: None, ..all.clone() }).unwrap().len(), 2); + assert_eq!(c.select(View::Live, Filter { observer: None, ..all.clone() }).unwrap().len(), 2); + assert_eq!(c.select(View::Live, Filter { subject_prefix: None, ..all.clone() }).unwrap().len(), 2); + assert_eq!(c.select(View::Live, Filter { occurred_after: None, ..all }).unwrap().len(), 2); + + // a claim with no subject_key cannot answer a subject_prefix filter, + // not even the empty one every subject starts with + let f = Filter { subject_prefix: Some(String::new()), ..Filter::default() }; + assert_eq!(c.select(View::Live, f).unwrap().len(), 5); + // an unclassified claim cannot answer a kinds filter + let f = Filter { kinds: Some(vec!["fyi".into()]), ..Filter::default() }; + assert!(c.select(View::Live, f).unwrap().is_empty()); + // occurred_after is strict + let f = Filter { occurred_after: Some(900_000), ..Filter::default() }; + assert!(c.select(View::Live, f).unwrap().is_empty()); + + // limit: honoured, and clamped rather than rejected + let f = Filter { limit: Some(2), ..Filter::default() }; + assert_eq!(c.select(View::Live, f).unwrap().len(), 2); + let f = Filter { limit: Some(100_000), ..Filter::default() }; + assert_eq!(c.select(View::Live, f).unwrap().len(), 6); + + assert!(matches!(c.select(View::Urgent { scope: "nope".into() }, Filter::default()), Err(ClogError::UnknownScope))); +} + +// Beyond the brief: `believed` is three-valued, and `EntityState` reports +// only the winners — the flag and the view must agree. +#[test] +fn select_believed_flag_and_entity_state_rows() { + let dir = tempfile::tempdir().unwrap(); + let c = Clog::open(cfg(dir.path())).unwrap(); + c.advance(1_000_000).unwrap(); + let entity = EntityRef { etype: "project".into(), id: "halcyon".into(), name: None }; + let mut loser = claim("a-loser", "invoice overdue", 100_000); + loser.subject_key = Some("inv:status".into()); + loser.entities = vec![entity.clone()]; + let mut winner = claim("b-winner", "invoice paid", 200_000); + winner.subject_key = Some("inv:status".into()); + winner.entities = vec![entity.clone()]; + let loose = claim("c-loose", "no subject at all", 200_000); + c.observe(vec![loser, winner, loose], ObserveOpts::default()).unwrap(); + + let rows = c.select(View::Live, Filter::default()).unwrap(); + let flags: Vec<(&str, Option)> = + rows.iter().map(|r| (r.claim.claim_key.as_str(), r.believed)).collect(); + assert_eq!(flags, vec![("a-loser", Some(false)), ("b-winner", Some(true)), ("c-loose", None)]); + + // entity_state carries only the subject's winner, flagged accordingly + let rows = c.select(View::EntityState, Filter::default()).unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].claim.claim_key, "b-winner"); + assert_eq!(rows[0].believed, Some(true)); + // and the entity filter reaches it + let f = Filter { entities: Some(vec![entity]), ..Filter::default() }; + assert_eq!(c.select(View::EntityState, f).unwrap().len(), 1); +} + +// Beyond the brief: revoking an observer with nothing live must be as +// invisible as a duplicate observe (INV-5), not an error. +#[test] +fn revoke_of_an_unknown_observer_commits_nothing() { + let dir = tempfile::tempdir().unwrap(); + let c = Clog::open(cfg(dir.path())).unwrap(); + c.advance(1_000_000).unwrap(); + let ack = c.observe(vec![claim("a", "x", 500_000)], ObserveOpts::default()).unwrap(); + assert_eq!(c.revoke_observer(&ObserverId::from("nobody")).unwrap().rev, ack.rev); + let done = c.revoke_observer(&ObserverId::from("test")).unwrap(); + assert_eq!(done.rev, ack.rev + 1); + // and again: now there is nothing left, so it is a no-op + assert_eq!(c.revoke_observer(&ObserverId::from("test")).unwrap().rev, done.rev); +} + +// Beyond the brief: a self-merge is a cycle too, and a repeated merge is an +// INV-5 no-op rather than a second revision. +#[test] +fn merge_self_is_a_cycle_and_re_merging_is_a_no_op() { + let dir = tempfile::tempdir().unwrap(); + let c = Clog::open(cfg(dir.path())).unwrap(); + c.advance(1_000_000).unwrap(); + let a = EntityRef { etype: "p".into(), id: "a".into(), name: None }; + let b = EntityRef { etype: "p".into(), id: "b".into(), name: None }; + assert!(matches!(c.merge_entities(&a, &a), Err(ClogError::AliasCycle))); + let first = c.merge_entities(&a, &b).unwrap(); + assert_eq!(c.merge_entities(&a, &b).unwrap().rev, first.rev, "identical merge must not commit"); + // §10 still applies to the entity refs a merge names + let bad = EntityRef { etype: "p".into(), id: String::new(), name: None }; + assert!(matches!(c.merge_entities(&bad, &b), Err(ClogError::InvalidClaim { .. }))); +} From 02ddb9b8000f62ad185d5a7543985ddaab5cca3a Mon Sep 17 00:00:00 2001 From: hhff Date: Sat, 15 Aug 2026 21:40:54 -0700 Subject: [PATCH 22/28] fix(clog): uncapped entity_state select, self-merge guard, merge format consolidation Co-Authored-By: Claude Fable 5 --- examples/clog/src/actor.rs | 57 ++++++++++++------- examples/clog/src/engine/mod.rs | 38 +++++++++++++ examples/clog/src/engine/naive.rs | 88 ++++++++++++++--------------- examples/clog/src/lib.rs | 40 ++++++++----- examples/clog/tests/api.rs | 93 ++++++++++++++++++++++++++++++- 5 files changed, 236 insertions(+), 80 deletions(-) diff --git a/examples/clog/src/actor.rs b/examples/clog/src/actor.rs index 2afdea1..4eaa129 100644 --- a/examples/clog/src/actor.rs +++ b/examples/clog/src/actor.rs @@ -28,7 +28,8 @@ use imbl::OrdMap; use crate::alias::EntityKey; use crate::clock::Clock; -use crate::engine::naive::{NaiveCfg, NaiveEngine, entity_state, is_reserved}; +use crate::engine::naive::{ENTITY_STATE_ROWS, NaiveCfg, NaiveEngine, entity_state, is_reserved}; +use crate::engine::{merge_body, merge_key}; use crate::engine::{Batch, Engine, Event, StoredClaim, WorldViews}; use crate::kinds::{self, RuleSet}; use crate::render::template::{DEFAULT_TEMPLATE, Template, parse}; @@ -47,11 +48,9 @@ pub(crate) const DEFAULT_SCOPE: &str = "default"; /// The observer every claim clog writes about itself carries (INV-8). const CLOG_OBSERVER: &str = "clog"; -/// The `source_ref` of a merge claim. +/// The `source_ref` of a merge claim. The key and body come from +/// [`merge_key`]/[`merge_body`], the one home of that wire format. const MERGE_SOURCE_REF: &str = "clog:merge"; -/// The field separator inside a merge claim's body (ASCII unit separator); -/// see `engine::naive` for the wire format this half writes. -const MERGE_SEP: &str = "\u{1f}"; /// `select`'s row cap when the caller names none. const DEFAULT_LIMIT: usize = 50; /// The largest row cap `select` will honour, whatever the caller asks for. @@ -400,16 +399,23 @@ impl Writer { /// the alias map it rebuilds from is unchanged either way. fn merge_entities(&mut self, alias: &EntityRef, canonical: &EntityRef) -> Result { let (alias_key, canonical_key) = (alias.key(), canonical.key()); - // U-ALIAS-2: the edge is inserted flattened, so it cycles exactly - // when the flattened target is the alias itself (`b -> a` after - // `a -> b`, or the self-loop `a -> a`). + // A self-merge is a cycle whatever the alias map says, and it has to + // be caught *before* flattening: once `a` is aliased to `b`, + // `flatten_target(a)` is `b`, so the check below would wave + // `merge_entities(a, a)` through and mint an inert `a -> a` claim. + if alias_key == canonical_key { + return Err(ClogError::AliasCycle); + } + // U-ALIAS-2: the edge is inserted flattened, so it otherwise cycles + // exactly when the flattened target is the alias itself — `b -> a` + // after `a -> b`. if self.engine.views().aliases.flatten_target(&canonical_key) == alias_key { return Err(ClogError::AliasCycle); } let now = self.clock.now_ms(); let mut claim = Claim { - claim_key: format!("clog:merge:{}:{}->{}:{}", alias_key.0, alias_key.1, canonical_key.0, canonical_key.1), + claim_key: merge_key(&alias_key, &canonical_key), subject_key: None, source_ref: MERGE_SOURCE_REF.to_string(), observer: ObserverId::from(CLOG_OBSERVER), @@ -419,9 +425,7 @@ impl Writer { reliability: Reliability::A, credibility: Credibility::One, entities: vec![alias.clone(), canonical.clone()], - body: [&alias_key.0, &alias_key.1, &canonical_key.0, &canonical_key.1] - .map(String::as_str) - .join(MERGE_SEP), + body: merge_body(&alias_key, &canonical_key), }; validate_claim(0, &claim, true)?; claim.entities.clear(); @@ -686,20 +690,31 @@ pub(crate) fn select(snapshot: &WorldSnapshot, view: View, filter: Filter) -> Re .filter_map(|(score, key)| Some((key.as_str(), views.claims.get(key.as_str())?, Some(*score)))) .collect() } - // `entity_state` orders its rows newest-first for the renderer; a - // selected row set is addressed by key, so subjects are re-sorted - // ascending here. One claim can be the believed answer for an entity - // under several entities at once, and then it appears once per - // entity — the row set is (entity, subject) shaped, not a claim set. + // Uncapped: §5.3's N=8 is a *rendering* cap, and a structured read + // must be able to enumerate everything believed about an entity — + // silently returning the newest 8 of 30 subjects would give the + // caller no way to tell the world had been truncated. `Filter.limit` + // is the only cap here, and it is the caller's own. + // + // `entity_state` orders each entity's rows newest-first for the + // renderer; a selected row set is addressed by key, so subjects are + // re-sorted ascending, giving (canonical entity, subject) ascending + // over the whole set. A claim believed under several entities is + // reported once, at its lowest-ordered entity: `Row` carries no + // entity field, so repeats would be byte-identical and + // indistinguishable — noise, not information. View::EntityState => { let mut rows = Vec::new(); - for (_, _, mut believed) in entity_state(views) { + let mut seen: BTreeSet<&str> = BTreeSet::new(); + for (_, _, mut believed) in entity_state(views, None) { believed.sort_by(|a, b| a.0.cmp(&b.0)); for (_, stored) in believed { // Re-borrowed out of the snapshot so the row keeps the // snapshot's lifetime rather than `entity_state`'s clone. - if let Some(stored) = views.claims.get(stored.claim.claim_key.as_str()) { - rows.push((stored.claim.claim_key.as_str(), stored, None)); + let Some(stored) = views.claims.get(stored.claim.claim_key.as_str()) else { continue }; + let key = stored.claim.claim_key.as_str(); + if seen.insert(key) { + rows.push((key, stored, None)); } } } @@ -850,7 +865,7 @@ fn slot_inputs(views: &WorldViews, scope: &str, rev: Rev, as_of_ms: u64) -> Slot // newest-first). An entity nobody believes anything about contributes // no summaries, and a bare "Name: " line says nothing, so it is dropped // rather than rendered empty. - let entities = entity_state(views) + let entities = entity_state(views, Some(ENTITY_STATE_ROWS)) .into_iter() .filter(|(_, _, rows)| !rows.is_empty()) .map(|(_, display, rows)| EntityItem { diff --git a/examples/clog/src/engine/mod.rs b/examples/clog/src/engine/mod.rs index 684a820..c27259c 100644 --- a/examples/clog/src/engine/mod.rs +++ b/examples/clog/src/engine/mod.rs @@ -6,6 +6,11 @@ //! *are* the WAL's on-disk wire format (postcard), so their shapes must stay //! stable. `WorldViews` is not serialized in P1 (`EngineDump` is deferred to //! a later phase) and derives `Clone` only. +//! +//! The merge-claim wire format lives here too, for the same reason: it is a +//! format with a writer (the actor, which mints merge claims) and a reader +//! (the naive engine, which parses the alias edge back out), and a format +//! spelled out twice is a format that can drift. use std::collections::BTreeMap; @@ -17,6 +22,39 @@ use crate::types::{Claim, Focus, JudgeSource, KindLabel, ObserverId, Rev}; pub(crate) mod naive; +// ---- merge-claim wire format ---------------------------------------------- + +/// The key prefix identifying a merge (entity alias) claim. +pub(crate) const MERGE_PREFIX: &str = "clog:merge:"; + +/// The field separator inside a merge claim's body (ASCII unit separator). +/// +/// Unit-separated rather than JSON so the engine needs no parser and no extra +/// dependency; §10 validation already rejects control characters in every +/// entity field, so the separator cannot appear inside an entity key. +pub(crate) const MERGE_SEP: char = '\u{1f}'; + +/// The `claim_key` of the merge claim asserting `alias -> canonical`: +/// `clog:merge:{alias.etype}:{alias.id}->{canonical.etype}:{canonical.id}`. +/// +/// This key is the merge's public handle — retracting it is how a host +/// un-merges (spec §5.2) — so it is built here and nowhere else. +pub(crate) fn merge_key(alias: &EntityKey, canonical: &EntityKey) -> String { + format!("{MERGE_PREFIX}{}:{}->{}:{}", alias.0, alias.1, canonical.0, canonical.1) +} + +/// The `body` of that claim: the four entity fields joined with +/// [`MERGE_SEP`], which is the alias edge the engine parses back out. +/// +/// ```text +/// alias.etype ␟ alias.id ␟ canonical.etype ␟ canonical.id +/// ``` +pub(crate) fn merge_body(alias: &EntityKey, canonical: &EntityKey) -> String { + [&alias.0, &alias.1, &canonical.0, &canonical.1] + .map(String::as_str) + .join(&MERGE_SEP.to_string()) +} + /// A claim as stored by the engine, alongside when clog recorded it. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub(crate) struct StoredClaim { diff --git a/examples/clog/src/engine/naive.rs b/examples/clog/src/engine/naive.rs index 2faf4b3..80d98d8 100644 --- a/examples/clog/src/engine/naive.rs +++ b/examples/clog/src/engine/naive.rs @@ -13,19 +13,8 @@ //! Reserved-namespace claims (`clog:*`) live in `views.claims` like any other //! claim, but are filtered out of `unclassified`, `open_loops`, `believed` //! and `urgent` (INV-8). They still *drive* state: a merge claim's body is -//! the alias edge. -//! -//! **Merge claim wire format.** A merge claim's `claim_key` starts with -//! `clog:merge:` and its `body` is exactly four fields joined with the ASCII -//! unit separator (`U+001F`): -//! -//! ```text -//! alias.etype ␟ alias.id ␟ canonical.etype ␟ canonical.id -//! ``` -//! -//! Unit-separated rather than JSON so the engine needs no parser and no extra -//! dependency; §10 validation already rejects control characters in every -//! host-supplied field, so the separator cannot appear in an entity key. +//! the alias edge, parsed back out by [`merge_edge`] using the wire format +//! defined in [`crate::engine`]. use std::collections::BTreeMap; @@ -33,19 +22,15 @@ use imbl::{OrdMap, OrdSet}; use crate::alias::{AliasMap, EntityKey}; use crate::belief::{self, BeliefInput}; -use crate::engine::{ApplyResult, Engine, Event, StoredClaim, WorldViews}; +use crate::engine::{ApplyResult, Engine, Event, MERGE_PREFIX, MERGE_SEP, StoredClaim, WorldViews}; use crate::score::score_claim; use crate::types::{Claim, Credibility, Focus, JudgeSource, KindLabel, ObserverId}; /// The namespace reserved for clog's own claims (INV-8). const RESERVED_PREFIX: &str = "clog:"; -/// The key prefix identifying a merge (entity alias) claim. -const MERGE_PREFIX: &str = "clog:merge:"; -/// The field separator inside a merge claim's body (ASCII unit separator). -const MERGE_SEP: char = '\u{1f}'; -/// How many recent believed claims `entity_state` reports per entity -/// (spec §5.3's internal constant N). -const ENTITY_STATE_ROWS: usize = 8; +/// How many recent believed claims the *rendered* entities slot reports per +/// entity (spec §5.3's constant N, which §5.3 scopes to rendering alone). +pub(crate) const ENTITY_STATE_ROWS: usize = 8; /// Whether `claim_key` is in the reserved namespace (INV-8). pub(crate) fn is_reserved(claim_key: &str) -> bool { @@ -53,7 +38,8 @@ pub(crate) fn is_reserved(claim_key: &str) -> bool { } /// Parses the alias edge carried by a merge claim, or `None` if `claim` is -/// not a merge claim (or its body is malformed). +/// not a merge claim (or its body is malformed). The inverse of +/// [`crate::engine::merge_body`]. fn merge_edge(claim: &Claim) -> Option<(EntityKey, EntityKey)> { if !claim.claim_key.starts_with(MERGE_PREFIX) { return None; @@ -417,9 +403,16 @@ pub(crate) type EntityStateRow = (EntityKey, String, Vec<(String, StoredClaim)>) /// subject-less claims produce no rows, and reserved claims are excluded /// throughout (INV-8; `believed` already skips them). /// -/// Rows are newest-first by `occurred_at`, ties broken by `claim_key` ascending -/// (the same tiebreak `urgent` uses), then capped at [`ENTITY_STATE_ROWS`]. -pub(crate) fn entity_state(views: &WorldViews) -> Vec { +/// Rows are newest-first by `occurred_at`, ties broken by `claim_key` +/// ascending (the same tiebreak `urgent` uses). +/// +/// `cap` truncates each entity's rows **after** that ordering, so a cap keeps +/// the newest. It is `Some(ENTITY_STATE_ROWS)` for the rendered entities slot +/// and `None` for `select`: §5.3 scopes N to rendering ("for rendering"), and +/// a structured read must be able to enumerate everything that is believed +/// about an entity — a reader that silently saw only the newest 8 of 30 +/// subjects would have no way to tell it was looking at a truncated world. +pub(crate) fn entity_state(views: &WorldViews, cap: Option) -> Vec { let mut out = Vec::new(); for (entity, keys) in views.by_entity.iter() { let display = match views.names.get(entity) { @@ -442,7 +435,9 @@ pub(crate) fn entity_state(views: &WorldViews) -> Vec { rows.sort_by(|a, b| { b.1.claim.occurred_at.cmp(&a.1.claim.occurred_at).then_with(|| a.1.claim.claim_key.cmp(&b.1.claim.claim_key)) }); - rows.truncate(ENTITY_STATE_ROWS); + if let Some(cap) = cap { + rows.truncate(cap); + } out.push((entity.clone(), display, rows)); } out @@ -570,27 +565,33 @@ mod tests { #[test] fn reserved_claims_invisible_in_urgent_and_loops() { let mut e = NaiveEngine::new(cfg()); - let mut c = tests_base_claim(); - c.claim_key = "clog:merge:p:a->p:b".into(); - c.body = ["p", "a", "p", "b"].join("\u{1f}"); - e.apply(&[Event::Observe(StoredClaim { claim: c, recorded_at: 1 })], &scopes(), 100); + let (alias, canonical) = edge(("p", "a"), ("p", "b")); + e.apply(&[merge(("p", "a"), ("p", "b"))], &scopes(), 100); assert!(e.views().urgent.get("default").unwrap().is_empty()); assert!(e.views().unclassified.is_empty()); // but the alias took effect - assert_eq!(e.views().aliases.resolve(&("p".into(), "a".into())), ("p".into(), "b".into())); + assert_eq!(e.views().aliases.resolve(&alias), canonical); // INV-8 covers `kinds` too: a reserved claim cannot be judged into a view. - e.apply(&[Event::Judge { claim_key: "clog:merge:p:a->p:b".into(), kind: "risk".into(), confidence: 1.0, source: JudgeSource::Rule }], &scopes(), 101); + let key = crate::engine::merge_key(&alias, &canonical); + e.apply(&[Event::Judge { claim_key: key, kind: "risk".into(), confidence: 1.0, source: JudgeSource::Rule }], &scopes(), 101); assert!(e.views().kinds.is_empty()); assert!(e.views().open_loops.is_empty()); } - /// A merge claim carrying the edge `{alias} -> {canonical}`. + /// The `(alias, canonical)` entity-key pair a test names as two tuples. + fn edge(alias: (&str, &str), canonical: (&str, &str)) -> (EntityKey, EntityKey) { + ((alias.0.into(), alias.1.into()), (canonical.0.into(), canonical.1.into())) + } + + /// A merge claim carrying the edge `{alias} -> {canonical}`, built through + /// the shared wire format so these tests cannot drift from the writer. fn merge(alias: (&str, &str), canonical: (&str, &str)) -> Event { + let (alias, canonical) = edge(alias, canonical); let mut m = tests_base_claim(); - m.claim_key = format!("clog:merge:{}:{}->{}:{}", alias.0, alias.1, canonical.0, canonical.1); + m.claim_key = crate::engine::merge_key(&alias, &canonical); m.observer = ObserverId::from("clog"); - m.body = [alias.0, alias.1, canonical.0, canonical.1].join("\u{1f}"); + m.body = crate::engine::merge_body(&alias, &canonical); Event::Observe(StoredClaim { claim: m, recorded_at: 1 }) } @@ -639,19 +640,16 @@ mod tests { c.entities = vec![EntityRef { etype: "p".into(), id: "a".into(), name: Some("Aye".into()) }]; e.apply(&[Event::Observe(StoredClaim { claim: c, recorded_at: 1 })], &scopes(), 100); - let mut m = tests_base_claim(); - m.claim_key = "clog:merge:p:a->p:b".into(); - m.observer = ObserverId::from("clog"); - m.body = ["p", "a", "p", "b"].join("\u{1f}"); - e.apply(&[Event::Observe(StoredClaim { claim: m, recorded_at: 2 })], &scopes(), 101); + let (alias, canonical) = edge(("p", "a"), ("p", "b")); + e.apply(&[merge(("p", "a"), ("p", "b"))], &scopes(), 101); // grouped under canonical b now assert!(e.views().by_entity.get(&("p".into(), "b".into())).unwrap().contains("about-a")); assert!(e.views().by_entity.get(&("p".into(), "a".into())).is_none()); - e.apply(&[Event::Retract { claim_key: "clog:merge:p:a->p:b".into() }], &scopes(), 102); + e.apply(&[Event::Retract { claim_key: crate::engine::merge_key(&alias, &canonical) }], &scopes(), 102); // un-merged: re-keyed back under a, name registry intact assert!(e.views().by_entity.get(&("p".into(), "a".into())).unwrap().contains("about-a")); - let es = entity_state(e.views()); + let es = entity_state(e.views(), Some(ENTITY_STATE_ROWS)); let (_, display, rows) = es.iter().find(|(k, _, _)| k == &("p".to_string(), "a".to_string())).unwrap(); assert_eq!(display, "Aye"); assert_eq!(rows.len(), 0); // no subject_key -> no believed rows @@ -730,7 +728,7 @@ mod tests { evs.push(Event::Observe(StoredClaim { claim: c, recorded_at: 1 })); } e.apply(&evs, &scopes(), 1000); - let es = entity_state(e.views()); + let es = entity_state(e.views(), Some(ENTITY_STATE_ROWS)); let (_, display, rows) = &es[0]; assert_eq!(display, "proj:h"); assert_eq!(rows.len(), 8); @@ -751,7 +749,7 @@ mod tests { new.entities = vec![ent("a")]; e.apply(&[Event::Observe(StoredClaim { claim: old, recorded_at: 1 }), Event::Observe(StoredClaim { claim: new, recorded_at: 2 })], &scopes(), 1000); - let es = entity_state(e.views()); + let es = entity_state(e.views(), Some(ENTITY_STATE_ROWS)); assert_eq!(es.len(), 1); assert_eq!(es[0].0, ("p".to_string(), "a".to_string())); // one row per subject: the believed claim, not the losing one @@ -759,7 +757,7 @@ mod tests { vec![("s1", "new")]); e.apply(&[merge(("p", "a"), ("p", "b"))], &scopes(), 1001); - let es = entity_state(e.views()); + let es = entity_state(e.views(), Some(ENTITY_STATE_ROWS)); assert_eq!(es.len(), 1); assert_eq!(es[0].0, ("p".to_string(), "b".to_string())); assert_eq!(es[0].1, "p:b"); diff --git a/examples/clog/src/lib.rs b/examples/clog/src/lib.rs index 92747d2..8d64fe0 100644 --- a/examples/clog/src/lib.rs +++ b/examples/clog/src/lib.rs @@ -249,9 +249,10 @@ impl Clog { /// /// # Errors /// - /// - `ClogError::AliasCycle` if the merge would close a loop — including - /// merging an entity onto itself, or reversing an existing merge - /// without retracting it first. Nothing is written in that case; + /// - `ClogError::AliasCycle` if the merge would close a loop: merging an + /// entity onto itself (whether or not it is already merged elsewhere), + /// or reversing an existing merge without retracting it first. Nothing + /// is written in that case; /// - `ClogError::InvalidClaim` if either entity ref breaks spec §10 (an /// empty or over-long `etype`/`id`, or a control character in one); /// - `ClogError::Storage` / `ClogError::Corrupt` if the log append @@ -270,16 +271,29 @@ impl Clog { /// /// Each view brings its own order — `Live`, `OpenLoops` and /// `Unclassified` by `claim_key` ascending, `Urgent` by descending rank - /// within the named scope, `EntityState` by (canonical entity, subject). - /// `EntityState` reports only *believed* claims and reports one per - /// (entity, subject), so a claim about several entities appears once for - /// each. Reserved `clog:*` claims never appear in any view (INV-8). - /// - /// Every filter is optional and they are AND-composed; `kinds` and - /// `entities` match any of their values. Entity filters resolve through - /// the merge map, so filtering on either half of a merged pair finds the - /// same rows. `occurred_after` is strict. `limit` defaults to 50 and is - /// capped at 500. + /// within the named scope, `EntityState` by (canonical entity, subject) + /// ascending. `EntityState` reports only *believed* claims, and reports + /// **every** believed subject of every entity: the 8-row cap in a + /// rendered document is a rendering budget, not a limit on this API, so + /// `limit` is the only cap here. A claim believed under several entities + /// is reported once, at its lowest-ordered entity. Reserved `clog:*` + /// claims never appear in any view (INV-8). + /// + /// Every filter is optional and they are AND-composed. In detail: + /// + /// - `kinds` — matches any of the named kinds. An unclassified claim + /// matches no `kinds` filter. + /// - `entities` — matches a claim mentioning any of the named entities, + /// resolved through the merge map on both sides, so filtering on + /// either half of a merged pair finds the same rows. `Some(vec![])` + /// names no entity and so matches nothing. + /// - `observer` — exact match. + /// - `subject_prefix` — `starts_with` on `subject_key`. A claim with no + /// subject matches no `subject_prefix` filter, not even `""`. + /// - `occurred_after` — strictly greater than. + /// - `min_score` — inclusive (`score >= min_score`), `View::Urgent` only. + /// - `limit` — defaults to 50 and is clamped to 500; `Some(0)` returns + /// no rows. /// /// ```no_run /// # use clog::*; diff --git a/examples/clog/tests/api.rs b/examples/clog/tests/api.rs index d7c777e..4844f72 100644 --- a/examples/clog/tests/api.rs +++ b/examples/clog/tests/api.rs @@ -411,11 +411,31 @@ fn select_filters_and_compose_and_limit_is_bounded() { let f = Filter { occurred_after: Some(900_000), ..Filter::default() }; assert!(c.select(View::Live, f).unwrap().is_empty()); - // limit: honoured, and clamped rather than rejected + // an empty entity list names no entity, so it matches nothing + let f = Filter { entities: Some(vec![]), ..Filter::default() }; + assert!(c.select(View::Live, f).unwrap().is_empty()); + + // limit: honoured, clamped rather than rejected, and zero means zero let f = Filter { limit: Some(2), ..Filter::default() }; assert_eq!(c.select(View::Live, f).unwrap().len(), 2); let f = Filter { limit: Some(100_000), ..Filter::default() }; assert_eq!(c.select(View::Live, f).unwrap().len(), 6); + let f = Filter { limit: Some(0), ..Filter::default() }; + assert!(c.select(View::Live, f).unwrap().is_empty()); + + // View::OpenLoops: the five "overdue" claims classified risk (a loop + // kind), in claim_key order, each carrying its label; "no-kind" is not + // a loop and does not appear. + let loops = c.select(View::OpenLoops, Filter::default()).unwrap(); + assert_eq!( + loops.iter().map(|r| r.claim.claim_key.as_str()).collect::>(), + vec!["hit", "no-subject", "too-old", "wrong-observer", "wrong-subject"] + ); + assert!(loops.iter().all(|r| r.kind.as_ref().is_some_and(|k| k.kind == "risk"))); + assert!(loops.iter().all(|r| r.score.is_none()), "only Urgent ranks"); + // and the same filters compose over it + let f = Filter { observer: Some(ObserverId::from("twist")), ..Filter::default() }; + assert_eq!(c.select(View::OpenLoops, f).unwrap().len(), 1); assert!(matches!(c.select(View::Urgent { scope: "nope".into() }, Filter::default()), Err(ClogError::UnknownScope))); } @@ -479,7 +499,78 @@ fn merge_self_is_a_cycle_and_re_merging_is_a_no_op() { assert!(matches!(c.merge_entities(&a, &a), Err(ClogError::AliasCycle))); let first = c.merge_entities(&a, &b).unwrap(); assert_eq!(c.merge_entities(&a, &b).unwrap().rev, first.rev, "identical merge must not commit"); + // A self-merge of an *already aliased* entity is still a cycle: the + // flattened target of `a` is now `b`, so only an identity check catches + // it, and it must not mint an inert `a -> a` claim. + assert!(matches!(c.merge_entities(&a, &a), Err(ClogError::AliasCycle))); + assert_eq!(c.select(View::Live, Filter::default()).unwrap().len(), 0); + assert!(matches!(c.retract("clog:merge:p:a->p:a"), Err(ClogError::UnknownClaim))); // §10 still applies to the entity refs a merge names let bad = EntityRef { etype: "p".into(), id: String::new(), name: None }; assert!(matches!(c.merge_entities(&bad, &b), Err(ClogError::InvalidClaim { .. }))); } + +// The 8-row cap in §5.3 is scoped "for rendering": the rendered entities +// slot summarizes, but a structured read must enumerate everything believed +// about an entity, or a caller has no way to tell it saw a truncated world. +#[test] +fn entity_state_select_is_uncapped_while_the_rendered_slot_still_summarizes() { + let dir = tempfile::tempdir().unwrap(); + let c = Clog::open(cfg(dir.path())).unwrap(); + c.advance(1_000_000).unwrap(); + let entity = EntityRef { etype: "proj".into(), id: "h".into(), name: Some("Halcyon".into()) }; + // Ten subjects, one believed claim each, all on the same entity. + let batch: Vec = (0..10) + .map(|i| { + let mut cl = claim(&format!("k{i:02}"), &format!("subject {i:02} update"), 100_000 + i); + cl.subject_key = Some(format!("s{i:02}")); + cl.entities = vec![entity.clone()]; + cl + }) + .collect(); + c.observe(batch, ObserveOpts::default()).unwrap(); + + // select: all ten, in (entity, subject) ascending order + let rows = c.select(View::EntityState, Filter::default()).unwrap(); + assert_eq!(rows.len(), 10, "select must not inherit the render cap"); + assert_eq!( + rows.iter().map(|r| r.claim.subject_key.as_deref().unwrap_or("")).collect::>(), + (0..10).map(|i| format!("s{i:02}")).collect::>() + ); + assert!(rows.iter().all(|r| r.believed == Some(true))); + + // the rendered slot still shows the newest 8 summaries on one line + let text = c.situation(None, None).unwrap().text; + let line = text.lines().find(|l| l.starts_with("Halcyon: ")).expect("entities slot"); + let summaries: Vec<&str> = line.trim_start_matches("Halcyon: ").split("; ").collect(); + assert_eq!(summaries.len(), 8, "{line}"); + assert!(summaries[0].starts_with("subject 09"), "{line}"); // newest first + assert!(!line.contains("subject 00") && !line.contains("subject 01"), "{line}"); + + // and the caller's own limit is the only cap that applies to select + let f = Filter { limit: Some(3), ..Filter::default() }; + assert_eq!(c.select(View::EntityState, f).unwrap().len(), 3); +} + +// A claim mentioning several entities is believed under each of them. +// `Row` carries no entity, so repeating it would be byte-identical noise: +// EntityState reports it once, at its lowest-ordered entity. +#[test] +fn entity_state_select_reports_a_multi_entity_claim_once() { + let dir = tempfile::tempdir().unwrap(); + let c = Clog::open(cfg(dir.path())).unwrap(); + c.advance(1_000_000).unwrap(); + let x = EntityRef { etype: "p".into(), id: "x".into(), name: None }; + let y = EntityRef { etype: "p".into(), id: "y".into(), name: None }; + let mut both = claim("both", "concerns x and y", 100_000); + both.subject_key = Some("s1".into()); + both.entities = vec![x.clone(), y.clone()]; + c.observe(vec![both], ObserveOpts::default()).unwrap(); + // it really is indexed under both entities + assert_eq!(c.select(View::EntityState, Filter { entities: Some(vec![x.clone()]), ..Filter::default() }).unwrap().len(), 1); + assert_eq!(c.select(View::EntityState, Filter { entities: Some(vec![y.clone()]), ..Filter::default() }).unwrap().len(), 1); + // ...and still yields exactly one row, filtered or not + assert_eq!(c.select(View::EntityState, Filter::default()).unwrap().len(), 1); + let f = Filter { entities: Some(vec![x, y]), ..Filter::default() }; + assert_eq!(c.select(View::EntityState, f).unwrap().len(), 1); +} From 48111862c26d940fb23f13448c5b73b60bf33891 Mon Sep 17 00:00:00 2001 From: hhff Date: Sat, 15 Aug 2026 21:48:38 -0700 Subject: [PATCH 23/28] test(clog): property suite P1-P4, P6, P7 Adds tests/props.rs per Task 16 brief, with two rulings applied: - norm() strips the whole header line + changes-since-last-brief section (not just the rev marker), matching tests/api.rs's established INV-3 normalization. - P6's generated claims each carry a fixed entity (p:shared-ent) so believed winners are readable via select(View::EntityState), which is keyed by entity and would otherwise see none of these claims. All 6 properties pass at cases=64 and at PROPTEST_CASES=256 with no counterexamples. Co-Authored-By: Claude Fable 5 --- examples/clog/tests/props.rs | 208 +++++++++++++++++++++++++++++++++++ 1 file changed, 208 insertions(+) create mode 100644 examples/clog/tests/props.rs diff --git a/examples/clog/tests/props.rs b/examples/clog/tests/props.rs new file mode 100644 index 0000000..318a4bc --- /dev/null +++ b/examples/clog/tests/props.rs @@ -0,0 +1,208 @@ +//! Property tests P1-P4, P6, P7 (Task 16). +//! +//! Everything here goes through the public surface only, with a Manual +//! clock (INV-10), mirroring `tests/api.rs`'s patterns. + +use clog::*; +use proptest::prelude::*; + +// -- generators --------------------------------------------------------- + +fn arb_claim() -> impl Strategy { + ( + 0..8u8, + prop::option::of(0..3u8), + 0..2u8, + 0..6u8, + 0..6u8, + 1..6u64, + prop::collection::vec(0..6u8, 1..4), + prop::bool::ANY, + ) + .prop_map(|(k, s, o, rel, cred, occ, words, with_ent)| { + let vocab = ["invoice", "overdue", "kickoff", "moved", "question", "paid"]; + Claim { + claim_key: format!("k{k}"), + subject_key: s.map(|s| format!("s{s}")), + source_ref: "prop:1".into(), + observer: ObserverId::from(if o == 0 { "o0" } else { "o1" }), + schema_v: 1, + occurred_at: occ, + observed_at: occ, + reliability: [ + Reliability::A, + Reliability::B, + Reliability::C, + Reliability::D, + Reliability::E, + Reliability::F, + ][rel as usize], + credibility: [ + Credibility::One, + Credibility::Two, + Credibility::Three, + Credibility::Four, + Credibility::Five, + Credibility::Six, + ][cred as usize], + entities: if with_ent { + vec![EntityRef { etype: "p".into(), id: "a".into(), name: None }] + } else { + vec![] + }, + body: words.iter().map(|w| vocab[*w as usize]).collect::>().join(" "), + } + }) +} + +fn open_manual(dir: &std::path::Path) -> Clog { + let mut c = Config::default_for(dir); + c.tick = TickConfig { mode: ClockMode::Manual, interval_ms: 60_000 }; + let h = Clog::open(c).unwrap(); + h.advance(1_000_000).unwrap(); + h +} + +/// Normalizes a situation's text for equality comparisons that are about +/// *world state*, not about counters, clocks or the render-diff log. +/// +/// CONTROLLER RULING (amends the task-16 brief's rev-only strip, matching +/// `tests/api.rs`'s `norm`): strips the entire header line (it carries the +/// rev and `as_of`, both bookkeeping) and the whole `## changes since last +/// brief` section (it legitimately echoes membership deltas per §5.7, and +/// INV-3 equality is about world content, not that echo). +fn norm(s: &Situation) -> String { + let body = s.text.split_once('\n').map_or("", |(_, rest)| rest); + match body.find("\n## changes since last brief\n") { + Some(i) => body[..i].to_string(), + None => body.to_string(), + } +} + +fn live_keys(c: &Clog) -> Vec { + c.select(View::Live, Filter::default()).unwrap().into_iter().map(|r| r.claim.claim_key).collect() +} + +proptest! { + #![proptest_config(ProptestConfig { cases: 64, ..ProptestConfig::default() })] + + // P1 (INV-3): observe everything, retract all -> empty-world text + #[test] + fn p1_retract_all_heals(claims in prop::collection::vec(arb_claim(), 1..12)) { + let d = tempfile::tempdir().unwrap(); + let c = open_manual(d.path()); + let empty = norm(&c.situation(None, None).unwrap()); + for cl in &claims { let _ = c.observe(vec![cl.clone()], ObserveOpts::default()); } + for k in live_keys(&c) { c.retract(&k).unwrap(); } + prop_assert_eq!(norm(&c.situation(None, None).unwrap()), empty); + } + + // P2 (INV-4): only the last version per key matters + #[test] + fn p2_last_writer_wins(claims in prop::collection::vec(arb_claim(), 1..12)) { + let d1 = tempfile::tempdir().unwrap(); + let full = open_manual(d1.path()); + for cl in &claims { let _ = full.observe(vec![cl.clone()], ObserveOpts::default()); } + + let mut last: std::collections::BTreeMap = Default::default(); + for cl in &claims { last.insert(cl.claim_key.clone(), cl.clone()); } + let d2 = tempfile::tempdir().unwrap(); + let compact = open_manual(d2.path()); + for cl in last.values() { let _ = compact.observe(vec![cl.clone()], ObserveOpts::default()); } + + prop_assert_eq!(norm(&full.situation(None, None).unwrap()), norm(&compact.situation(None, None).unwrap())); + prop_assert_eq!(live_keys(&full), live_keys(&compact)); + } + + // P3 (INV-5): duplicating a prefix changes nothing, incl. scope revs + #[test] + fn p3_duplicates_invisible(claims in prop::collection::vec(arb_claim(), 1..8), cut in 0..8usize) { + let cut = cut.min(claims.len()); + let d1 = tempfile::tempdir().unwrap(); + let a = open_manual(d1.path()); + for cl in &claims { let _ = a.observe(vec![cl.clone()], ObserveOpts::default()); } + let s_a = a.situation(None, None).unwrap(); + + let d2 = tempfile::tempdir().unwrap(); + let b = open_manual(d2.path()); + for cl in &claims { let _ = b.observe(vec![cl.clone()], ObserveOpts::default()); } + for cl in claims.iter().take(cut) { + // replay a prefix of stale versions: only claims still live in identical + // form are true duplicates; superseded keys will upsert — so restrict to + // claims whose key's final version is this version + if claims.iter().rev().find(|c2| c2.claim_key == cl.claim_key).map(|c2| c2 == cl).unwrap_or(false) { + let _ = b.observe(vec![cl.clone()], ObserveOpts::default()); + } + } + let s_b = b.situation(None, None).unwrap(); + prop_assert_eq!(s_a.rev, s_b.rev, "duplicate observes must not advance situation rev"); + prop_assert_eq!(s_a.text, s_b.text); + } + + // P4 (INV-6): revoke == retract-each + #[test] + fn p4_revoke_equals_retract_each(claims in prop::collection::vec(arb_claim(), 1..12)) { + let d1 = tempfile::tempdir().unwrap(); + let a = open_manual(d1.path()); + let d2 = tempfile::tempdir().unwrap(); + let b = open_manual(d2.path()); + for cl in &claims { + let _ = a.observe(vec![cl.clone()], ObserveOpts::default()); + let _ = b.observe(vec![cl.clone()], ObserveOpts::default()); + } + let _ = a.revoke_observer(&ObserverId::from("o0")); + for r in b.select(View::Live, Filter { observer: Some(ObserverId::from("o0")), ..Filter::default() }).unwrap() { + b.retract(&r.claim.claim_key).unwrap(); + } + prop_assert_eq!(norm(&a.situation(None, None).unwrap()), norm(&b.situation(None, None).unwrap())); + prop_assert_eq!(live_keys(&a), live_keys(&b)); + } + + // P6: belief winner is arrival-order-insensitive + // + // Adaptation from the brief: every generated claim here is given a fixed + // entity (etype "p", id "shared-ent") so believed winners are readable + // via `View::EntityState` — that view is keyed by entity, and claims + // with no entities never appear in it. The shuffle/winner-comparison + // logic itself is unchanged. + #[test] + fn p6_belief_order_insensitive(mut claims in prop::collection::vec(arb_claim(), 2..8), seed in 0..1000u64) { + for (i, c) in claims.iter_mut().enumerate() { + c.subject_key = Some("shared".into()); + c.claim_key = format!("k{i}"); // distinct keys, same subject + c.entities = vec![EntityRef { etype: "p".into(), id: "shared-ent".into(), name: None }]; + } + let d1 = tempfile::tempdir().unwrap(); + let a = open_manual(d1.path()); + for cl in &claims { a.observe(vec![cl.clone()], ObserveOpts::default()).unwrap(); } + + // deterministic shuffle + let mut shuffled = claims.clone(); + let mut s = seed; + for i in (1..shuffled.len()).rev() { + s = s.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); + shuffled.swap(i, (s as usize) % (i + 1)); + } + let d2 = tempfile::tempdir().unwrap(); + let b = open_manual(d2.path()); + for cl in &shuffled { b.observe(vec![cl.clone()], ObserveOpts::default()).unwrap(); } + + let believed = |h: &Clog| h.select(View::EntityState, Filter::default()).unwrap() + .into_iter().map(|r| r.claim.claim_key).collect::>(); + prop_assert_eq!(believed(&a), believed(&b)); + } + + // P7: merge round-trip is invisible + #[test] + fn p7_merge_round_trip(claims in prop::collection::vec(arb_claim(), 1..8)) { + let d1 = tempfile::tempdir().unwrap(); + let a = open_manual(d1.path()); + for cl in &claims { let _ = a.observe(vec![cl.clone()], ObserveOpts::default()); } + let before = norm(&a.situation(None, None).unwrap()); + let al = EntityRef { etype: "p".into(), id: "a".into(), name: None }; + let ca = EntityRef { etype: "p".into(), id: "b".into(), name: None }; + a.merge_entities(&al, &ca).unwrap(); + a.retract("clog:merge:p:a->p:b").unwrap(); + prop_assert_eq!(norm(&a.situation(None, None).unwrap()), before); + } +} From b1bf07830ef7b5d632a8b042b4a561aae5c966d6 Mon Sep 17 00:00:00 2001 From: hhff Date: Sat, 15 Aug 2026 22:06:41 -0700 Subject: [PATCH 24/28] test(clog): G1 agency golden simulation (rendering contract frozen) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ten-claim, three-client, two-scope fixture (spec §11.4) exercising belief resolution's freshness-over-trust limitation and its healing on retraction, the entities slot dropping subject-less entities, and merge_entities re-keying entity grouping. Adds one auxiliary `fyi` classification rule beyond the brief's four so cash-and-collections' fyi=0.5 weight is actually exercised by a live claim; documented in task-17-report.md along with the hand/script-verified score tables for every checkpoint. --- examples/clog/tests/g1_agency.rs | 333 ++++++++++++++++++ .../tests/snapshots/g1_agency__g1_a_cash.snap | 32 ++ .../snapshots/g1_agency__g1_a_delivery.snap | 32 ++ .../g1_agency__g1_a_unclassified.snap | 5 + .../tests/snapshots/g1_agency__g1_b_cash.snap | 29 ++ .../snapshots/g1_agency__g1_b_delivery.snap | 29 ++ .../g1_agency__g1_b_unclassified.snap | 5 + .../tests/snapshots/g1_agency__g1_c_cash.snap | 27 ++ .../snapshots/g1_agency__g1_c_delivery.snap | 27 ++ .../g1_agency__g1_c_unclassified.snap | 5 + .../tests/snapshots/g1_agency__g1_d_cash.snap | 26 ++ .../snapshots/g1_agency__g1_d_delivery.snap | 26 ++ .../g1_agency__g1_d_unclassified.snap | 5 + 13 files changed, 581 insertions(+) create mode 100644 examples/clog/tests/g1_agency.rs create mode 100644 examples/clog/tests/snapshots/g1_agency__g1_a_cash.snap create mode 100644 examples/clog/tests/snapshots/g1_agency__g1_a_delivery.snap create mode 100644 examples/clog/tests/snapshots/g1_agency__g1_a_unclassified.snap create mode 100644 examples/clog/tests/snapshots/g1_agency__g1_b_cash.snap create mode 100644 examples/clog/tests/snapshots/g1_agency__g1_b_delivery.snap create mode 100644 examples/clog/tests/snapshots/g1_agency__g1_b_unclassified.snap create mode 100644 examples/clog/tests/snapshots/g1_agency__g1_c_cash.snap create mode 100644 examples/clog/tests/snapshots/g1_agency__g1_c_delivery.snap create mode 100644 examples/clog/tests/snapshots/g1_agency__g1_c_unclassified.snap create mode 100644 examples/clog/tests/snapshots/g1_agency__g1_d_cash.snap create mode 100644 examples/clog/tests/snapshots/g1_agency__g1_d_delivery.snap create mode 100644 examples/clog/tests/snapshots/g1_agency__g1_d_unclassified.snap diff --git a/examples/clog/tests/g1_agency.rs b/examples/clog/tests/g1_agency.rs new file mode 100644 index 0000000..122eb7b --- /dev/null +++ b/examples/clog/tests/g1_agency.rs @@ -0,0 +1,333 @@ +//! G1 golden simulation (Task 17): the "client-services studio" fixture +//! (spec §11.4). This freezes the §5.8 rendering contract via insta +//! snapshots — **changing a snapshot under `tests/snapshots/` after this +//! task requires a spec edit**. +//! +//! Ten claims across three clients (Halcyon, Meridian, Vega), two scopes +//! with different weights/boosts, and four checkpoints (A-D) that exercise: +//! belief resolution's "freshness beats trust" limitation (§5.3) and its +//! healing on retraction, the entities slot dropping subject-less entities, +//! and `merge_entities` re-keying entity grouping. +//! +//! Everything here goes through the public API only (no crate internals), +//! with a `Manual` clock (INV-10) so every rendered timestamp is +//! deterministic. See `task-17-report.md` for the hand-computed score +//! tables and line-by-line snapshot verification this fixture was checked +//! against. + +use clog::*; + +const DAY_MS: u64 = 86_400_000; + +fn day(n: u64) -> u64 { + n * DAY_MS +} + +/// Two scopes, five classification rules (the brief's four, plus `fyi` — +/// see the comment at its declaration), Manual clock. +fn fixture_config(dir: &std::path::Path) -> Config { + let mut c = Config::default_for(dir); + c.tick = TickConfig { mode: ClockMode::Manual, interval_ms: 60_000 }; + + c.scopes.insert( + "delivery-health".into(), + Focus::uniform() + .weight("risk", 2.5) + .weight("question", 1.5) + .weight("commitment", 1.5) + .boost(EntityRef { etype: "project".into(), id: "halcyon".into(), name: None }, 1.5), + ); + c.scopes.insert( + "cash-and-collections".into(), + Focus::uniform().weight("risk", 2.0).weight("fyi", 0.5).weight("opportunity", 1.5), + ); + + for kd in &mut c.kinds.kinds { + match kd.name.as_str() { + "risk" => kd.rules.push(Rule { + any_of: vec![Matcher::BodyContains("overdue".into()), Matcher::BodyContains("slipping".into())], + }), + "question" => kd.rules.push(Rule { any_of: vec![Matcher::BodyRegex(r"\?$".into())] }), + "fact" => kd.rules.push(Rule { any_of: vec![Matcher::ObserverIs("bank-feed".into())] }), + "opportunity" => kd.rules.push(Rule { any_of: vec![Matcher::BodyContains("inbound".into())] }), + // Beyond the brief's four required rules: without this, claims + // 5/6/11 (a kickoff move, a PTO note, a moved 1:1) all land in + // `Unclassified`, and cash-and-collections' `fyi = 0.5` damping + // — part of this fixture's required story — would never be + // exercised by any live claim. Documented in task-17-report.md. + "fyi" => kd.rules.push(Rule { + any_of: vec![Matcher::BodyContains("moved".into()), Matcher::BodyContains("PTO".into())], + }), + _ => {} + } + } + c +} + +#[allow(clippy::too_many_arguments)] +fn claim( + key: &str, + subject: Option<&str>, + body: &str, + observer: &str, + reliability: Reliability, + credibility: Credibility, + occurred_day: u64, + entities: Vec, +) -> Claim { + let occ = day(occurred_day); + Claim { + claim_key: key.into(), + subject_key: subject.map(Into::into), + source_ref: format!("{observer}:{key}"), + observer: ObserverId::from(observer), + schema_v: 1, + occurred_at: occ, + observed_at: occ, + reliability, + credibility, + entities, + body: body.into(), + } +} + +fn halcyon() -> EntityRef { + EntityRef { etype: "project".into(), id: "halcyon".into(), name: Some("Halcyon".into()) } +} +fn samuel() -> EntityRef { + EntityRef { etype: "person".into(), id: "samuel".into(), name: Some("Samuel".into()) } +} +fn sam() -> EntityRef { + EntityRef { etype: "person".into(), id: "sam".into(), name: Some("Sam".into()) } +} + +fn unclassified_keys(c: &Clog) -> String { + c.select(View::Unclassified, Filter::default()) + .unwrap() + .iter() + .map(|r| r.claim.claim_key.clone()) + .collect::>() + .join(", ") +} + +#[test] +fn g1_agency_simulation() { + let dir = tempfile::tempdir().unwrap(); + let c = Clog::open(fixture_config(dir.path())).unwrap(); + + // ====================================================================== + // Checkpoint A: claims 1-2 and 5-9 (the brief's numbering; 3, 4, 10, 11 + // arrive later). Establishes the slipping-deliverable + overdue-invoice + // risk pair, a kickoff-moved fyi, a PTO fyi on `person:samuel`, an open + // question, an inbound opportunity, and one claim (9) that no rule + // matches (the fixture's one permanent Unclassified item). + // ====================================================================== + c.advance(day(20_003)).unwrap(); + c.observe( + vec![ + claim( + "halcyon:deliverable:slip", + Some("halcyon:deliverable:status"), + "Halcyon deliverable is slipping by a week", + "twist", + Reliability::B, + Credibility::Three, + 20_000, + vec![halcyon()], + ), + claim( + "halcyon:inv-1042:v1", + Some("halcyon:inv-1042:status"), + "Invoice 1042 is 30 days overdue", + "gmail", + Reliability::B, + Credibility::Two, + 20_000, + vec![halcyon()], + ), + claim( + "meridian:kickoff:moved", + None, + "Meridian kickoff moved to Thursday", + "gmail", + Reliability::B, + Credibility::Two, + 20_000, + vec![], + ), + claim( + "meridian:pto:sam", + Some("meridian:pto:samuel:status"), + "Sam is on PTO next week", + "slack", + Reliability::C, + Credibility::Two, + 20_000, + vec![samuel()], + ), + claim( + "meridian:question:sow", + Some("meridian:sow"), + "Did Meridian sign the SOW?", + "gmail", + Reliability::B, + Credibility::Two, + 20_001, + vec![], + ), + claim( + "vega:lead:inbound", + None, + "Inbound lead from Vega Labs", + "hubspot", + Reliability::B, + Credibility::Two, + 20_001, + vec![], + ), + claim( + "vega:upsell:maybe", + None, + "Vega mentioned maybe expanding scope", + "twist", + Reliability::C, + Credibility::Three, + 20_001, + vec![], + ), + ], + ObserveOpts::default(), + ) + .unwrap(); + + insta::assert_snapshot!("g1_a_delivery", c.situation(Some("delivery-health"), None).unwrap().text); + insta::assert_snapshot!("g1_a_cash", c.situation(Some("cash-and-collections"), None).unwrap().text); + insta::assert_snapshot!("g1_a_unclassified", unclassified_keys(&c)); + + // ====================================================================== + // Checkpoint B: claim 3 (a lower-trust "still overdue" restatement, + // later occurred_at) then claim 4 (the bank-feed payment, earlier + // occurred_at but far better trust). Belief goes to claim 3 — the + // spec §5.3 known limitation: only `occurred_at` orders belief, so a + // stale-but-fresher-dated low-trust claim beats a well-trusted one. + // ====================================================================== + c.advance(day(3)).unwrap(); // now = day 20_006 + c.observe( + vec![claim( + "halcyon:inv-1042:v2", + Some("halcyon:inv-1042:status"), + "Invoice 1042 still overdue per bookkeeper", + "twist", + Reliability::C, + Credibility::Three, + 20_004, + vec![halcyon()], + )], + ObserveOpts::default(), + ) + .unwrap(); + c.observe( + vec![claim( + "halcyon:inv-1042:paid", + Some("halcyon:inv-1042:status"), + "Payment received for invoice 1042", + "bank-feed", + Reliability::A, + Credibility::One, + 20_002, + vec![halcyon()], + )], + ObserveOpts::default(), + ) + .unwrap(); + + insta::assert_snapshot!("g1_b_delivery", c.situation(Some("delivery-health"), None).unwrap().text); + insta::assert_snapshot!("g1_b_cash", c.situation(Some("cash-and-collections"), None).unwrap().text); + insta::assert_snapshot!("g1_b_unclassified", unclassified_keys(&c)); + + // `EntityState` reports only believed winners: the deliverable (its + // subject's only claim) and claim 3 (the invoice's current winner). + let entity_state_rows = + c.select(View::EntityState, Filter { entities: Some(vec![halcyon()]), ..Filter::default() }).unwrap(); + assert_eq!( + entity_state_rows.iter().map(|r| (r.claim.claim_key.as_str(), r.believed)).collect::>(), + vec![("halcyon:deliverable:slip", Some(true)), ("halcyon:inv-1042:v2", Some(true))], + ); + // `Live` shows every competitor for the invoice subject with its flag: + // claim 3 (later occurred_at) beats both claim 2 and the far-better- + // trusted claim 4. + let live_rows = c + .select(View::Live, Filter { subject_prefix: Some("halcyon:inv-1042:".into()), ..Filter::default() }) + .unwrap(); + assert_eq!( + live_rows.iter().map(|r| (r.claim.claim_key.as_str(), r.believed)).collect::>(), + vec![ + ("halcyon:inv-1042:paid", Some(false)), + ("halcyon:inv-1042:v1", Some(false)), + ("halcyon:inv-1042:v2", Some(true)), + ], + ); + + // ====================================================================== + // Checkpoint C: an extra small fyi claim on `person:sam` (needed so + // checkpoint D's merge visibly consolidates two distinct entities), then + // retract claim 7 (the client answered — self-resolved) and retract + // claim 3 (the erroneous "still overdue" restatement, once the payment + // is confirmed). Both heal: the question drops out of open loops, and + // belief on the invoice subject flips to the bank-feed claim. + // ====================================================================== + c.advance(day(2)).unwrap(); // now = day 20_008 + c.observe( + vec![claim( + "meridian:sam:oneone", + Some("meridian:sam:oneone:status"), + "Sam moved his 1:1 to Friday", + "slack", + Reliability::B, + Credibility::Two, + 20_005, + vec![sam()], + )], + ObserveOpts::default(), + ) + .unwrap(); + c.retract("meridian:question:sow").unwrap(); + c.retract("halcyon:inv-1042:v2").unwrap(); + + insta::assert_snapshot!("g1_c_delivery", c.situation(Some("delivery-health"), None).unwrap().text); + insta::assert_snapshot!("g1_c_cash", c.situation(Some("cash-and-collections"), None).unwrap().text); + insta::assert_snapshot!("g1_c_unclassified", unclassified_keys(&c)); + + let healed = c + .select(View::EntityState, Filter { entities: Some(vec![halcyon()]), ..Filter::default() }) + .unwrap(); + assert_eq!( + healed.iter().map(|r| r.claim.claim_key.as_str()).collect::>(), + vec!["halcyon:deliverable:slip", "halcyon:inv-1042:paid"], + "belief must flip to the bank-feed claim once the fresher-but-wrong claim 3 is retracted", + ); + + // ====================================================================== + // Checkpoint D: merge person:samuel (claim 6's PTO note, named + // "Samuel") into person:sam (claim 11's note, named "Sam"). Registry + // proof: the two separate entity rows consolidate into one. + // ====================================================================== + c.advance(day(2)).unwrap(); // now = day 20_010 + c.merge_entities(&samuel(), &sam()).unwrap(); + + insta::assert_snapshot!("g1_d_delivery", c.situation(Some("delivery-health"), None).unwrap().text); + insta::assert_snapshot!("g1_d_cash", c.situation(Some("cash-and-collections"), None).unwrap().text); + insta::assert_snapshot!("g1_d_unclassified", unclassified_keys(&c)); + + // `EntityState` orders rows (canonical entity, subject) ascending: + // "meridian:pto:samuel:status" < "meridian:sam:oneone:status" (p < s), + // so claim 6 (the PTO note) sorts before claim 11 even though claim 11 + // occurred later — this is `select`'s subject-key order, not the + // newest-first order the *rendered* entities slot uses. + let merged = c.select(View::EntityState, Filter { entities: Some(vec![sam()]), ..Filter::default() }).unwrap(); + assert_eq!( + merged.iter().map(|r| r.claim.claim_key.as_str()).collect::>(), + vec!["meridian:pto:sam", "meridian:sam:oneone"], + "both person:samuel's and person:sam's believed claims now group under one canonical entity" + ); +} diff --git a/examples/clog/tests/snapshots/g1_agency__g1_a_cash.snap b/examples/clog/tests/snapshots/g1_agency__g1_a_cash.snap new file mode 100644 index 0000000..6204666 --- /dev/null +++ b/examples/clog/tests/snapshots/g1_agency__g1_a_cash.snap @@ -0,0 +1,32 @@ +--- +source: examples/clog/tests/g1_agency.rs +expression: "c.situation(Some(\"cash-and-collections\"), None).unwrap().text" +--- +# situation · scope: cash-and-collections · rev 1 · 2024-10-07T00:00:00Z + +## urgent +1. (1.2) Invoice 1042 is 30 days overdue [B/2] (halcyon:inv-1042:v1) +2. (1.0) Halcyon deliverable is slipping by a week [B/3] (halcyon:deliverable:slip) +3. (0.9) Inbound lead from Vega Labs [B/2] (vega:lead:inbound) +4. (0.6) Did Meridian sign the SOW? [B/2] (meridian:question:sow) +5. (0.4) Vega mentioned maybe expanding scope [C/3] (vega:upsell:maybe) +6. (0.3) Meridian kickoff moved to Thursday [B/2] (meridian:kickoff:moved) +7. (0.3) Sam is on PTO next week [C/2] (meridian:pto:sam) + +## open loops +- RISK Halcyon deliverable is slipping by a week (halcyon:deliverable:slip) +- RISK Invoice 1042 is 30 days overdue (halcyon:inv-1042:v1) +- QUESTION Did Meridian sign the SOW? (meridian:question:sow) + +## entities +Samuel: Sam is on PTO next week +Halcyon: Halcyon deliverable is slipping by a week; Invoice 1042 is 30 days overdue + +## changes since last brief ++ Halcyon deliverable is slipping by a week ++ Invoice 1042 is 30 days overdue ++ Meridian kickoff moved to Thursday ++ Sam is on PTO next week ++ Did Meridian sign the SOW? ++ Inbound lead from Vega Labs +… (1 more) diff --git a/examples/clog/tests/snapshots/g1_agency__g1_a_delivery.snap b/examples/clog/tests/snapshots/g1_agency__g1_a_delivery.snap new file mode 100644 index 0000000..f55d374 --- /dev/null +++ b/examples/clog/tests/snapshots/g1_agency__g1_a_delivery.snap @@ -0,0 +1,32 @@ +--- +source: examples/clog/tests/g1_agency.rs +expression: "c.situation(Some(\"delivery-health\"), None).unwrap().text" +--- +# situation · scope: delivery-health · rev 1 · 2024-10-07T00:00:00Z + +## urgent +1. (2.3) Invoice 1042 is 30 days overdue [B/2] (halcyon:inv-1042:v1) +2. (2.0) Halcyon deliverable is slipping by a week [B/3] (halcyon:deliverable:slip) +3. (0.9) Did Meridian sign the SOW? [B/2] (meridian:question:sow) +4. (0.6) Meridian kickoff moved to Thursday [B/2] (meridian:kickoff:moved) +5. (0.6) Inbound lead from Vega Labs [B/2] (vega:lead:inbound) +6. (0.5) Sam is on PTO next week [C/2] (meridian:pto:sam) +7. (0.4) Vega mentioned maybe expanding scope [C/3] (vega:upsell:maybe) + +## open loops +- RISK Halcyon deliverable is slipping by a week (halcyon:deliverable:slip) +- RISK Invoice 1042 is 30 days overdue (halcyon:inv-1042:v1) +- QUESTION Did Meridian sign the SOW? (meridian:question:sow) + +## entities +Samuel: Sam is on PTO next week +Halcyon: Halcyon deliverable is slipping by a week; Invoice 1042 is 30 days overdue + +## changes since last brief ++ Halcyon deliverable is slipping by a week ++ Invoice 1042 is 30 days overdue ++ Meridian kickoff moved to Thursday ++ Sam is on PTO next week ++ Did Meridian sign the SOW? ++ Inbound lead from Vega Labs +… (1 more) diff --git a/examples/clog/tests/snapshots/g1_agency__g1_a_unclassified.snap b/examples/clog/tests/snapshots/g1_agency__g1_a_unclassified.snap new file mode 100644 index 0000000..4b34ca4 --- /dev/null +++ b/examples/clog/tests/snapshots/g1_agency__g1_a_unclassified.snap @@ -0,0 +1,5 @@ +--- +source: examples/clog/tests/g1_agency.rs +expression: unclassified_keys(&c) +--- +vega:upsell:maybe diff --git a/examples/clog/tests/snapshots/g1_agency__g1_b_cash.snap b/examples/clog/tests/snapshots/g1_agency__g1_b_cash.snap new file mode 100644 index 0000000..ea269fc --- /dev/null +++ b/examples/clog/tests/snapshots/g1_agency__g1_b_cash.snap @@ -0,0 +1,29 @@ +--- +source: examples/clog/tests/g1_agency.rs +expression: "c.situation(Some(\"cash-and-collections\"), None).unwrap().text" +--- +# situation · scope: cash-and-collections · rev 3 · 2024-10-10T00:00:00Z + +## urgent +1. (0.9) Invoice 1042 is 30 days overdue [B/2] (halcyon:inv-1042:v1) +2. (0.9) Invoice 1042 still overdue per bookkeeper [C/3] (halcyon:inv-1042:v2) +3. (0.8) Inbound lead from Vega Labs [B/2] (vega:lead:inbound) +4. (0.7) Halcyon deliverable is slipping by a week [B/3] (halcyon:deliverable:slip) +5. (0.6) Payment received for invoice 1042 [A/1] (halcyon:inv-1042:paid) +6. (0.5) Did Meridian sign the SOW? [B/2] (meridian:question:sow) +7. (0.4) Vega mentioned maybe expanding scope [C/3] (vega:upsell:maybe) +8. (0.2) Meridian kickoff moved to Thursday [B/2] (meridian:kickoff:moved) +… (1 more) + +## open loops +- RISK Halcyon deliverable is slipping by a week (halcyon:deliverable:slip) +- RISK Invoice 1042 is 30 days overdue (halcyon:inv-1042:v1) +- RISK Invoice 1042 still overdue per bookkeeper (halcyon:inv-1042:v2) +- QUESTION Did Meridian sign the SOW? (meridian:question:sow) + +## entities +Samuel: Sam is on PTO next week +Halcyon: Invoice 1042 still overdue per bookkeeper; Halcyon deliverable is slipping by a week + +## changes since last brief ++ Payment received for invoice 1042 diff --git a/examples/clog/tests/snapshots/g1_agency__g1_b_delivery.snap b/examples/clog/tests/snapshots/g1_agency__g1_b_delivery.snap new file mode 100644 index 0000000..37cc3fb --- /dev/null +++ b/examples/clog/tests/snapshots/g1_agency__g1_b_delivery.snap @@ -0,0 +1,29 @@ +--- +source: examples/clog/tests/g1_agency.rs +expression: "c.situation(Some(\"delivery-health\"), None).unwrap().text" +--- +# situation · scope: delivery-health · rev 3 · 2024-10-10T00:00:00Z + +## urgent +1. (1.7) Invoice 1042 is 30 days overdue [B/2] (halcyon:inv-1042:v1) +2. (1.6) Invoice 1042 still overdue per bookkeeper [C/3] (halcyon:inv-1042:v2) +3. (1.4) Halcyon deliverable is slipping by a week [B/3] (halcyon:deliverable:slip) +4. (1.0) Payment received for invoice 1042 [A/1] (halcyon:inv-1042:paid) +5. (0.8) Did Meridian sign the SOW? [B/2] (meridian:question:sow) +6. (0.5) Inbound lead from Vega Labs [B/2] (vega:lead:inbound) +7. (0.4) Meridian kickoff moved to Thursday [B/2] (meridian:kickoff:moved) +8. (0.4) Sam is on PTO next week [C/2] (meridian:pto:sam) +… (1 more) + +## open loops +- RISK Halcyon deliverable is slipping by a week (halcyon:deliverable:slip) +- RISK Invoice 1042 is 30 days overdue (halcyon:inv-1042:v1) +- RISK Invoice 1042 still overdue per bookkeeper (halcyon:inv-1042:v2) +- QUESTION Did Meridian sign the SOW? (meridian:question:sow) + +## entities +Samuel: Sam is on PTO next week +Halcyon: Invoice 1042 still overdue per bookkeeper; Halcyon deliverable is slipping by a week + +## changes since last brief ++ Payment received for invoice 1042 diff --git a/examples/clog/tests/snapshots/g1_agency__g1_b_unclassified.snap b/examples/clog/tests/snapshots/g1_agency__g1_b_unclassified.snap new file mode 100644 index 0000000..4b34ca4 --- /dev/null +++ b/examples/clog/tests/snapshots/g1_agency__g1_b_unclassified.snap @@ -0,0 +1,5 @@ +--- +source: examples/clog/tests/g1_agency.rs +expression: unclassified_keys(&c) +--- +vega:upsell:maybe diff --git a/examples/clog/tests/snapshots/g1_agency__g1_c_cash.snap b/examples/clog/tests/snapshots/g1_agency__g1_c_cash.snap new file mode 100644 index 0000000..3d12512 --- /dev/null +++ b/examples/clog/tests/snapshots/g1_agency__g1_c_cash.snap @@ -0,0 +1,27 @@ +--- +source: examples/clog/tests/g1_agency.rs +expression: "c.situation(Some(\"cash-and-collections\"), None).unwrap().text" +--- +# situation · scope: cash-and-collections · rev 6 · 2024-10-12T00:00:00Z + +## urgent +1. (0.7) Invoice 1042 is 30 days overdue [B/2] (halcyon:inv-1042:v1) +2. (0.6) Halcyon deliverable is slipping by a week [B/3] (halcyon:deliverable:slip) +3. (0.6) Inbound lead from Vega Labs [B/2] (vega:lead:inbound) +4. (0.5) Payment received for invoice 1042 [A/1] (halcyon:inv-1042:paid) +5. (0.3) Sam moved his 1:1 to Friday [B/2] (meridian:sam:oneone) +6. (0.3) Vega mentioned maybe expanding scope [C/3] (vega:upsell:maybe) +7. (0.2) Meridian kickoff moved to Thursday [B/2] (meridian:kickoff:moved) +8. (0.2) Sam is on PTO next week [C/2] (meridian:pto:sam) + +## open loops +- RISK Halcyon deliverable is slipping by a week (halcyon:deliverable:slip) +- RISK Invoice 1042 is 30 days overdue (halcyon:inv-1042:v1) + +## entities +Sam: Sam moved his 1:1 to Friday +Samuel: Sam is on PTO next week +Halcyon: Payment received for invoice 1042; Halcyon deliverable is slipping by a week + +## changes since last brief +- Invoice 1042 still overdue per bookkeeper diff --git a/examples/clog/tests/snapshots/g1_agency__g1_c_delivery.snap b/examples/clog/tests/snapshots/g1_agency__g1_c_delivery.snap new file mode 100644 index 0000000..98a6bb3 --- /dev/null +++ b/examples/clog/tests/snapshots/g1_agency__g1_c_delivery.snap @@ -0,0 +1,27 @@ +--- +source: examples/clog/tests/g1_agency.rs +expression: "c.situation(Some(\"delivery-health\"), None).unwrap().text" +--- +# situation · scope: delivery-health · rev 6 · 2024-10-12T00:00:00Z + +## urgent +1. (1.4) Invoice 1042 is 30 days overdue [B/2] (halcyon:inv-1042:v1) +2. (1.2) Halcyon deliverable is slipping by a week [B/3] (halcyon:deliverable:slip) +3. (0.8) Payment received for invoice 1042 [A/1] (halcyon:inv-1042:paid) +4. (0.6) Sam moved his 1:1 to Friday [B/2] (meridian:sam:oneone) +5. (0.4) Meridian kickoff moved to Thursday [B/2] (meridian:kickoff:moved) +6. (0.4) Inbound lead from Vega Labs [B/2] (vega:lead:inbound) +7. (0.3) Sam is on PTO next week [C/2] (meridian:pto:sam) +8. (0.3) Vega mentioned maybe expanding scope [C/3] (vega:upsell:maybe) + +## open loops +- RISK Halcyon deliverable is slipping by a week (halcyon:deliverable:slip) +- RISK Invoice 1042 is 30 days overdue (halcyon:inv-1042:v1) + +## entities +Sam: Sam moved his 1:1 to Friday +Samuel: Sam is on PTO next week +Halcyon: Payment received for invoice 1042; Halcyon deliverable is slipping by a week + +## changes since last brief +- Invoice 1042 still overdue per bookkeeper diff --git a/examples/clog/tests/snapshots/g1_agency__g1_c_unclassified.snap b/examples/clog/tests/snapshots/g1_agency__g1_c_unclassified.snap new file mode 100644 index 0000000..4b34ca4 --- /dev/null +++ b/examples/clog/tests/snapshots/g1_agency__g1_c_unclassified.snap @@ -0,0 +1,5 @@ +--- +source: examples/clog/tests/g1_agency.rs +expression: unclassified_keys(&c) +--- +vega:upsell:maybe diff --git a/examples/clog/tests/snapshots/g1_agency__g1_d_cash.snap b/examples/clog/tests/snapshots/g1_agency__g1_d_cash.snap new file mode 100644 index 0000000..5296b07 --- /dev/null +++ b/examples/clog/tests/snapshots/g1_agency__g1_d_cash.snap @@ -0,0 +1,26 @@ +--- +source: examples/clog/tests/g1_agency.rs +expression: "c.situation(Some(\"cash-and-collections\"), None).unwrap().text" +--- +# situation · scope: cash-and-collections · rev 7 · 2024-10-14T00:00:00Z + +## urgent +1. (0.6) Invoice 1042 is 30 days overdue [B/2] (halcyon:inv-1042:v1) +2. (0.5) Halcyon deliverable is slipping by a week [B/3] (halcyon:deliverable:slip) +3. (0.5) Inbound lead from Vega Labs [B/2] (vega:lead:inbound) +4. (0.5) Payment received for invoice 1042 [A/1] (halcyon:inv-1042:paid) +5. (0.3) Sam moved his 1:1 to Friday [B/2] (meridian:sam:oneone) +6. (0.2) Vega mentioned maybe expanding scope [C/3] (vega:upsell:maybe) +7. (0.2) Meridian kickoff moved to Thursday [B/2] (meridian:kickoff:moved) +8. (0.1) Sam is on PTO next week [C/2] (meridian:pto:sam) + +## open loops +- RISK Halcyon deliverable is slipping by a week (halcyon:deliverable:slip) +- RISK Invoice 1042 is 30 days overdue (halcyon:inv-1042:v1) + +## entities +Sam: Sam moved his 1:1 to Friday; Sam is on PTO next week +Halcyon: Payment received for invoice 1042; Halcyon deliverable is slipping by a week + +## changes since last brief +(none) diff --git a/examples/clog/tests/snapshots/g1_agency__g1_d_delivery.snap b/examples/clog/tests/snapshots/g1_agency__g1_d_delivery.snap new file mode 100644 index 0000000..60e5a72 --- /dev/null +++ b/examples/clog/tests/snapshots/g1_agency__g1_d_delivery.snap @@ -0,0 +1,26 @@ +--- +source: examples/clog/tests/g1_agency.rs +expression: "c.situation(Some(\"delivery-health\"), None).unwrap().text" +--- +# situation · scope: delivery-health · rev 7 · 2024-10-14T00:00:00Z + +## urgent +1. (1.2) Invoice 1042 is 30 days overdue [B/2] (halcyon:inv-1042:v1) +2. (1.0) Halcyon deliverable is slipping by a week [B/3] (halcyon:deliverable:slip) +3. (0.7) Payment received for invoice 1042 [A/1] (halcyon:inv-1042:paid) +4. (0.5) Sam moved his 1:1 to Friday [B/2] (meridian:sam:oneone) +5. (0.3) Meridian kickoff moved to Thursday [B/2] (meridian:kickoff:moved) +6. (0.3) Inbound lead from Vega Labs [B/2] (vega:lead:inbound) +7. (0.3) Sam is on PTO next week [C/2] (meridian:pto:sam) +8. (0.2) Vega mentioned maybe expanding scope [C/3] (vega:upsell:maybe) + +## open loops +- RISK Halcyon deliverable is slipping by a week (halcyon:deliverable:slip) +- RISK Invoice 1042 is 30 days overdue (halcyon:inv-1042:v1) + +## entities +Sam: Sam moved his 1:1 to Friday; Sam is on PTO next week +Halcyon: Payment received for invoice 1042; Halcyon deliverable is slipping by a week + +## changes since last brief +(none) diff --git a/examples/clog/tests/snapshots/g1_agency__g1_d_unclassified.snap b/examples/clog/tests/snapshots/g1_agency__g1_d_unclassified.snap new file mode 100644 index 0000000..4b34ca4 --- /dev/null +++ b/examples/clog/tests/snapshots/g1_agency__g1_d_unclassified.snap @@ -0,0 +1,5 @@ +--- +source: examples/clog/tests/g1_agency.rs +expression: unclassified_keys(&c) +--- +vega:upsell:maybe From f5601b1af9a4d9b4be09764f08025eb8050e4444 Mon Sep 17 00:00:00 2001 From: hhff Date: Sat, 15 Aug 2026 22:16:37 -0700 Subject: [PATCH 25/28] test(clog): crash-point recovery R1 and rebuild R3 (INV-11) R1 re-executes the recovery test binary as a child that aborts in the test-crash hook immediately after each of the script's four WAL appends (post-fsync, pre-apply), then reopens that directory and asserts the recovered world is byte-identical to a fresh instance fed exactly the durable prefix -- same text, same scope rev, same as_of -- and that the survivor still writes, taking rev N+1. R3 pins the rebuild_on_open contract: a rebuild equals a normal open. Trivially true in P1 (every open is already a full WAL replay), and load-bearing the moment an engine-state cache lands. Co-Authored-By: Claude Fable 5 --- examples/clog/tests/recovery.rs | 163 ++++++++++++++++++++++++++++++++ 1 file changed, 163 insertions(+) create mode 100644 examples/clog/tests/recovery.rs diff --git a/examples/clog/tests/recovery.rs b/examples/clog/tests/recovery.rs new file mode 100644 index 0000000..de10c49 --- /dev/null +++ b/examples/clog/tests/recovery.rs @@ -0,0 +1,163 @@ +//! Recovery tests (Task 18): INV-11, crash safety. +//! +//! R1 kills a real process at every WAL commit point and proves the reopened +//! world equals a fresh world fed exactly the durable prefix — same document, +//! byte for byte, same rev — and that the survivor still accepts writes. +//! +//! R3 pins the `rebuild_on_open` contract: rebuilding from the WAL must +//! produce the same world as a normal open. In P1 every open already is a +//! full rebuild (there is no engine-state cache yet), so this is cheap today +//! and load-bearing the moment a cache lands. +//! +//! The clock is always `Manual` (INV-10) so both sides of every comparison +//! commit at identical readings. + +use clog::*; + +/// The fixed clock reading every scripted batch commits at. Both the crashed +/// instance and the fresh comparison instance advance to it before writing, +/// and each replayed batch re-renders against its own recorded `as_of`, so +/// the two documents agree on their headers as well as their content. +const SCRIPT_NOW_MS: u64 = 1_000_000; + +/// The number of batches the script commits: revs 1..=4. +const SCRIPT_LEN: u64 = 4; + +fn manual_cfg(dir: &std::path::Path) -> Config { + let mut c = Config::default_for(dir); + c.tick = TickConfig { mode: ClockMode::Manual, interval_ms: 60_000 }; + c +} + +fn claim(key: &str, body: &str) -> Claim { + Claim { + claim_key: key.into(), + subject_key: None, + source_ref: "t:1".into(), + observer: ObserverId::from("t"), + schema_v: 1, + occurred_at: 500_000, + observed_at: 500_000, + reliability: Reliability::B, + credibility: Credibility::Two, + entities: vec![], + body: body.into(), + } +} + +/// Applies the `n`th batch of the script (1-based). Every step commits +/// exactly one batch, so "crashed after batch `n`" and "ran steps 1..=n" +/// describe the same durable prefix. +fn step(c: &Clog, n: u64) { + match n { + 1 => { + c.observe(vec![claim("a", "first")], ObserveOpts::default()).unwrap(); + } + 2 => { + c.observe(vec![claim("b", "second")], ObserveOpts::default()).unwrap(); + } + 3 => { + c.retract("a").unwrap(); + } + 4 => { + c.observe(vec![claim("c", "third")], ObserveOpts::default()).unwrap(); + } + _ => unreachable!("script has {SCRIPT_LEN} steps, asked for {n}"), + } +} + +/// Advances to the script's clock reading, then runs its first `n` batches. +fn run_prefix(c: &Clog, n: u64) { + c.advance(SCRIPT_NOW_MS).unwrap(); + for i in 1..=n { + step(c, i); + } +} + +/// R1: a child process aborts immediately after the Nth WAL append (post +/// fsync, pre apply); the parent reopens that directory and compares it +/// against a fresh instance fed the same first N batches. +/// +/// The child is this very test binary, re-executed with `CLOG_R1_CHILD` set: +/// the crash hook lives behind `--features test-crash`, so the harness and +/// the code under test have to be the same build. +#[test] +#[cfg_attr(not(feature = "test-crash"), ignore = "needs --features test-crash")] +fn r1_crash_points() { + if std::env::var_os("CLOG_R1_CHILD").is_some() { + let dir = std::env::var("CLOG_R1_DIR").expect("child needs CLOG_R1_DIR"); + let c = Clog::open(manual_cfg(std::path::Path::new(&dir))).unwrap(); + run_prefix(&c, SCRIPT_LEN); // aborts partway via CLOG_CRASH_AFTER_WAL + unreachable!("child should have crashed"); + } + + for crash_after in 1..=SCRIPT_LEN { + let dir = tempfile::tempdir().unwrap(); + let exe = std::env::current_exe().unwrap(); + // Not `--ignored`: with `test-crash` on, this test is not ignored. + let status = std::process::Command::new(&exe) + .args(["r1_crash_points", "--exact", "--nocapture"]) + .env("CLOG_R1_CHILD", "1") + .env("CLOG_R1_DIR", dir.path()) + .env("CLOG_CRASH_AFTER_WAL", crash_after.to_string()) + .status() + .unwrap(); + assert!(!status.success(), "child must abort (crash point {crash_after})"); + // A panicking child would also be "unsuccessful", and would mean the + // hook never fired — so insist on death by signal, i.e. `abort()`. + #[cfg(unix)] + { + use std::os::unix::process::ExitStatusExt; + /// `SIGABRT`, spelled out rather than pulled in as a dependency; + /// it is 6 on every Unix clog builds on. + const SIGABRT: i32 = 6; + assert_eq!( + status.signal(), + Some(SIGABRT), + "child must die in the crash hook, not fail as a test (crash point {crash_after})" + ); + } + + // The reopened world == a fresh world fed the same durable prefix. + let reopened = Clog::open(manual_cfg(dir.path())).unwrap(); + let fresh_dir = tempfile::tempdir().unwrap(); + let fresh = Clog::open(manual_cfg(fresh_dir.path())).unwrap(); + run_prefix(&fresh, crash_after); + let (s1, s2) = (reopened.situation(None, None).unwrap(), fresh.situation(None, None).unwrap()); + assert_eq!(s1.text, s2.text, "crash point {crash_after}"); + assert_eq!(s1.rev, s2.rev, "crash point {crash_after}"); + assert_eq!(s1.as_of, s2.as_of, "crash point {crash_after}"); + + // The survivor is not merely readable: it still writes, and the next + // rev continues from the durable prefix rather than from a gap. + reopened.advance(2_000_000).unwrap(); + let ack = reopened + .observe(vec![claim("post", "after the crash")], ObserveOpts::default()) + .unwrap(); + assert_eq!(ack.rev, crash_after + 1, "crash point {crash_after}"); + assert!(reopened.situation(None, None).unwrap().text.contains("after the crash")); + } +} + +/// R3: `rebuild_on_open` produces the same world as a normal open. +#[test] +fn r3_rebuild_equals_open() { + let dir = tempfile::tempdir().unwrap(); + { + let c = Clog::open(manual_cfg(dir.path())).unwrap(); + run_prefix(&c, SCRIPT_LEN); + } // drop -> clean shutdown + + let normal = Clog::open(manual_cfg(dir.path())).unwrap(); + let s1 = normal.situation(None, None).unwrap(); + drop(normal); + + let mut cfg2 = manual_cfg(dir.path()); + cfg2.rebuild_on_open = true; + let rebuilt = Clog::open(cfg2).unwrap(); + let s2 = rebuilt.situation(None, None).unwrap(); + + assert_eq!(s1.text, s2.text); + assert_eq!(s1.rev, s2.rev); + assert_eq!(s1.as_of, s2.as_of); +} From c130e703933369c76c0a97991a41ce6b3018529c Mon Sep 17 00:00:00 2001 From: hhff Date: Sat, 15 Aug 2026 22:25:22 -0700 Subject: [PATCH 26/28] docs(clog): P1 README, quickstart example, rustdoc + fmt pass; M0+M1 exit checklist green Rewrites examples/clog/README.md for the host-integration reader (what clog is, the observe/situation loop, P1 API table, quickstart, config knobs, documented caveats, status/roadmap), adds a runnable examples/clog/examples/quickstart.rs mirrored in the README, and promotes Clog's top-level doctest from no_run to an executing example. Formats the clog crate for the first time (cargo fmt -p clog, no other workspace crate touched) and re-verifies clippy/tests pass after reformatting. Exit checklist green: cargo test -p clog; cargo test -p clog --features test-crash (R1 included); cargo doc -p clog --no-deps (warning-free); cargo clippy -p clog --all-targets [--features test-crash] -- -D warnings; cargo fmt -p clog --check. --- examples/clog/README.md | 183 ++++++++- examples/clog/examples/quickstart.rs | 58 +++ examples/clog/src/actor.rs | 207 +++++++--- examples/clog/src/alias.rs | 21 +- examples/clog/src/belief.rs | 81 +++- examples/clog/src/clock.rs | 4 +- examples/clog/src/engine/mod.rs | 28 +- examples/clog/src/engine/naive.rs | 522 ++++++++++++++++++++---- examples/clog/src/kinds.rs | 51 ++- examples/clog/src/lib.rs | 65 ++- examples/clog/src/render/mod.rs | 48 ++- examples/clog/src/render/template.rs | 43 +- examples/clog/src/score.rs | 37 +- examples/clog/src/types.rs | 29 +- examples/clog/src/validate.rs | 249 ++++++++++-- examples/clog/src/wal.rs | 52 ++- examples/clog/tests/api.rs | 580 ++++++++++++++++++++++----- examples/clog/tests/g1_agency.rs | 169 ++++++-- examples/clog/tests/props.rs | 23 +- examples/clog/tests/recovery.rs | 37 +- 20 files changed, 2059 insertions(+), 428 deletions(-) create mode 100644 examples/clog/examples/quickstart.rs diff --git a/examples/clog/README.md b/examples/clog/README.md index ea0914a..9acc951 100644 --- a/examples/clog/README.md +++ b/examples/clog/README.md @@ -4,33 +4,165 @@ Orientation engine for agentic systems, built on the BogKit workspace. Spec: `../../docs/clog-spec-v1.md`. Build design: `../../docs/superpowers/specs/2026-08-15-clog-build-design.md`. -Status: P1 in progress (pure core + naive engine + WAL + the writer actor). +## What clog is -## Public API (implemented so far) +clog is an orientation engine, not a chat framework: hosts write small, +structured claims ("Invoice 1042 is 30 days overdue", sourced from Gmail, +rated for reliability and credibility), and clog maintains a set of +materialized views over them incrementally as claims arrive, get corrected, +or get retracted. On demand it renders a token-budgeted *situation* +document — a ranked, deduplicated brief — per named scope. clog never calls +a model, never generates text beyond deterministic template substitution, +and carries no host-specific semantics: it doesn't know what an "invoice" +or a "renewal" is, only what a claim, a kind, and an entity are. + +## The host integration loop + +The intended shape of an integration is small and does no retrieval work at +read time: + +1. **Connectors call `observe()`.** Each source (Gmail, Slack, a ticket + tracker, whatever) turns its events into `Claim`s and calls + `clog.observe(claims, opts)`. clog validates, deduplicates, classifies, + and updates its materialized views — this is the only place work happens + on write. +2. **The agent calls `situation()` once per turn.** Before doing anything + else, the agent reads `clog.situation(scope, None)` as its orientation + layer: a ranked, budgeted brief of what's live and urgent in that scope. + This is a pure read of the last published snapshot (INV-1) — no scoring, + no ranking, no rendering happens at read time, so it's cheap enough to + call every turn. +3. **Corrections flow back as `retract`/`observe`, not mutation.** If the + agent or a host process learns a claim was wrong, it retracts the old + claim and (optionally) observes a corrected one. clog has no "edit" + operation by design: every state change is an append to the log, so the + history of what was believed and when is always reconstructable. + +What P1 deliberately does *not* do: it never decides *when* to interrupt the +agent, and it never ranks by anything but the deterministic scorer in this +crate. Runtime focus changes, ticked recency decay, and a watch/wake +mechanism for push-style attention land in P2; semantic recall and a kNN +classification tier land in P3. Until then, `situation()` polling and the +rule-based classifier are the whole story, and that is enough to wire a host +today. + +## P1 API + +| Function | What it does | +|---|---| +| `Clog::open(cfg)` | Opens (or creates) an instance at `cfg.path`, replaying its WAL. | +| `observe(claims, opts)` | Records a batch of claims; duplicates are invisible, changed keys supersede. | +| `retract(claim_key)` | Retracts a claim, healing every view that mentioned it. | +| `revoke_observer(id)` | Withdraws everything one observer ever said, in a single revision. | +| `merge_entities(alias, canonical)` | Declares two entity refs the same entity, depth-1, retractable. | +| `select(view, filter)` | Reads rows out of one materialized view (`Live`, `Urgent`, `OpenLoops`, `EntityState`, `Unclassified`), filtered. | +| `situation(scope, template)` | Reads a scope's rendered, budgeted situation document. | +| `advance(ms)` | Moves the manual test clock forward; `ClockMode::Manual` only. | + +## Quickstart + +The listing below is also a runnable, compiling example: +`examples/clog/examples/quickstart.rs`. Run it with: + +``` +cargo run -p clog --example quickstart +``` ```rust -pub struct Clog; // Clone + Send + Sync +use clog::*; -impl Clog { - pub fn open(cfg: Config) -> Result; +fn main() -> Result<(), ClogError> { + let dir = tempfile::tempdir().expect("tempdir"); - // WRITE — one committed batch per call, at most one rev bump - pub fn observe(&self, claims: Vec, opts: ObserveOpts) -> Result; - pub fn retract(&self, claim_key: &str) -> Result; + // A scope named "inbox", declared with default focus; `Config::default_for` + // always injects "default" too. + let mut cfg = Config::default_for(dir.path()); + cfg.scopes.insert("inbox".to_string(), Focus::default()); + let clog = Clog::open(cfg)?; - // READ — published snapshot only (INV-1) - pub fn situation(&self, scope: Option<&str>, template: Option<&str>) - -> Result; + clog.observe( + vec![ + claim("gmail:msg/1", "Invoice 1042 is 30 days overdue"), + claim("gmail:msg/2", "Halcyon renewal call moved to Thursday"), + ], + ObserveOpts::default(), + )?; - // TEST CLOCK — Manual mode only (INV-10) - pub fn advance(&self, ms: u64) -> Result<(), ClogError>; + let situation = clog.situation(Some("inbox"), None)?; + println!("{}", situation.text); + + // A correction: the host learned the invoice claim was wrong. + clog.retract("gmail:msg/1")?; + let healed = clog.situation(Some("inbox"), None)?; + println!("{}", healed.text); + Ok(()) +} + +fn claim(key: &str, body: &str) -> Claim { + Claim { + claim_key: key.into(), + subject_key: None, + source_ref: key.into(), + observer: ObserverId::from("gmail-v3"), + schema_v: 1, + occurred_at: 1_786_752_000_000, + observed_at: 1_786_752_000_000, + reliability: Reliability::B, + credibility: Credibility::Two, + entities: vec![], + body: body.into(), + } } ``` -Plus the data types in `types.rs` (`Claim`, `Config`, `Focus`, `Situation`, -`Ack`, `ObserveOpts`, `ClogError`, …), all serde round-trippable (INV-12). +## Config knobs + +All fields on `Config`; build one with `Config::default_for(path)` and +override what you need. + +| Field | Default | What it controls | +|---|---|---| +| `scopes` | `{}` (+ `"default"` always injected) | Per-scope `Focus` overrides — weights, boosts, half-life, `top_k`. | +| `kinds` | 8-kind default taxonomy (`fact`, `decision`, `risk`, `question`, `commitment`, `agreement`, `opportunity`, `fyi`) | The claim kind taxonomy the rules classifier matches against. | +| `loop_kinds` | `[question, risk, commitment]` | Which kinds count as "open loops" — unresolved until retracted or reclassified. | +| `top_k` | `12` | Default cap on ranked rows per rendered situation. | +| `budget_chars` | `6000` | Character budget for a rendered situation document. | +| `tick` | `TickConfig { mode: System, interval_ms: 60_000 }` | Clock mode (`System` or `Manual`, INV-10) and tick interval. | +| `decay_buckets_per_half_life` | `4` | Granularity of the bucketed recency-decay clock. | +| `belief_min_credibility` | `Credibility::Six` | The credibility floor below which a claim is never believed. | +| `wal_fsync` | `FsyncPolicy::OnCommit` | Whether the WAL fsyncs after every commit or relies on OS buffering. | +| `write_queue` | `1024` | Bounded depth of the writer's command channel — backpressure past this blocks the caller. | +| `rebuild_on_open` | `false` | If `true`, discards cached engine state and rebuilds every view from a full WAL replay on open. | -Still to land in P1: `select`, `revoke_observer`, `merge_entities`. +## Caveats + +These are documented, deliberate P1 behaviors — not bugs — worth knowing +before you build against them: + +- **No `%{` escape in templates.** The template renderer has no escape + sequence for a literal `%{` in v1; if you write custom templates, avoid + that exact two-character sequence outside of a slot. +- **A batch that is entirely duplicates commits nothing.** If every claim in + an `observe()` call is byte-identical to what's already live (INV-5), the + call succeeds but takes no revision — `Ack.rev` comes back unchanged from + the previous call, not incremented. +- **Watches and wakes are P2.** There is no push notification when a scope's + situation changes; hosts poll `situation()` (cheap — it's a pure snapshot + read) rather than subscribing to one. +- **The WAL wire format is not yet versioned.** Pre-1.0, the on-disk frame + format (`[len][crc32][payload]`, postcard-encoded) can change between + releases without a migration path. Don't treat a clog data directory as a + long-term archival format yet. +- **`ClockMode::Manual` is the test/deterministic clock.** Production hosts + should use the default `ClockMode::System`; `Manual` (advanced only via + `Clog::advance`) exists so tests can pin `occurred_at`/`as_of` and get + byte-identical rendered output. +- **Merge dedupe is timestamp-sensitive under `ClockMode::System`.** + `merge_entities`'s INV-5 dedupe of an identical repeat call compares the + full claim, including the writer's clock reading — so it holds exactly + under `Manual`, but a repeat call under a moving system clock will + supersede and take a new revision even though the resulting alias map is + identical. This is a revision-churn question, not a correctness one. ## How it fits together @@ -52,6 +184,25 @@ Still to land in P1: `select`, `revoke_observer`, `merge_entities`. - Dropping the last `Clog` handle shuts the writer down: it finishes the in-flight batch, fsyncs and exits before `drop` returns. +## Status + +- **P1 (this release) — complete.** Pure core, naive engine, WAL, writer + actor, full public API (`open`/`observe`/`retract`/`revoke_observer`/ + `select`/`situation`/`merge_entities`/`advance`), property suite, G1 + goldens, crash-recovery tests. +- **P2 (next)** — runtime `set_focus`, ticked/bucketed recency decay, + watches and wakes for push-style attention. +- **P3** — semantic recall, a kNN classification tier layered onto the rule + classifier, `correct()`/exemplars, and a Notion-fed classifier eval + harness. +- **P4** — a `fold`-backed engine (behind `feature = "fold"`) as a + drop-in-faster alternative to the naive engine, differentially tested + against it. + +See `../../docs/clog-spec-v1.md` for the full specification and +`../../docs/superpowers/specs/2026-08-15-clog-build-design.md` for the +build design and milestone plan. + ## Docs `cargo doc -p clog --open` for the rustdoc; the module docs carry the diff --git a/examples/clog/examples/quickstart.rs b/examples/clog/examples/quickstart.rs new file mode 100644 index 0000000..ecb815c --- /dev/null +++ b/examples/clog/examples/quickstart.rs @@ -0,0 +1,58 @@ +//! Minimal host-integration loop: open, declare a scope, observe a few +//! claims, read the situation, retract one, and read again. +//! +//! Run with `cargo run -p clog --example quickstart`. This is also the +//! source of the README's quickstart listing — keep them in sync. + +use clog::*; + +fn main() -> Result<(), ClogError> { + let dir = tempfile::tempdir().expect("tempdir"); + + // A scope named "inbox" with default focus; `Config::default_for` + // always injects "default" too. + let mut cfg = Config::default_for(dir.path()); + cfg.scopes.insert("inbox".to_string(), Focus::default()); + let clog = Clog::open(cfg)?; + + clog.observe( + vec![ + claim("gmail:msg/1", "Invoice 1042 is 30 days overdue"), + claim("gmail:msg/2", "Halcyon renewal call moved to Thursday"), + ], + ObserveOpts::default(), + )?; + + let situation = clog.situation(Some("inbox"), None)?; + println!( + "--- situation (rev {}) ---\n{}", + situation.rev, situation.text + ); + + // A correction: the host learned the invoice claim was wrong. + clog.retract("gmail:msg/1")?; + + let healed = clog.situation(Some("inbox"), None)?; + println!( + "--- situation after retract (rev {}) ---\n{}", + healed.rev, healed.text + ); + + Ok(()) +} + +fn claim(key: &str, body: &str) -> Claim { + Claim { + claim_key: key.into(), + subject_key: None, + source_ref: key.into(), + observer: ObserverId::from("gmail-v3"), + schema_v: 1, + occurred_at: 1_786_752_000_000, + observed_at: 1_786_752_000_000, + reliability: Reliability::B, + credibility: Credibility::Two, + entities: vec![], + body: body.into(), + } +} diff --git a/examples/clog/src/actor.rs b/examples/clog/src/actor.rs index 4eaa129..e5f455a 100644 --- a/examples/clog/src/actor.rs +++ b/examples/clog/src/actor.rs @@ -29,15 +29,15 @@ use imbl::OrdMap; use crate::alias::EntityKey; use crate::clock::Clock; use crate::engine::naive::{ENTITY_STATE_ROWS, NaiveCfg, NaiveEngine, entity_state, is_reserved}; -use crate::engine::{merge_body, merge_key}; use crate::engine::{Batch, Engine, Event, StoredClaim, WorldViews}; +use crate::engine::{merge_body, merge_key}; use crate::kinds::{self, RuleSet}; use crate::render::template::{DEFAULT_TEMPLATE, Template, parse}; use crate::render::time::rfc3339_utc; use crate::render::{ChangeItem, EntityItem, LoopItem, SlotInputs, UrgentItem, headline, render}; use crate::types::{ - Ack, Claim, ClogError, Config, Credibility, EntityRef, Filter, Focus, ObserveOpts, ObserverId, Reliability, Rev, - Row, Situation, View, + Ack, Claim, ClogError, Config, Credibility, EntityRef, Filter, Focus, ObserveOpts, ObserverId, + Reliability, Rev, Row, Situation, View, }; use crate::validate::{validate_claim, validate_focus}; use crate::wal::{self, Wal}; @@ -239,7 +239,13 @@ pub(crate) fn spawn(cfg: Config) -> Result { .name("clog-writer".to_string()) .spawn(move || run(writer, rx))?; - Ok(Spawned { tx, snapshot, join, clock, budget_chars: cfg.budget_chars }) + Ok(Spawned { + tx, + snapshot, + join, + clock, + budget_chars: cfg.budget_chars, + }) } /// The configured scopes with `"default"` injected if the host did not @@ -247,7 +253,9 @@ pub(crate) fn spawn(cfg: Config) -> Result { /// taxonomy (§10) before the instance is allowed to open. fn resolve_scopes(cfg: &Config) -> Result, ClogError> { let mut scopes = cfg.scopes.clone(); - scopes.entry(DEFAULT_SCOPE.to_string()).or_insert_with(Focus::uniform); + scopes + .entry(DEFAULT_SCOPE.to_string()) + .or_insert_with(Focus::uniform); for focus in scopes.values() { validate_focus(focus, &cfg.kinds)?; } @@ -397,7 +405,11 @@ impl Writer { /// `observed_at` are the writer's clock reading, so a re-merge under a /// moving (`System`) clock does supersede the live claim and take a rev; /// the alias map it rebuilds from is unchanged either way. - fn merge_entities(&mut self, alias: &EntityRef, canonical: &EntityRef) -> Result { + fn merge_entities( + &mut self, + alias: &EntityRef, + canonical: &EntityRef, + ) -> Result { let (alias_key, canonical_key) = (alias.key(), canonical.key()); // A self-merge is a cycle whatever the alias map says, and it has to // be caught *before* flattening: once `a` is aliased to `b`, @@ -433,14 +445,27 @@ impl Writer { // The same upsert expansion `observe` uses, minus the rules tier: a // reserved claim is never classified (INV-8), so running the // classifier could only ever write a `Judge` the engine discards. - let live = self.engine.views().claims.get(&claim.claim_key).map(|sc| &sc.claim); + let live = self + .engine + .views() + .claims + .get(&claim.claim_key) + .map(|sc| &sc.claim); let events = match live { Some(old) if *old == claim => Vec::new(), Some(_) => vec![ - Event::Retract { claim_key: claim.claim_key.clone() }, - Event::Observe(StoredClaim { claim, recorded_at: now }), + Event::Retract { + claim_key: claim.claim_key.clone(), + }, + Event::Observe(StoredClaim { + claim, + recorded_at: now, + }), ], - None => vec![Event::Observe(StoredClaim { claim, recorded_at: now })], + None => vec![Event::Observe(StoredClaim { + claim, + recorded_at: now, + })], }; self.commit(events, None, now) } @@ -469,16 +494,24 @@ impl Writer { let mut pending: BTreeMap<&str, &Claim> = BTreeMap::new(); for claim in claims { - let live = pending - .get(claim.claim_key.as_str()) - .copied() - .or_else(|| self.engine.views().claims.get(&claim.claim_key).map(|sc| &sc.claim)); + let live = pending.get(claim.claim_key.as_str()).copied().or_else(|| { + self.engine + .views() + .claims + .get(&claim.claim_key) + .map(|sc| &sc.claim) + }); match live { Some(old) if old == claim => continue, - Some(_) => events.push(Event::Retract { claim_key: claim.claim_key.clone() }), + Some(_) => events.push(Event::Retract { + claim_key: claim.claim_key.clone(), + }), None => {} } - events.push(Event::Observe(StoredClaim { claim: claim.clone(), recorded_at: now })); + events.push(Event::Observe(StoredClaim { + claim: claim.clone(), + recorded_at: now, + })); pending.insert(claim.claim_key.as_str(), claim); } @@ -507,7 +540,12 @@ impl Writer { /// An empty event list is *not* a batch: it takes no rev, writes no WAL /// record and re-renders nothing, so a wholly duplicate `observe` is /// invisible to every reader (INV-5). - fn commit(&mut self, events: Vec, want: Option<&str>, now: u64) -> Result { + fn commit( + &mut self, + events: Vec, + want: Option<&str>, + now: u64, + ) -> Result { // 5. Nothing to do. if events.is_empty() { return Ok(self.ack(want)); @@ -516,7 +554,11 @@ impl Writer { // only once the record is durable, so a failed append leaves the // world exactly where it was. The clock reading rides along in // the record so replay can reproduce this batch's render. - let batch = Batch { rev: self.rev + 1, as_of: now, events }; + let batch = Batch { + rev: self.rev + 1, + as_of: now, + events, + }; self.wal.append(&batch)?; #[cfg(feature = "test-crash")] maybe_crash_after_wal(batch.rev); @@ -553,7 +595,9 @@ impl Writer { fn ack(&self, want: Option<&str>) -> Ack { Ack { rev: self.rev, - situation: want.and_then(|scope| self.situations.get(scope)).map(|s| s.situation.clone()), + situation: want + .and_then(|scope| self.situations.get(scope)) + .map(|s| s.situation.clone()), } } @@ -587,7 +631,12 @@ impl Writer { .urgent .iter() .map(|u| (u.claim_key.clone(), u.headline.clone())) - .chain(inputs.open_loops.iter().map(|l| (l.claim_key.clone(), l.headline.clone()))) + .chain( + inputs + .open_loops + .iter() + .map(|l| (l.claim_key.clone(), l.headline.clone())), + ) .collect(); let previous = self.situations.get(scope); @@ -603,15 +652,26 @@ impl Writer { == mask_header(&text, self.rev, now) }) .map(|s| s.situation.clone()); - let situation = retained.unwrap_or(Situation { scope: scope.to_string(), text, rev: self.rev, as_of: now }); + let situation = retained.unwrap_or(Situation { + scope: scope.to_string(), + text, + rev: self.rev, + as_of: now, + }); // Keep the stored inputs' header fields in step with the document // they belong to, so a custom `%{header}` agrees with `Situation.rev`. inputs.rev = situation.rev; inputs.as_of_ms = situation.as_of; - self.situations - .insert(scope.to_string(), Arc::new(SituationState { situation, inputs, membership })); + self.situations.insert( + scope.to_string(), + Arc::new(SituationState { + situation, + inputs, + membership, + }), + ); } /// Write-path step 8: publish the new snapshot. This is the moment the @@ -646,7 +706,11 @@ impl Writer { /// to "optional": it only *means* anything where a score exists, so pairing /// it with any view but `Urgent` is a malformed request rather than a filter /// that silently matches everything. -pub(crate) fn select(snapshot: &WorldSnapshot, view: View, filter: Filter) -> Result, ClogError> { +pub(crate) fn select( + snapshot: &WorldSnapshot, + view: View, + filter: Filter, +) -> Result, ClogError> { if filter.min_score.is_some() && !matches!(view, View::Urgent { .. }) { return Err(ClogError::InvalidFilter { reason: "min_score is only meaningful for View::Urgent".into(), @@ -658,10 +722,12 @@ pub(crate) fn select(snapshot: &WorldSnapshot, view: View, filter: Filter) -> Re // may name an entity the way the host knows it while the claim names the // one it was merged into, or vice versa (§5.2 — every view that filters // by entity resolves through the alias map). - let wanted: Option> = filter - .entities - .as_ref() - .map(|entities| entities.iter().map(|e| views.aliases.resolve(&e.key())).collect()); + let wanted: Option> = filter.entities.as_ref().map(|entities| { + entities + .iter() + .map(|e| views.aliases.resolve(&e.key())) + .collect() + }); // `(claim_key, stored claim, score)` in the view's order. Scores exist // only in `urgent`, which is the only view that ranks. @@ -687,7 +753,9 @@ pub(crate) fn select(snapshot: &WorldSnapshot, view: View, filter: Filter) -> Re .into_iter() .flatten() .filter(|(_, key)| !is_reserved(key)) - .filter_map(|(score, key)| Some((key.as_str(), views.claims.get(key.as_str())?, Some(*score)))) + .filter_map(|(score, key)| { + Some((key.as_str(), views.claims.get(key.as_str())?, Some(*score))) + }) .collect() } // Uncapped: §5.3's N=8 is a *rendering* cap, and a structured read @@ -711,7 +779,9 @@ pub(crate) fn select(snapshot: &WorldSnapshot, view: View, filter: Filter) -> Re for (_, stored) in believed { // Re-borrowed out of the snapshot so the row keeps the // snapshot's lifetime rather than `entity_state`'s clone. - let Some(stored) = views.claims.get(stored.claim.claim_key.as_str()) else { continue }; + let Some(stored) = views.claims.get(stored.claim.claim_key.as_str()) else { + continue; + }; let key = stored.claim.claim_key.as_str(); if seen.insert(key) { rows.push((key, stored, None)); @@ -724,7 +794,9 @@ pub(crate) fn select(snapshot: &WorldSnapshot, view: View, filter: Filter) -> Re Ok(ordered .into_iter() - .filter(|(key, stored, score)| matches_filter(views, key, stored, *score, &filter, wanted.as_ref())) + .filter(|(key, stored, score)| { + matches_filter(views, key, stored, *score, &filter, wanted.as_ref()) + }) .take(limit) .map(|(key, stored, score)| hydrate(views, key, stored, score)) .collect()) @@ -757,12 +829,18 @@ fn matches_filter( ) -> bool { let claim = &stored.claim; if let Some(kinds) = &filter.kinds - && !views.kinds.get(key).is_some_and(|label| kinds.contains(&label.kind)) + && !views + .kinds + .get(key) + .is_some_and(|label| kinds.contains(&label.kind)) { return false; } if let Some(wanted) = wanted - && !claim.entities.iter().any(|e| wanted.contains(&views.aliases.resolve(&e.key()))) + && !claim + .entities + .iter() + .any(|e| wanted.contains(&views.aliases.resolve(&e.key()))) { return false; } @@ -772,7 +850,10 @@ fn matches_filter( return false; } if let Some(prefix) = &filter.subject_prefix - && !claim.subject_key.as_ref().is_some_and(|s| s.starts_with(prefix.as_str())) + && !claim + .subject_key + .as_ref() + .is_some_and(|s| s.starts_with(prefix.as_str())) { return false; } @@ -803,11 +884,9 @@ fn hydrate(views: &WorldViews, key: &str, stored: &StoredClaim, score: Option String { - text.replacen(&format!("rev {rev} · {}", rfc3339_utc(as_of)), "rev _ · _", 1) + text.replacen( + &format!("rev {rev} · {}", rfc3339_utc(as_of)), + "rev _ · _", + 1, + ) } /// Builds one scope's slot inputs from the materialized views (§5.7, §5.8). @@ -870,11 +953,22 @@ fn slot_inputs(views: &WorldViews, scope: &str, rev: Rev, as_of_ms: u64) -> Slot .filter(|(_, _, rows)| !rows.is_empty()) .map(|(_, display, rows)| EntityItem { display, - summaries: rows.iter().map(|(_, stored)| headline(&stored.claim.body)).collect(), + summaries: rows + .iter() + .map(|(_, stored)| headline(&stored.claim.body)) + .collect(), }) .collect(); - SlotInputs { scope: scope.to_string(), rev, as_of_ms, urgent, open_loops, entities, changes: Vec::new() } + SlotInputs { + scope: scope.to_string(), + rev, + as_of_ms, + urgent, + open_loops, + entities, + changes: Vec::new(), + } } /// The `changes` slot: the membership delta between the last two rendered @@ -885,7 +979,10 @@ fn slot_inputs(views: &WorldViews, scope: &str, rev: Rev, as_of_ms: u64) -> Slot /// produces nothing even if its headline changed: this slot tracks /// membership, not content. Removed items take their headline from the /// *previous* render, because a removed claim is no longer live to read. -fn changes_since(previous: Option<&OrdMap>, current: &OrdMap) -> Vec { +fn changes_since( + previous: Option<&OrdMap>, + current: &OrdMap, +) -> Vec { let empty = OrdMap::new(); let previous = previous.unwrap_or(&empty); let mut changes: Vec = current @@ -909,7 +1006,10 @@ mod tests { fn manual_cfg(dir: &std::path::Path) -> Config { let mut cfg = Config::default_for(dir); - cfg.tick = TickConfig { mode: ClockMode::Manual, interval_ms: 60_000 }; + cfg.tick = TickConfig { + mode: ClockMode::Manual, + interval_ms: 60_000, + }; cfg } @@ -920,7 +1020,8 @@ mod tests { assert_eq!(scopes.keys().collect::>(), vec![DEFAULT_SCOPE]); let mut cfg = manual_cfg(dir.path()); - cfg.scopes.insert("bad".into(), Focus::uniform().weight("no-such-kind", 2.0)); + cfg.scopes + .insert("bad".into(), Focus::uniform().weight("no-such-kind", 2.0)); assert!(matches!(resolve_scopes(&cfg), Err(ClogError::UnknownKind))); } @@ -937,10 +1038,18 @@ mod tests { #[test] fn changes_are_adds_then_removes_each_key_ordered() { - let previous: OrdMap = - [("b".to_string(), "bee".to_string()), ("c".to_string(), "cee".to_string())].into_iter().collect(); - let current: OrdMap = - [("a".to_string(), "ay".to_string()), ("c".to_string(), "cee2".to_string())].into_iter().collect(); + let previous: OrdMap = [ + ("b".to_string(), "bee".to_string()), + ("c".to_string(), "cee".to_string()), + ] + .into_iter() + .collect(); + let current: OrdMap = [ + ("a".to_string(), "ay".to_string()), + ("c".to_string(), "cee2".to_string()), + ] + .into_iter() + .collect(); let rendered: Vec = changes_since(Some(&previous), ¤t) .iter() .map(|c| match c { diff --git a/examples/clog/src/alias.rs b/examples/clog/src/alias.rs index 62ce2a0..8040a9d 100644 --- a/examples/clog/src/alias.rs +++ b/examples/clog/src/alias.rs @@ -29,7 +29,10 @@ impl AliasMap { /// Write-time flattening helper: if `canonical` is itself aliased, /// returns its target; otherwise returns `canonical` unchanged. pub(crate) fn flatten_target(&self, canonical: &EntityKey) -> EntityKey { - self.edges.get(canonical).cloned().unwrap_or_else(|| canonical.clone()) + self.edges + .get(canonical) + .cloned() + .unwrap_or_else(|| canonical.clone()) } /// Inserts an alias edge `alias -> canonical`, flattening `canonical` @@ -38,7 +41,11 @@ impl AliasMap { /// `ClogError::AliasCycle` if the flattened target equals `alias` /// (covers both reverse edges, e.g. inserting `b -> a` after `a -> b`, /// and self-loops, e.g. `z -> z`). - pub(crate) fn insert(&mut self, alias: EntityKey, canonical: EntityKey) -> Result<(), ClogError> { + pub(crate) fn insert( + &mut self, + alias: EntityKey, + canonical: EntityKey, + ) -> Result<(), ClogError> { let flattened = self.flatten_target(&canonical); if flattened == alias { return Err(ClogError::AliasCycle); @@ -104,8 +111,14 @@ mod tests { fn u_alias_2_cycle_rejected() { let mut m = AliasMap::default(); m.insert(k("p", "a"), k("p", "b")).unwrap(); - assert!(matches!(m.insert(k("p", "b"), k("p", "a")), Err(crate::ClogError::AliasCycle))); - assert!(matches!(m.insert(k("p", "z"), k("p", "z")), Err(crate::ClogError::AliasCycle))); + assert!(matches!( + m.insert(k("p", "b"), k("p", "a")), + Err(crate::ClogError::AliasCycle) + )); + assert!(matches!( + m.insert(k("p", "z"), k("p", "z")), + Err(crate::ClogError::AliasCycle) + )); } #[test] diff --git a/examples/clog/src/belief.rs b/examples/clog/src/belief.rs index c7f11f7..e86beb7 100644 --- a/examples/clog/src/belief.rs +++ b/examples/clog/src/belief.rs @@ -18,7 +18,15 @@ pub(crate) struct BeliefInput<'a> { /// The §5.3 total order as a max-key: later `occurred_at` wins; tie goes to /// better reliability, then better credibility, then later `recorded_at`, /// then the lexicographically larger `claim_key`. -pub(crate) fn belief_key(c: &BeliefInput) -> (u64, std::cmp::Reverse, std::cmp::Reverse, u64, String) { +pub(crate) fn belief_key( + c: &BeliefInput, +) -> ( + u64, + std::cmp::Reverse, + std::cmp::Reverse, + u64, + String, +) { ( c.claim.occurred_at, std::cmp::Reverse(c.claim.reliability.rank()), @@ -37,7 +45,10 @@ pub(crate) fn belief_key(c: &BeliefInput) -> (u64, std::cmp::Reverse, std::c /// returned. Otherwise the winner is the max by [`belief_key`] among the /// eligible members. pub(crate) fn resolve<'a>(group: &[BeliefInput<'a>], floor: Credibility) -> Option<&'a Claim> { - let eligible: Vec<&BeliefInput<'a>> = group.iter().filter(|c| c.claim.credibility.rank() <= floor.rank()).collect(); + let eligible: Vec<&BeliefInput<'a>> = group + .iter() + .filter(|c| c.claim.credibility.rank() <= floor.rank()) + .collect(); if eligible.is_empty() { return if group.len() == 1 { @@ -47,7 +58,10 @@ pub(crate) fn resolve<'a>(group: &[BeliefInput<'a>], floor: Credibility) -> Opti }; } - eligible.into_iter().max_by_key(|c| belief_key(c)).map(|c| c.claim) + eligible + .into_iter() + .max_by_key(|c| belief_key(c)) + .map(|c| c.claim) } #[cfg(test)] @@ -68,8 +82,8 @@ mod tests { #[test] fn u_belief_1_total_order() { - use Reliability::*; use Credibility::*; + use Reliability::*; // each later claim beats all before it, per one tier of the order let a = claim("a", 100, F, Six); // baseline let b = claim("b", 100, F, Five); // better credibility @@ -78,14 +92,38 @@ mod tests { let claims = [&a, &b, &c, &d]; // recorded_at all equal; exhaustive permutations of arrival order for perm in permutations(&claims) { - let group: Vec = perm.iter().map(|c| BeliefInput { claim: c, recorded_at: 1 }).collect(); + let group: Vec = perm + .iter() + .map(|c| BeliefInput { + claim: c, + recorded_at: 1, + }) + .collect(); assert_eq!(resolve(&group, Credibility::Six).unwrap().claim_key, "d"); } // tie on everything but recorded_at - let g = [BeliefInput { claim: &a, recorded_at: 5 }, BeliefInput { claim: &b0(&a, "a2"), recorded_at: 9 }]; + let g = [ + BeliefInput { + claim: &a, + recorded_at: 5, + }, + BeliefInput { + claim: &b0(&a, "a2"), + recorded_at: 9, + }, + ]; assert_eq!(resolve(&g, Credibility::Six).unwrap().claim_key, "a2"); // full tie -> lexicographically larger claim_key - let g = [BeliefInput { claim: &a, recorded_at: 5 }, BeliefInput { claim: &b0(&a, "z"), recorded_at: 5 }]; + let g = [ + BeliefInput { + claim: &a, + recorded_at: 5, + }, + BeliefInput { + claim: &b0(&a, "z"), + recorded_at: 5, + }, + ]; assert_eq!(resolve(&g, Credibility::Six).unwrap().claim_key, "z"); } @@ -113,18 +151,39 @@ mod tests { #[test] fn u_belief_2_credibility_floor() { - use Reliability::*; use Credibility::*; + use Reliability::*; let good = claim("good", 100, A, Two); let bad = claim("bad", 200, A, Five); // newer but below floor Three - let g = [BeliefInput { claim: &good, recorded_at: 1 }, BeliefInput { claim: &bad, recorded_at: 2 }]; + let g = [ + BeliefInput { + claim: &good, + recorded_at: 1, + }, + BeliefInput { + claim: &bad, + recorded_at: 2, + }, + ]; assert_eq!(resolve(&g, Three).unwrap().claim_key, "good"); // only-claim exception - let g = [BeliefInput { claim: &bad, recorded_at: 2 }]; + let g = [BeliefInput { + claim: &bad, + recorded_at: 2, + }]; assert_eq!(resolve(&g, Three).unwrap().claim_key, "bad"); // all floored, >= 2 members -> nobody believed let bad2 = claim("bad2", 300, A, Six); - let g = [BeliefInput { claim: &bad, recorded_at: 2 }, BeliefInput { claim: &bad2, recorded_at: 3 }]; + let g = [ + BeliefInput { + claim: &bad, + recorded_at: 2, + }, + BeliefInput { + claim: &bad2, + recorded_at: 3, + }, + ]; assert!(resolve(&g, Three).is_none()); } } diff --git a/examples/clog/src/clock.rs b/examples/clog/src/clock.rs index d5a798d..88e2f37 100644 --- a/examples/clog/src/clock.rs +++ b/examples/clog/src/clock.rs @@ -59,7 +59,9 @@ impl Clock { // `fetch_update` rather than `fetch_add` so the saturation is // atomic too: two concurrent advances can never wrap. counter - .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |now| Some(now.saturating_add(ms))) + .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |now| { + Some(now.saturating_add(ms)) + }) .unwrap_or(0) .saturating_add(ms) } diff --git a/examples/clog/src/engine/mod.rs b/examples/clog/src/engine/mod.rs index c27259c..9a5f01d 100644 --- a/examples/clog/src/engine/mod.rs +++ b/examples/clog/src/engine/mod.rs @@ -40,7 +40,10 @@ pub(crate) const MERGE_SEP: char = '\u{1f}'; /// This key is the merge's public handle — retracting it is how a host /// un-merges (spec §5.2) — so it is built here and nowhere else. pub(crate) fn merge_key(alias: &EntityKey, canonical: &EntityKey) -> String { - format!("{MERGE_PREFIX}{}:{}->{}:{}", alias.0, alias.1, canonical.0, canonical.1) + format!( + "{MERGE_PREFIX}{}:{}->{}:{}", + alias.0, alias.1, canonical.0, canonical.1 + ) } /// The `body` of that claim: the four entity fields joined with @@ -181,7 +184,12 @@ pub(crate) trait Engine: Send { /// Applies `events` to the materialized views, using `scopes` to /// recompute per-scope derived state (e.g. `urgent`) and `now_ms` as the /// clock for recency-sensitive computations. - fn apply(&mut self, events: &[Event], scopes: &BTreeMap, now_ms: u64) -> ApplyResult; + fn apply( + &mut self, + events: &[Event], + scopes: &BTreeMap, + now_ms: u64, + ) -> ApplyResult; /// Borrows the current materialized-view snapshot. fn views(&self) -> &WorldViews; } @@ -197,9 +205,19 @@ mod tests { rev: 3, as_of: 1_700_000_000_000, events: vec![ - Event::Observe(StoredClaim { claim: tests_base_claim(), recorded_at: 9 }), - Event::Retract { claim_key: "k1".into() }, - Event::Judge { claim_key: "k1".into(), kind: "risk".into(), confidence: 1.0, source: crate::JudgeSource::Rule }, + Event::Observe(StoredClaim { + claim: tests_base_claim(), + recorded_at: 9, + }), + Event::Retract { + claim_key: "k1".into(), + }, + Event::Judge { + claim_key: "k1".into(), + kind: "risk".into(), + confidence: 1.0, + source: crate::JudgeSource::Rule, + }, Event::Tick { epoch: 4 }, ], }; diff --git a/examples/clog/src/engine/naive.rs b/examples/clog/src/engine/naive.rs index 80d98d8..cfa7664 100644 --- a/examples/clog/src/engine/naive.rs +++ b/examples/clog/src/engine/naive.rs @@ -48,7 +48,10 @@ fn merge_edge(claim: &Claim) -> Option<(EntityKey, EntityKey)> { let [alias_etype, alias_id, canonical_etype, canonical_id] = parts[..] else { return None; }; - if [alias_etype, alias_id, canonical_etype, canonical_id].iter().any(|f| f.is_empty()) { + if [alias_etype, alias_id, canonical_etype, canonical_id] + .iter() + .any(|f| f.is_empty()) + { return None; } Some(( @@ -96,7 +99,10 @@ pub(crate) struct NaiveEngine { impl NaiveEngine { /// An engine over an empty world. pub(crate) fn new(cfg: NaiveCfg) -> Self { - NaiveEngine { views: WorldViews::default(), cfg } + NaiveEngine { + views: WorldViews::default(), + cfg, + } } // ---- events ------------------------------------------------------- @@ -106,7 +112,11 @@ impl NaiveEngine { /// the old version's indexes, kind and alias edge are dropped first. fn observe(&mut self, sc: &StoredClaim) -> bool { let key = sc.claim.claim_key.clone(); - let previous_subject = self.views.claims.get(&key).and_then(|old| old.claim.subject_key.clone()); + let previous_subject = self + .views + .claims + .get(&key) + .and_then(|old| old.claim.subject_key.clone()); self.remove_live(&key); self.views.claims.insert(key.clone(), sc.clone()); @@ -146,7 +156,11 @@ impl NaiveEngine { } self.views.kinds.insert( claim_key.to_string(), - KindLabel { kind: kind.to_string(), confidence, source }, + KindLabel { + kind: kind.to_string(), + confidence, + source, + }, ); self.recompute_membership(claim_key); true @@ -158,7 +172,9 @@ impl NaiveEngine { /// `claim_key` order and the resulting views are replay-deterministic /// (INV-11). Returns whether any live claim was removed. fn revoke(&mut self, observer: &ObserverId) -> bool { - let Some(keys) = self.views.by_observer.get(&observer.0) else { return false }; + let Some(keys) = self.views.by_observer.get(&observer.0) else { + return false; + }; let keys: Vec = keys.iter().cloned().collect(); let mut touched = false; for key in keys { @@ -173,7 +189,9 @@ impl NaiveEngine { /// merge claim (U-ALIAS-3), and heals the derived registries. Returns /// whether a live claim was removed. fn remove_live(&mut self, claim_key: &str) -> bool { - let Some(old) = self.views.claims.remove(claim_key) else { return false }; + let Some(old) = self.views.claims.remove(claim_key) else { + return false; + }; self.unindex_claim(&old.claim); self.views.kinds.remove(claim_key); self.views.unclassified.remove(claim_key); @@ -196,9 +214,17 @@ impl NaiveEngine { /// entity. fn reindex_claim(&mut self, claim: &Claim) { if let Some(subject) = &claim.subject_key { - index_add(&mut self.views.by_subject, subject.clone(), &claim.claim_key); + index_add( + &mut self.views.by_subject, + subject.clone(), + &claim.claim_key, + ); } - index_add(&mut self.views.by_observer, claim.observer.0.clone(), &claim.claim_key); + index_add( + &mut self.views.by_observer, + claim.observer.0.clone(), + &claim.claim_key, + ); for e in &claim.entities { let canonical = self.views.aliases.resolve(&e.key()); index_add(&mut self.views.by_entity, canonical, &claim.claim_key); @@ -212,7 +238,11 @@ impl NaiveEngine { if let Some(subject) = &claim.subject_key { index_remove(&mut self.views.by_subject, subject, &claim.claim_key); } - index_remove(&mut self.views.by_observer, &claim.observer.0, &claim.claim_key); + index_remove( + &mut self.views.by_observer, + &claim.observer.0, + &claim.claim_key, + ); for e in &claim.entities { let canonical = self.views.aliases.resolve(&e.key()); index_remove(&mut self.views.by_entity, &canonical, &claim.claim_key); @@ -276,7 +306,9 @@ impl NaiveEngine { for e in &sc.claim.entities { let Some(name) = &e.name else { continue }; let canonical = self.views.aliases.resolve(&e.key()); - let newer = names.get(&canonical).is_none_or(|(at, _)| sc.recorded_at >= *at); + let newer = names + .get(&canonical) + .is_none_or(|(at, _)| sc.recorded_at >= *at); if newer { names.insert(canonical, (sc.recorded_at, name.clone())); } @@ -305,7 +337,10 @@ impl NaiveEngine { } let group: Vec = members .iter() - .map(|sc| BeliefInput { claim: &sc.claim, recorded_at: sc.recorded_at }) + .map(|sc| BeliefInput { + claim: &sc.claim, + recorded_at: sc.recorded_at, + }) .collect(); let winner = belief::resolve(&group, self.cfg.belief_floor).map(|c| c.claim_key.clone()); self.views.believed.insert(subject.to_string(), winner); @@ -366,11 +401,21 @@ impl NaiveEngine { .iter() .filter(|(key, _)| !is_reserved(key)) .map(|(key, sc)| { - let entities: Vec = - sc.claim.entities.iter().map(|e| self.views.aliases.resolve(&e.key())).collect(); + let entities: Vec = sc + .claim + .entities + .iter() + .map(|e| self.views.aliases.resolve(&e.key())) + .collect(); let kind = self.views.kinds.get(key).map(|l| l.kind.as_str()); - let score = - score_claim(&sc.claim, kind, focus, &entities, now_ms, self.cfg.buckets_per_half_life); + let score = score_claim( + &sc.claim, + kind, + focus, + &entities, + now_ms, + self.cfg.buckets_per_half_life, + ); (score, key.clone()) }) .collect(); @@ -433,7 +478,10 @@ pub(crate) fn entity_state(views: &WorldViews, cap: Option) -> Vec) -> Vec, now_ms: u64) -> ApplyResult { + fn apply( + &mut self, + events: &[Event], + scopes: &BTreeMap, + now_ms: u64, + ) -> ApplyResult { let mut touched = false; for event in events { let effect = match event { Event::Observe(sc) => self.observe(sc), Event::Retract { claim_key } => self.retract(claim_key), - Event::Judge { claim_key, kind, confidence, source } => { - self.judge(claim_key, kind, *confidence, *source) - } + Event::Judge { + claim_key, + kind, + confidence, + source, + } => self.judge(claim_key, kind, *confidence, *source), Event::Revoke { observer } => self.revoke(observer), // The actor owns `scopes` and the clock, and only forwards a // `Tick` when a claim actually crossed a decay bucket (§5.5), @@ -479,8 +535,12 @@ mod tests { use std::collections::BTreeMap; fn cfg() -> NaiveCfg { - NaiveCfg { loop_kinds: vec!["question".into(), "risk".into(), "commitment".into()], - top_k: 12, buckets_per_half_life: 4, belief_floor: Credibility::Six } + NaiveCfg { + loop_kinds: vec!["question".into(), "risk".into(), "commitment".into()], + top_k: 12, + buckets_per_half_life: 4, + belief_floor: Credibility::Six, + } } fn scopes() -> BTreeMap { BTreeMap::from([("default".to_string(), Focus::uniform())]) @@ -490,8 +550,12 @@ mod tests { c.claim_key = key.into(); c.subject_key = subject.map(Into::into); c.body = body.into(); - c.occurred_at = occ; c.observed_at = occ; - Event::Observe(StoredClaim { claim: c, recorded_at: rec }) + c.occurred_at = occ; + c.observed_at = occ; + Event::Observe(StoredClaim { + claim: c, + recorded_at: rec, + }) } #[test] @@ -501,12 +565,21 @@ mod tests { e.apply(&[obs("a", Some("s1"), "hello", 10, 10)], &scopes(), 1000); assert!(e.views().claims.contains_key("a")); assert_eq!(e.views().believed.get("s1"), Some(&Some("a".to_string()))); - e.apply(&[Event::Retract { claim_key: "a".into() }], &scopes(), 1001); + e.apply( + &[Event::Retract { + claim_key: "a".into(), + }], + &scopes(), + 1001, + ); // INV-3: identical to never having observed (view contents, not revs) assert!(e.views().claims.is_empty()); assert!(e.views().believed.is_empty()); assert!(e.views().by_subject.is_empty()); - assert_eq!(e.views().urgent.get("default").map(Vec::len), empty.urgent.get("default").map(Vec::len).or(Some(0))); + assert_eq!( + e.views().urgent.get("default").map(Vec::len), + empty.urgent.get("default").map(Vec::len).or(Some(0)) + ); } // Beyond the brief's five: INV-3 says retraction heals *everything*, and @@ -518,13 +591,42 @@ mod tests { c.claim_key = "a".into(); c.subject_key = Some("s1".into()); c.observer = ObserverId::from("gmail"); - c.entities = vec![EntityRef { etype: "p".into(), id: "sam".into(), name: Some("Sam".into()) }]; - e.apply(&[Event::Observe(StoredClaim { claim: c, recorded_at: 1 })], &scopes(), 100); - e.apply(&[Event::Judge { claim_key: "a".into(), kind: "risk".into(), confidence: 1.0, source: JudgeSource::Rule }], &scopes(), 101); - assert_eq!(e.views().names.get(&("p".to_string(), "sam".to_string())), Some(&(1, "Sam".to_string()))); + c.entities = vec![EntityRef { + etype: "p".into(), + id: "sam".into(), + name: Some("Sam".into()), + }]; + e.apply( + &[Event::Observe(StoredClaim { + claim: c, + recorded_at: 1, + })], + &scopes(), + 100, + ); + e.apply( + &[Event::Judge { + claim_key: "a".into(), + kind: "risk".into(), + confidence: 1.0, + source: JudgeSource::Rule, + }], + &scopes(), + 101, + ); + assert_eq!( + e.views().names.get(&("p".to_string(), "sam".to_string())), + Some(&(1, "Sam".to_string())) + ); assert!(e.views().open_loops.contains("a")); - e.apply(&[Event::Retract { claim_key: "a".into() }], &scopes(), 102); + e.apply( + &[Event::Retract { + claim_key: "a".into(), + }], + &scopes(), + 102, + ); assert!(e.views().by_entity.is_empty()); assert!(e.views().by_observer.is_empty()); assert!(e.views().names.is_empty()); @@ -536,8 +638,14 @@ mod tests { #[test] fn belief_competition_and_flags() { let mut e = NaiveEngine::new(cfg()); - e.apply(&[obs("old", Some("s1"), "invoice overdue", 100, 1), - obs("new", Some("s1"), "invoice paid", 200, 2)], &scopes(), 1000); + e.apply( + &[ + obs("old", Some("s1"), "invoice overdue", 100, 1), + obs("new", Some("s1"), "invoice paid", 200, 2), + ], + &scopes(), + 1000, + ); assert_eq!(e.views().believed.get("s1"), Some(&Some("new".to_string()))); } @@ -547,7 +655,16 @@ mod tests { e.apply(&[obs("a", None, "x", 10, 10)], &scopes(), 1000); assert!(e.views().unclassified.contains("a")); assert!(!e.views().open_loops.contains("a")); - e.apply(&[Event::Judge { claim_key: "a".into(), kind: "risk".into(), confidence: 1.0, source: JudgeSource::Rule }], &scopes(), 1001); + e.apply( + &[Event::Judge { + claim_key: "a".into(), + kind: "risk".into(), + confidence: 1.0, + source: JudgeSource::Rule, + }], + &scopes(), + 1001, + ); assert!(!e.views().unclassified.contains("a")); assert!(e.views().open_loops.contains("a")); assert_eq!(e.views().kinds.get("a").unwrap().kind, "risk"); @@ -557,9 +674,16 @@ mod tests { fn urgent_ranked_desc_tiebreak_key() { let mut e = NaiveEngine::new(cfg()); // same trust/recency -> equal scores -> claim_key asc breaks tie - e.apply(&[obs("b", None, "x", 100, 1), obs("a", None, "y", 100, 1)], &scopes(), 200); + e.apply( + &[obs("b", None, "x", 100, 1), obs("a", None, "y", 100, 1)], + &scopes(), + 200, + ); let u = e.views().urgent.get("default").unwrap(); - assert_eq!(u.iter().map(|(_, k)| k.as_str()).collect::>(), vec!["a", "b"]); + assert_eq!( + u.iter().map(|(_, k)| k.as_str()).collect::>(), + vec!["a", "b"] + ); } #[test] @@ -574,14 +698,26 @@ mod tests { // INV-8 covers `kinds` too: a reserved claim cannot be judged into a view. let key = crate::engine::merge_key(&alias, &canonical); - e.apply(&[Event::Judge { claim_key: key, kind: "risk".into(), confidence: 1.0, source: JudgeSource::Rule }], &scopes(), 101); + e.apply( + &[Event::Judge { + claim_key: key, + kind: "risk".into(), + confidence: 1.0, + source: JudgeSource::Rule, + }], + &scopes(), + 101, + ); assert!(e.views().kinds.is_empty()); assert!(e.views().open_loops.is_empty()); } /// The `(alias, canonical)` entity-key pair a test names as two tuples. fn edge(alias: (&str, &str), canonical: (&str, &str)) -> (EntityKey, EntityKey) { - ((alias.0.into(), alias.1.into()), (canonical.0.into(), canonical.1.into())) + ( + (alias.0.into(), alias.1.into()), + (canonical.0.into(), canonical.1.into()), + ) } /// A merge claim carrying the edge `{alias} -> {canonical}`, built through @@ -592,19 +728,49 @@ mod tests { m.claim_key = crate::engine::merge_key(&alias, &canonical); m.observer = ObserverId::from("clog"); m.body = crate::engine::merge_body(&alias, &canonical); - Event::Observe(StoredClaim { claim: m, recorded_at: 1 }) + Event::Observe(StoredClaim { + claim: m, + recorded_at: 1, + }) } #[test] fn revoke_retracts_all_of_observer_inv6() { let mut e = NaiveEngine::new(cfg()); - let mut c1 = tests_base_claim(); c1.claim_key = "a".into(); c1.observer = ObserverId::from("gmail"); - let mut c2 = tests_base_claim(); c2.claim_key = "b".into(); c2.observer = ObserverId::from("gmail"); - let mut c3 = tests_base_claim(); c3.claim_key = "c".into(); c3.observer = ObserverId::from("twist"); - e.apply(&[Event::Observe(StoredClaim { claim: c1, recorded_at: 1 }), - Event::Observe(StoredClaim { claim: c2, recorded_at: 1 }), - Event::Observe(StoredClaim { claim: c3, recorded_at: 1 })], &scopes(), 100); - e.apply(&[Event::Revoke { observer: ObserverId::from("gmail") }], &scopes(), 101); + let mut c1 = tests_base_claim(); + c1.claim_key = "a".into(); + c1.observer = ObserverId::from("gmail"); + let mut c2 = tests_base_claim(); + c2.claim_key = "b".into(); + c2.observer = ObserverId::from("gmail"); + let mut c3 = tests_base_claim(); + c3.claim_key = "c".into(); + c3.observer = ObserverId::from("twist"); + e.apply( + &[ + Event::Observe(StoredClaim { + claim: c1, + recorded_at: 1, + }), + Event::Observe(StoredClaim { + claim: c2, + recorded_at: 1, + }), + Event::Observe(StoredClaim { + claim: c3, + recorded_at: 1, + }), + ], + &scopes(), + 100, + ); + e.apply( + &[Event::Revoke { + observer: ObserverId::from("gmail"), + }], + &scopes(), + 101, + ); assert!(!e.views().claims.contains_key("a")); assert!(!e.views().claims.contains_key("b")); assert!(e.views().claims.contains_key("c")); @@ -619,17 +785,52 @@ mod tests { let mut c = tests_base_claim(); c.claim_key = "about-a".into(); c.observer = ObserverId::from("clog"); - c.entities = vec![EntityRef { etype: "p".into(), id: "a".into(), name: None }]; - e.apply(&[Event::Observe(StoredClaim { claim: c, recorded_at: 1 }), merge(("p", "a"), ("p", "b"))], &scopes(), 100); - assert_eq!(e.views().aliases.resolve(&("p".into(), "a".into())), ("p".into(), "b".into())); + c.entities = vec![EntityRef { + etype: "p".into(), + id: "a".into(), + name: None, + }]; + e.apply( + &[ + Event::Observe(StoredClaim { + claim: c, + recorded_at: 1, + }), + merge(("p", "a"), ("p", "b")), + ], + &scopes(), + 100, + ); + assert_eq!( + e.views().aliases.resolve(&("p".into(), "a".into())), + ("p".into(), "b".into()) + ); - assert!(!e.apply(&[Event::Revoke { observer: ObserverId::from("nobody") }], &scopes(), 101).touched); - e.apply(&[Event::Revoke { observer: ObserverId::from("clog") }], &scopes(), 102); + assert!( + !e.apply( + &[Event::Revoke { + observer: ObserverId::from("nobody") + }], + &scopes(), + 101 + ) + .touched + ); + e.apply( + &[Event::Revoke { + observer: ObserverId::from("clog"), + }], + &scopes(), + 102, + ); assert!(e.views().claims.is_empty()); assert!(e.views().by_observer.is_empty()); assert!(e.views().by_entity.is_empty()); // the merge claim went with it, so the alias edge did too (INV-3) - assert_eq!(e.views().aliases.resolve(&("p".into(), "a".into())), ("p".into(), "a".into())); + assert_eq!( + e.views().aliases.resolve(&("p".into(), "a".into())), + ("p".into(), "a".into()) + ); } #[test] @@ -637,20 +838,52 @@ mod tests { let mut e = NaiveEngine::new(cfg()); let mut c = tests_base_claim(); c.claim_key = "about-a".into(); - c.entities = vec![EntityRef { etype: "p".into(), id: "a".into(), name: Some("Aye".into()) }]; - e.apply(&[Event::Observe(StoredClaim { claim: c, recorded_at: 1 })], &scopes(), 100); + c.entities = vec![EntityRef { + etype: "p".into(), + id: "a".into(), + name: Some("Aye".into()), + }]; + e.apply( + &[Event::Observe(StoredClaim { + claim: c, + recorded_at: 1, + })], + &scopes(), + 100, + ); let (alias, canonical) = edge(("p", "a"), ("p", "b")); e.apply(&[merge(("p", "a"), ("p", "b"))], &scopes(), 101); // grouped under canonical b now - assert!(e.views().by_entity.get(&("p".into(), "b".into())).unwrap().contains("about-a")); + assert!( + e.views() + .by_entity + .get(&("p".into(), "b".into())) + .unwrap() + .contains("about-a") + ); assert!(e.views().by_entity.get(&("p".into(), "a".into())).is_none()); - e.apply(&[Event::Retract { claim_key: crate::engine::merge_key(&alias, &canonical) }], &scopes(), 102); + e.apply( + &[Event::Retract { + claim_key: crate::engine::merge_key(&alias, &canonical), + }], + &scopes(), + 102, + ); // un-merged: re-keyed back under a, name registry intact - assert!(e.views().by_entity.get(&("p".into(), "a".into())).unwrap().contains("about-a")); + assert!( + e.views() + .by_entity + .get(&("p".into(), "a".into())) + .unwrap() + .contains("about-a") + ); let es = entity_state(e.views(), Some(ENTITY_STATE_ROWS)); - let (_, display, rows) = es.iter().find(|(k, _, _)| k == &("p".to_string(), "a".to_string())).unwrap(); + let (_, display, rows) = es + .iter() + .find(|(k, _, _)| k == &("p".to_string(), "a".to_string())) + .unwrap(); assert_eq!(display, "Aye"); assert_eq!(rows.len(), 0); // no subject_key -> no believed rows } @@ -662,12 +895,31 @@ mod tests { #[test] fn u_alias_3_retracting_a_flattening_merge_restores_the_earlier_edge() { let mut e = NaiveEngine::new(cfg()); - e.apply(&[merge(("p", "a"), ("p", "b")), merge(("p", "b"), ("p", "c"))], &scopes(), 100); - assert_eq!(e.views().aliases.resolve(&("p".into(), "a".into())), ("p".into(), "c".into())); + e.apply( + &[merge(("p", "a"), ("p", "b")), merge(("p", "b"), ("p", "c"))], + &scopes(), + 100, + ); + assert_eq!( + e.views().aliases.resolve(&("p".into(), "a".into())), + ("p".into(), "c".into()) + ); - e.apply(&[Event::Retract { claim_key: "clog:merge:p:b->p:c".into() }], &scopes(), 101); - assert_eq!(e.views().aliases.resolve(&("p".into(), "a".into())), ("p".into(), "b".into())); - assert_eq!(e.views().aliases.resolve(&("p".into(), "b".into())), ("p".into(), "b".into())); + e.apply( + &[Event::Retract { + claim_key: "clog:merge:p:b->p:c".into(), + }], + &scopes(), + 101, + ); + assert_eq!( + e.views().aliases.resolve(&("p".into(), "a".into())), + ("p".into(), "b".into()) + ); + assert_eq!( + e.views().aliases.resolve(&("p".into(), "b".into())), + ("p".into(), "b".into()) + ); } // Ruling A, case 2: two live merge claims can name the same alias. The @@ -678,14 +930,46 @@ mod tests { let mut e = NaiveEngine::new(cfg()); let mut c = tests_base_claim(); c.claim_key = "about-a".into(); - c.entities = vec![EntityRef { etype: "p".into(), id: "a".into(), name: None }]; - e.apply(&[Event::Observe(StoredClaim { claim: c, recorded_at: 1 }), - merge(("p", "a"), ("p", "b")), merge(("p", "a"), ("p", "c"))], &scopes(), 100); - assert_eq!(e.views().aliases.resolve(&("p".into(), "a".into())), ("p".into(), "c".into())); - - e.apply(&[Event::Retract { claim_key: "clog:merge:p:a->p:c".into() }], &scopes(), 101); - assert_eq!(e.views().aliases.resolve(&("p".into(), "a".into())), ("p".into(), "b".into())); - assert!(e.views().by_entity.get(&("p".into(), "b".into())).unwrap().contains("about-a")); + c.entities = vec![EntityRef { + etype: "p".into(), + id: "a".into(), + name: None, + }]; + e.apply( + &[ + Event::Observe(StoredClaim { + claim: c, + recorded_at: 1, + }), + merge(("p", "a"), ("p", "b")), + merge(("p", "a"), ("p", "c")), + ], + &scopes(), + 100, + ); + assert_eq!( + e.views().aliases.resolve(&("p".into(), "a".into())), + ("p".into(), "c".into()) + ); + + e.apply( + &[Event::Retract { + claim_key: "clog:merge:p:a->p:c".into(), + }], + &scopes(), + 101, + ); + assert_eq!( + e.views().aliases.resolve(&("p".into(), "a".into())), + ("p".into(), "b".into()) + ); + assert!( + e.views() + .by_entity + .get(&("p".into(), "b".into())) + .unwrap() + .contains("about-a") + ); assert!(e.views().by_entity.get(&("p".into(), "a".into())).is_none()); } @@ -696,28 +980,65 @@ mod tests { let mut e = NaiveEngine::new(cfg()); let mut c = tests_base_claim(); c.claim_key = "about-a".into(); - c.entities = vec![EntityRef { etype: "p".into(), id: "a".into(), name: None }]; - let ent = |id: &str| EntityRef { etype: "p".into(), id: id.into(), name: None }; + c.entities = vec![EntityRef { + etype: "p".into(), + id: "a".into(), + name: None, + }]; + let ent = |id: &str| EntityRef { + etype: "p".into(), + id: id.into(), + name: None, + }; let scopes = BTreeMap::from([ ("plain".to_string(), Focus::uniform()), ("alias".to_string(), Focus::uniform().boost(ent("a"), 3.0)), - ("canonical".to_string(), Focus::uniform().boost(ent("b"), 3.0)), + ( + "canonical".to_string(), + Focus::uniform().boost(ent("b"), 3.0), + ), ]); - e.apply(&[Event::Observe(StoredClaim { claim: c, recorded_at: 1 }), merge(("p", "a"), ("p", "b"))], &scopes, 100); + e.apply( + &[ + Event::Observe(StoredClaim { + claim: c, + recorded_at: 1, + }), + merge(("p", "a"), ("p", "b")), + ], + &scopes, + 100, + ); let score = |scope: &str| { - e.views().urgent.get(scope).unwrap().iter().find(|(_, k)| k == "about-a").unwrap().0 + e.views() + .urgent + .get(scope) + .unwrap() + .iter() + .find(|(_, k)| k == "about-a") + .unwrap() + .0 }; let plain = score("plain"); assert!(plain > 0.0); - assert!((score("alias") - plain * 3.0).abs() < 1e-6, "{} vs {}", score("alias"), plain * 3.0); + assert!( + (score("alias") - plain * 3.0).abs() < 1e-6, + "{} vs {}", + score("alias"), + plain * 3.0 + ); assert!((score("canonical") - plain * 3.0).abs() < 1e-6); } #[test] fn entity_state_newest_first_capped() { let mut e = NaiveEngine::new(cfg()); - let ent = EntityRef { etype: "proj".into(), id: "h".into(), name: None }; + let ent = EntityRef { + etype: "proj".into(), + id: "h".into(), + name: None, + }; let mut evs = vec![]; for i in 0..10 { let mut c = tests_base_claim(); @@ -725,7 +1046,10 @@ mod tests { c.subject_key = Some(format!("s{i}")); c.occurred_at = 100 + i; c.entities = vec![ent.clone()]; - evs.push(Event::Observe(StoredClaim { claim: c, recorded_at: 1 })); + evs.push(Event::Observe(StoredClaim { + claim: c, + recorded_at: 1, + })); } e.apply(&evs, &scopes(), 1000); let es = entity_state(e.views(), Some(ENTITY_STATE_ROWS)); @@ -740,21 +1064,47 @@ mod tests { #[test] fn entity_state_reports_believed_rows_under_the_canonical_entity() { let mut e = NaiveEngine::new(cfg()); - let ent = |id: &str| EntityRef { etype: "p".into(), id: id.into(), name: None }; + let ent = |id: &str| EntityRef { + etype: "p".into(), + id: id.into(), + name: None, + }; let mut old = tests_base_claim(); - old.claim_key = "old".into(); old.subject_key = Some("s1".into()); old.occurred_at = 100; + old.claim_key = "old".into(); + old.subject_key = Some("s1".into()); + old.occurred_at = 100; old.entities = vec![ent("a")]; let mut new = tests_base_claim(); - new.claim_key = "new".into(); new.subject_key = Some("s1".into()); new.occurred_at = 200; + new.claim_key = "new".into(); + new.subject_key = Some("s1".into()); + new.occurred_at = 200; new.entities = vec![ent("a")]; - e.apply(&[Event::Observe(StoredClaim { claim: old, recorded_at: 1 }), - Event::Observe(StoredClaim { claim: new, recorded_at: 2 })], &scopes(), 1000); + e.apply( + &[ + Event::Observe(StoredClaim { + claim: old, + recorded_at: 1, + }), + Event::Observe(StoredClaim { + claim: new, + recorded_at: 2, + }), + ], + &scopes(), + 1000, + ); let es = entity_state(e.views(), Some(ENTITY_STATE_ROWS)); assert_eq!(es.len(), 1); assert_eq!(es[0].0, ("p".to_string(), "a".to_string())); // one row per subject: the believed claim, not the losing one - assert_eq!(es[0].2.iter().map(|(s, sc)| (s.as_str(), sc.claim.claim_key.as_str())).collect::>(), - vec![("s1", "new")]); + assert_eq!( + es[0] + .2 + .iter() + .map(|(s, sc)| (s.as_str(), sc.claim.claim_key.as_str())) + .collect::>(), + vec![("s1", "new")] + ); e.apply(&[merge(("p", "a"), ("p", "b"))], &scopes(), 1001); let es = entity_state(e.views(), Some(ENTITY_STATE_ROWS)); diff --git a/examples/clog/src/kinds.rs b/examples/clog/src/kinds.rs index f09f3ff..06014ad 100644 --- a/examples/clog/src/kinds.rs +++ b/examples/clog/src/kinds.rs @@ -71,8 +71,9 @@ pub(crate) fn compile(tax: &KindTaxonomy) -> Result { Ok(match m { Matcher::BodyContains(s) => CompiledMatcher::BodyContains(s.to_lowercase()), Matcher::BodyRegex(pat) => { - let re = Regex::new(pat) - .map_err(|e| ClogError::Corrupt { detail: format!("config: bad regex {pat:?}: {e}") })?; + let re = Regex::new(pat).map_err(|e| ClogError::Corrupt { + detail: format!("config: bad regex {pat:?}: {e}"), + })?; CompiledMatcher::BodyRegex(re) } Matcher::ObserverIs(s) => CompiledMatcher::ObserverIs(s.clone()), @@ -81,7 +82,11 @@ pub(crate) fn compile(tax: &KindTaxonomy) -> Result { } fn compile_rule(r: &Rule) -> Result { - let any_of = r.any_of.iter().map(compile_matcher).collect::, _>>()?; + let any_of = r + .any_of + .iter() + .map(compile_matcher) + .collect::, _>>()?; Ok(CompiledRule { any_of }) } @@ -89,7 +94,11 @@ pub(crate) fn compile(tax: &KindTaxonomy) -> Result { .kinds .iter() .map(|kd| -> Result<(String, Vec), ClogError> { - let rules = kd.rules.iter().map(compile_rule).collect::, _>>()?; + let rules = kd + .rules + .iter() + .map(compile_rule) + .collect::, _>>()?; Ok((kd.name.clone(), rules)) }) .collect::, _>>()?; @@ -107,7 +116,11 @@ pub(crate) fn classify(rs: &RuleSet, c: &Claim) -> Option { for (kind, rules) in &rs.kinds { for rule in rules { if rule.matches(c) { - return Some(KindLabel { kind: kind.clone(), confidence: 1.0, source: JudgeSource::Rule }); + return Some(KindLabel { + kind: kind.clone(), + confidence: 1.0, + source: JudgeSource::Rule, + }); } } } @@ -126,12 +139,18 @@ mod tests { // fact, decision, risk, question, ... — rules are evaluated in config order. for kd in &mut tax.kinds { match kd.name.as_str() { - "risk" => kd.rules.push(Rule { any_of: vec![Matcher::BodyContains("overdue".into())] }), - "question" => kd.rules.push(Rule { any_of: vec![ - Matcher::BodyRegex(r"\?$".into()), - Matcher::ObserverIs("faq-bot".into()), - ] }), - "fact" => kd.rules.push(Rule { any_of: vec![Matcher::EntityType("bankfeed".into())] }), + "risk" => kd.rules.push(Rule { + any_of: vec![Matcher::BodyContains("overdue".into())], + }), + "question" => kd.rules.push(Rule { + any_of: vec![ + Matcher::BodyRegex(r"\?$".into()), + Matcher::ObserverIs("faq-bot".into()), + ], + }), + "fact" => kd.rules.push(Rule { + any_of: vec![Matcher::EntityType("bankfeed".into())], + }), _ => {} } } @@ -144,7 +163,11 @@ mod tests { let mut c = tests_base_claim(); // matches BOTH fact (entity type) and risk (body) -> fact wins (declared first) c.body = "Invoice 1042 is OVERDUE".into(); - c.entities = vec![EntityRef { etype: "bankfeed".into(), id: "x".into(), name: None }]; + c.entities = vec![EntityRef { + etype: "bankfeed".into(), + id: "x".into(), + name: None, + }]; let k = classify(&rs, &c).unwrap(); assert_eq!(k.kind, "fact"); assert_eq!(k.confidence, 1.0); @@ -171,7 +194,9 @@ mod tests { #[test] fn bad_regex_rejected_at_compile() { let mut tax = KindTaxonomy::default_taxonomy(); - tax.kinds[0].rules.push(Rule { any_of: vec![Matcher::BodyRegex("(".into())] }); + tax.kinds[0].rules.push(Rule { + any_of: vec![Matcher::BodyRegex("(".into())], + }); assert!(matches!(compile(&tax), Err(ClogError::Corrupt { .. }))); } } diff --git a/examples/clog/src/lib.rs b/examples/clog/src/lib.rs index 8d64fe0..258b484 100644 --- a/examples/clog/src/lib.rs +++ b/examples/clog/src/lib.rs @@ -68,10 +68,15 @@ pub(crate) mod actor; /// Dropping the last handle shuts the instance down: the writer finishes /// any in-flight batch, fsyncs the WAL and exits before `drop` returns. /// -/// ```no_run +/// The full host loop — open, observe, read a situation, retract a +/// correction, read again — also lives as a runnable example at +/// `examples/quickstart.rs` (`cargo run -p clog --example quickstart`): +/// +/// ``` /// use clog::*; /// -/// let handle = Clog::open(Config::default_for("/var/lib/my-agent/clog"))?; +/// let dir = tempfile::tempdir()?; +/// let handle = Clog::open(Config::default_for(dir.path()))?; /// handle.observe( /// vec![Claim { /// claim_key: "halcyon:inv-1042".into(), @@ -88,8 +93,15 @@ pub(crate) mod actor; /// }], /// ObserveOpts::default(), /// )?; -/// println!("{}", handle.situation(None, None)?.text); -/// # Ok::<(), ClogError>(()) +/// let before = handle.situation(None, None)?; +/// assert!(before.text.contains("Invoice 1042")); +/// +/// // A correction flows back as a retract, not a mutation. +/// handle.retract("halcyon:inv-1042")?; +/// let after = handle.situation(None, None)?; +/// assert!(after.rev > before.rev); +/// assert!(!after.text.contains("## urgent\n1.")); +/// # Ok::<(), Box>(()) /// ``` #[derive(Clone)] pub struct Clog { @@ -190,7 +202,9 @@ impl Clog { /// fails; /// - `ClogError::ShuttingDown` if the instance is stopping. pub fn retract(&self, claim_key: &str) -> Result { - self.write(WriteOp::Retract { claim_key: claim_key.to_string() }) + self.write(WriteOp::Retract { + claim_key: claim_key.to_string(), + }) } /// Withdraws everything an observer ever said, in one batch. @@ -219,7 +233,9 @@ impl Clog { /// fails; /// - `ClogError::ShuttingDown` if the instance is stopping. pub fn revoke_observer(&self, observer: &ObserverId) -> Result { - self.write(WriteOp::RevokeObserver { observer: observer.clone() }) + self.write(WriteOp::RevokeObserver { + observer: observer.clone(), + }) } /// Declares that `alias` and `canonical` are the same entity: every view @@ -258,8 +274,15 @@ impl Clog { /// - `ClogError::Storage` / `ClogError::Corrupt` if the log append /// fails; /// - `ClogError::ShuttingDown` if the instance is stopping. - pub fn merge_entities(&self, alias: &EntityRef, canonical: &EntityRef) -> Result { - self.write(WriteOp::Merge { alias: alias.clone(), canonical: canonical.clone() }) + pub fn merge_entities( + &self, + alias: &EntityRef, + canonical: &EntityRef, + ) -> Result { + self.write(WriteOp::Merge { + alias: alias.clone(), + canonical: canonical.clone(), + }) } /// Reads rows out of one materialized view, filtered. @@ -342,19 +365,29 @@ impl Clog { /// - `ClogError::TemplateError` if `template` does not parse. That /// rejects the call only; nothing is written and no stored document /// is affected. - pub fn situation(&self, scope: Option<&str>, template: Option<&str>) -> Result { + pub fn situation( + &self, + scope: Option<&str>, + template: Option<&str>, + ) -> Result { // `load_full` rather than `load`: parsing and rendering a custom // template is unbounded caller-supplied work, and an `ArcSwap` guard // must not be held across it. let snapshot = self.inner.snapshot.load_full(); let scope = scope.unwrap_or(DEFAULT_SCOPE); - let state = snapshot.situations.get(scope).ok_or(ClogError::UnknownScope)?; + let state = snapshot + .situations + .get(scope) + .ok_or(ClogError::UnknownScope)?; match template { None => Ok(state.situation.clone()), Some(source) => { let template = parse(source)?; let text = render(&template, &state.inputs, self.inner.budget_chars); - Ok(Situation { text, ..state.situation.clone() }) + Ok(Situation { + text, + ..state.situation.clone() + }) } } } @@ -382,7 +415,10 @@ impl Clog { return Err(ClogError::ManualClockRequired); } let (reply, done) = bounded(1); - self.inner.tx.send(Cmd::Advance(ms, reply)).map_err(|_| ClogError::ShuttingDown)?; + self.inner + .tx + .send(Cmd::Advance(ms, reply)) + .map_err(|_| ClogError::ShuttingDown)?; done.recv().map_err(|_| ClogError::ShuttingDown) } @@ -390,7 +426,10 @@ impl Clog { /// full queue blocks here: backpressure is the point (spec §6.1). fn write(&self, op: WriteOp) -> Result { let (reply, ack) = bounded(1); - self.inner.tx.send(Cmd::Write(WriteReq { op, reply })).map_err(|_| ClogError::ShuttingDown)?; + self.inner + .tx + .send(Cmd::Write(WriteReq { op, reply })) + .map_err(|_| ClogError::ShuttingDown)?; ack.recv().map_err(|_| ClogError::ShuttingDown)? } } diff --git a/examples/clog/src/render/mod.rs b/examples/clog/src/render/mod.rs index 1004261..e88b2d6 100644 --- a/examples/clog/src/render/mod.rs +++ b/examples/clog/src/render/mod.rs @@ -175,7 +175,14 @@ pub(crate) fn render(t: &Template, inputs: &SlotInputs, budget_chars: usize) -> let open_loop_lines: Vec = inputs .open_loops .iter() - .map(|l| format!("- {} {} ({})", l.kind.to_uppercase(), l.headline, l.claim_key)) + .map(|l| { + format!( + "- {} {} ({})", + l.kind.to_uppercase(), + l.headline, + l.claim_key + ) + }) .collect(); let entity_lines: Vec = inputs .entities @@ -192,13 +199,23 @@ pub(crate) fn render(t: &Template, inputs: &SlotInputs, budget_chars: usize) -> .collect(); let mut urgent_state = SlotState::new(urgent_lines, configured_limit(t, SlotName::Urgent)); - let mut open_loops_state = SlotState::new(open_loop_lines, configured_limit(t, SlotName::OpenLoops)); + let mut open_loops_state = + SlotState::new(open_loop_lines, configured_limit(t, SlotName::OpenLoops)); let mut entities_state = SlotState::new(entity_lines, configured_limit(t, SlotName::Entities)); let mut changes_state = SlotState::new(change_lines, configured_limit(t, SlotName::Changes)); - let header_line = format!("{} · rev {} · {}", inputs.scope, inputs.rev, rfc3339_utc(inputs.as_of_ms)); - - let build = |urgent: &SlotState, open_loops: &SlotState, entities: &SlotState, changes: &SlotState| -> String { + let header_line = format!( + "{} · rev {} · {}", + inputs.scope, + inputs.rev, + rfc3339_utc(inputs.as_of_ms) + ); + + let build = |urgent: &SlotState, + open_loops: &SlotState, + entities: &SlotState, + changes: &SlotState| + -> String { let mut out = String::new(); for seg in &t.0 { match seg { @@ -218,7 +235,12 @@ pub(crate) fn render(t: &Template, inputs: &SlotInputs, budget_chars: usize) -> out }; - let mut current = build(&urgent_state, &open_loops_state, &entities_state, &changes_state); + let mut current = build( + &urgent_state, + &open_loops_state, + &entities_state, + &changes_state, + ); while current.chars().count() > budget_chars { let dropped = changes_state.drop_one() @@ -228,7 +250,12 @@ pub(crate) fn render(t: &Template, inputs: &SlotInputs, budget_chars: usize) -> if !dropped { break; } - current = build(&urgent_state, &open_loops_state, &entities_state, &changes_state); + current = build( + &urgent_state, + &open_loops_state, + &entities_state, + &changes_state, + ); } current @@ -237,7 +264,7 @@ pub(crate) fn render(t: &Template, inputs: &SlotInputs, budget_chars: usize) -> #[cfg(test)] mod render_tests { use super::*; - use crate::render::template::{parse, DEFAULT_TEMPLATE}; + use crate::render::template::{DEFAULT_TEMPLATE, parse}; fn inputs() -> SlotInputs { SlotInputs { @@ -269,7 +296,10 @@ mod render_tests { display: "Halcyon".into(), summaries: vec!["paid".into(), "kicked off".into()], }], - changes: vec![ChangeItem::Added("Invoice 1042 overdue".into()), ChangeItem::Removed("old thing".into())], + changes: vec![ + ChangeItem::Added("Invoice 1042 overdue".into()), + ChangeItem::Removed("old thing".into()), + ], } } diff --git a/examples/clog/src/render/template.rs b/examples/clog/src/render/template.rs index cc5464e..0dcd1f37 100644 --- a/examples/clog/src/render/template.rs +++ b/examples/clog/src/render/template.rs @@ -97,9 +97,9 @@ pub(crate) fn parse(src: &str) -> Result { segments.push(Segment::Text(rest[..start].to_string())); } let after_open = &rest[start + 2..]; - let close = after_open - .find('}') - .ok_or_else(|| ClogError::TemplateError("unterminated slot: missing '}'".to_string()))?; + let close = after_open.find('}').ok_or_else(|| { + ClogError::TemplateError("unterminated slot: missing '}'".to_string()) + })?; let body = &after_open[..close]; segments.push(parse_slot(body)?); rest = &after_open[close + 1..]; @@ -121,20 +121,28 @@ fn parse_slot(body: &str) -> Result { "open_loops" => SlotName::OpenLoops, "entities" => SlotName::Entities, "changes" => SlotName::Changes, - other => return Err(ClogError::TemplateError(format!("unknown slot name {other:?}"))), + other => { + return Err(ClogError::TemplateError(format!( + "unknown slot name {other:?}" + ))); + } }; let mut limit = None; for tok in tokens { - let (key, value) = tok - .split_once('=') - .ok_or_else(|| ClogError::TemplateError(format!("malformed slot argument {tok:?}: expected key=value")))?; + let (key, value) = tok.split_once('=').ok_or_else(|| { + ClogError::TemplateError(format!( + "malformed slot argument {tok:?}: expected key=value" + )) + })?; if key != "limit" { - return Err(ClogError::TemplateError(format!("unknown slot key {key:?}"))); + return Err(ClogError::TemplateError(format!( + "unknown slot key {key:?}" + ))); } - let parsed = value - .parse::() - .map_err(|_| ClogError::TemplateError(format!("invalid limit value {value:?}: expected a usize")))?; + let parsed = value.parse::().map_err(|_| { + ClogError::TemplateError(format!("invalid limit value {value:?}: expected a usize")) + })?; limit = Some(parsed); } @@ -154,7 +162,13 @@ mod tests { assert!(parse("%{entities limit=10}%{changes limit=6}").is_ok()); // slot with limit parses the value let t = parse("%{urgent limit=3}").unwrap(); - assert!(matches!(&t.0[0], Segment::Slot { name: SlotName::Urgent, limit: Some(3) })); + assert!(matches!( + &t.0[0], + Segment::Slot { + name: SlotName::Urgent, + limit: Some(3) + } + )); // rejects for bad in [ "%{nope}", // unknown slot name @@ -164,7 +178,10 @@ mod tests { "%{urgent size=3}", // unknown key "%{}", // empty slot ] { - assert!(matches!(parse(bad), Err(crate::ClogError::TemplateError(_))), "{bad}"); + assert!( + matches!(parse(bad), Err(crate::ClogError::TemplateError(_))), + "{bad}" + ); } } diff --git a/examples/clog/src/score.rs b/examples/clog/src/score.rs index 77bc24b..6e99458 100644 --- a/examples/clog/src/score.rs +++ b/examples/clog/src/score.rs @@ -23,7 +23,11 @@ pub(crate) fn trust(r: Reliability, c: Credibility) -> f32 { /// Bucket width is `half_life_days / buckets_per_half_life`. A negative age /// (an `occurred_at` in the future, after clamping) is treated as age zero, /// clamping to the first bucket's midpoint. -pub(crate) fn bucket_age_days(age_days: f32, half_life_days: f32, buckets_per_half_life: u32) -> f32 { +pub(crate) fn bucket_age_days( + age_days: f32, + half_life_days: f32, + buckets_per_half_life: u32, +) -> f32 { let w = half_life_days / buckets_per_half_life as f32; if age_days < 0.0 { return w / 2.0; @@ -49,11 +53,15 @@ pub(crate) fn score_claim( now_ms: u64, buckets_per_half_life: u32, ) -> f32 { - let age_days = (now_ms.saturating_sub(scoring_clamp(claim.occurred_at, now_ms))) as f32 / 86_400_000.0; + let age_days = + (now_ms.saturating_sub(scoring_clamp(claim.occurred_at, now_ms))) as f32 / 86_400_000.0; let bucket_age = bucket_age_days(age_days, focus.half_life_days, buckets_per_half_life); let rec = recency(bucket_age, focus.half_life_days); let trust_val = trust(claim.reliability, claim.credibility); - let kind_weight = kind.and_then(|k| focus.weights.get(k)).copied().unwrap_or(1.0); + let kind_weight = kind + .and_then(|k| focus.weights.get(k)) + .copied() + .unwrap_or(1.0); let boost: f32 = focus .boosts .iter() @@ -92,14 +100,31 @@ mod tests { fn u_score_1_full_formula_with_boost_stacking() { let focus = Focus::uniform() .weight("risk", 2.0) - .boost(EntityRef { etype: "project".into(), id: "halcyon".into(), name: None }, 1.5) - .boost(EntityRef { etype: "person".into(), id: "sam".into(), name: None }, 2.0); + .boost( + EntityRef { + etype: "project".into(), + id: "halcyon".into(), + name: None, + }, + 1.5, + ) + .boost( + EntityRef { + etype: "person".into(), + id: "sam".into(), + name: None, + }, + 2.0, + ); let mut claim = crate::validate::tests_base_claim(); // helper added in step 3 claim.reliability = Reliability::B; // 0.90 claim.credibility = Credibility::Three; // 0.75 let now = 10 * DAY_MS; claim.occurred_at = now; // age 0 -> bucket midpoint 0.875d - let ents = vec![("project".to_string(), "halcyon".to_string()), ("person".to_string(), "sam".to_string())]; + let ents = vec![ + ("project".to_string(), "halcyon".to_string()), + ("person".to_string(), "sam".to_string()), + ]; let expected = 2.0 * (0.90 * 0.75) * 0.5f32.powf((0.875f32) / 7.0) * 1.5 * 2.0; // both boosts stack multiplicatively let got = score_claim(&claim, Some("risk"), &focus, &ents, now, 4); assert!((got - expected).abs() < 1e-6, "{got} vs {expected}"); diff --git a/examples/clog/src/types.rs b/examples/clog/src/types.rs index a4a317e..3e1830d 100644 --- a/examples/clog/src/types.rs +++ b/examples/clog/src/types.rs @@ -466,7 +466,11 @@ impl Config { path: path.into(), scopes: BTreeMap::new(), kinds: KindTaxonomy::default_taxonomy(), - loop_kinds: vec!["question".to_string(), "risk".to_string(), "commitment".to_string()], + loop_kinds: vec![ + "question".to_string(), + "risk".to_string(), + "commitment".to_string(), + ], top_k: 12, budget_chars: 6000, tick: TickConfig::default(), @@ -551,13 +555,20 @@ mod tests { observed_at: 2_000, reliability: Reliability::B, credibility: Credibility::Two, - entities: vec![EntityRef { etype: "project".into(), id: "halcyon".into(), name: Some("Halcyon".into()) }], + entities: vec![EntityRef { + etype: "project".into(), + id: "halcyon".into(), + name: Some("Halcyon".into()), + }], body: "Invoice 1042 is 30 days overdue".into(), }; let bytes = postcard::to_allocvec(&c).unwrap(); assert_eq!(postcard::from_bytes::(&bytes).unwrap(), c); - let f = Focus::uniform().weight("risk", 2.5).half_life_days(3.0).top_k(8); + let f = Focus::uniform() + .weight("risk", 2.5) + .half_life_days(3.0) + .top_k(8); let json = serde_json_like_roundtrip(&f); // via postcard, same as above assert_eq!(json.weights.get("risk"), Some(&2.5)); assert_eq!(json.half_life_days, 3.0); @@ -570,8 +581,16 @@ mod tests { #[test] fn entity_ref_identity_ignores_name() { - let a = EntityRef { etype: "person".into(), id: "sam".into(), name: Some("Sam".into()) }; - let b = EntityRef { etype: "person".into(), id: "sam".into(), name: None }; + let a = EntityRef { + etype: "person".into(), + id: "sam".into(), + name: Some("Sam".into()), + }; + let b = EntityRef { + etype: "person".into(), + id: "sam".into(), + name: None, + }; assert_eq!(a, b); use std::collections::BTreeSet; let mut s = BTreeSet::new(); diff --git a/examples/clog/src/validate.rs b/examples/clog/src/validate.rs index 21616d4..243e537 100644 --- a/examples/clog/src/validate.rs +++ b/examples/clog/src/validate.rs @@ -5,7 +5,7 @@ //! whole batch is rejected on the first invalid claim (atomic, no partial //! commit). -use crate::types::{ClogError, Claim, Focus, KindTaxonomy}; +use crate::types::{Claim, ClogError, Focus, KindTaxonomy}; /// Builds a `Claim` with every field satisfying §10, for tests to mutate. /// @@ -35,12 +35,19 @@ pub(crate) fn tests_base_claim() -> Claim { /// `ClogError::InvalidClaim` for host-side error reporting. `allow_reserved` /// is set only by internal writers (e.g. merge claims), which are permitted /// to use the `clog:` namespace reserved from hosts by INV-8. -pub(crate) fn validate_claim(index: usize, c: &Claim, allow_reserved: bool) -> Result<(), ClogError> { +pub(crate) fn validate_claim( + index: usize, + c: &Claim, + allow_reserved: bool, +) -> Result<(), ClogError> { fn has_control(s: &str) -> bool { s.chars().any(|ch| ch.is_control()) } fn invalid(index: usize, reason: impl Into) -> ClogError { - ClogError::InvalidClaim { index, reason: reason.into() } + ClogError::InvalidClaim { + index, + reason: reason.into(), + } } if c.claim_key.trim().is_empty() { @@ -50,7 +57,10 @@ pub(crate) fn validate_claim(index: usize, c: &Claim, allow_reserved: bool) -> R return Err(invalid(index, "claim_key must be <= 256 bytes")); } if has_control(&c.claim_key) { - return Err(invalid(index, "claim_key must not contain control characters")); + return Err(invalid( + index, + "claim_key must not contain control characters", + )); } if c.claim_key.starts_with("clog:") && !allow_reserved { return Err(ClogError::ReservedNamespace); @@ -61,7 +71,10 @@ pub(crate) fn validate_claim(index: usize, c: &Claim, allow_reserved: bool) -> R return Err(invalid(index, "subject_key must be <= 256 bytes")); } if has_control(subject_key) { - return Err(invalid(index, "subject_key must not contain control characters")); + return Err(invalid( + index, + "subject_key must not contain control characters", + )); } } @@ -72,7 +85,10 @@ pub(crate) fn validate_claim(index: usize, c: &Claim, allow_reserved: bool) -> R return Err(invalid(index, "source_ref must be <= 1024 bytes")); } if has_control(&c.source_ref) { - return Err(invalid(index, "source_ref must not contain control characters")); + return Err(invalid( + index, + "source_ref must not contain control characters", + )); } if c.observer.0.trim().is_empty() { @@ -103,7 +119,10 @@ pub(crate) fn validate_claim(index: usize, c: &Claim, allow_reserved: bool) -> R // merge-claim bodies (see `engine::naive`); banning control chars // here is what makes that encoding unambiguous. if has_control(&e.etype) { - return Err(invalid(index, "entity etype must not contain control characters")); + return Err(invalid( + index, + "entity etype must not contain control characters", + )); } if e.id.trim().is_empty() { return Err(invalid(index, "entity id must be non-empty")); @@ -112,7 +131,10 @@ pub(crate) fn validate_claim(index: usize, c: &Claim, allow_reserved: bool) -> R return Err(invalid(index, "entity id must be <= 128 bytes")); } if has_control(&e.id) { - return Err(invalid(index, "entity id must not contain control characters")); + return Err(invalid( + index, + "entity id must not contain control characters", + )); } } @@ -142,18 +164,24 @@ pub(crate) fn validate_focus(f: &Focus, taxonomy: &KindTaxonomy) -> Result<(), C return Err(ClogError::UnknownKind); } if !valid_factor(*w) { - return Err(ClogError::InvalidFilter { reason: format!("focus weight for {kind} must be finite and > 0") }); + return Err(ClogError::InvalidFilter { + reason: format!("focus weight for {kind} must be finite and > 0"), + }); } } for (_, boost) in &f.boosts { if !valid_factor(*boost) { - return Err(ClogError::InvalidFilter { reason: "focus boost factor must be finite and > 0".into() }); + return Err(ClogError::InvalidFilter { + reason: "focus boost factor must be finite and > 0".into(), + }); } } if !(0.01 < f.half_life_days && f.half_life_days < 3650.0) { - return Err(ClogError::InvalidFilter { reason: "half_life_days must be in (0.01, 3650)".into() }); + return Err(ClogError::InvalidFilter { + reason: "half_life_days must be in (0.01, 3650)".into(), + }); } Ok(()) @@ -163,11 +191,7 @@ pub(crate) fn validate_focus(f: &Focus, taxonomy: &KindTaxonomy) -> Result<(), C /// beyond `now` are clamped to `now`. Storage always keeps the verbatim /// value; only scoring consumes this clamped result. pub(crate) fn scoring_clamp(ts: u64, now: u64) -> u64 { - if ts > now + 86_400_000 { - now - } else { - ts - } + if ts > now + 86_400_000 { now } else { ts } } #[cfg(test)] @@ -182,35 +206,163 @@ mod tests { let cases: Vec<(Box, bool, &str)> = vec![ (Box::new(|_| {}), true, ""), (Box::new(|c| c.claim_key = " ".into()), false, "claim_key"), - (Box::new(|c| c.claim_key = "x".repeat(257)), false, "claim_key"), - (Box::new(|c| c.claim_key = "clog:evil".into()), false, "reserved"), - (Box::new(|c| c.claim_key = "has\u{0007}bell".into()), false, "control"), - (Box::new(|c| c.subject_key = Some("x".repeat(257))), false, "subject_key"), - (Box::new(|c| c.subject_key = Some("has\u{0007}bell".into())), false, "control"), + ( + Box::new(|c| c.claim_key = "x".repeat(257)), + false, + "claim_key", + ), + ( + Box::new(|c| c.claim_key = "clog:evil".into()), + false, + "reserved", + ), + ( + Box::new(|c| c.claim_key = "has\u{0007}bell".into()), + false, + "control", + ), + ( + Box::new(|c| c.subject_key = Some("x".repeat(257))), + false, + "subject_key", + ), + ( + Box::new(|c| c.subject_key = Some("has\u{0007}bell".into())), + false, + "control", + ), (Box::new(|c| c.source_ref = "".into()), false, "source_ref"), - (Box::new(|c| c.source_ref = "x".repeat(1025)), false, "source_ref"), - (Box::new(|c| c.source_ref = "has\u{0007}bell".into()), false, "control"), - (Box::new(|c| c.observer = ObserverId(String::new())), false, "observer"), - (Box::new(|c| c.observer = ObserverId("x".repeat(129))), false, "observer"), + ( + Box::new(|c| c.source_ref = "x".repeat(1025)), + false, + "source_ref", + ), + ( + Box::new(|c| c.source_ref = "has\u{0007}bell".into()), + false, + "control", + ), + ( + Box::new(|c| c.observer = ObserverId(String::new())), + false, + "observer", + ), + ( + Box::new(|c| c.observer = ObserverId("x".repeat(129))), + false, + "observer", + ), (Box::new(|c| c.body = " ".into()), false, "body"), - (Box::new(|c| c.body = "x".repeat(16 * 1024 + 1)), false, "body"), - (Box::new(|c| c.entities = vec![EntityRef { etype: "p".into(), id: "i".into(), name: None }; 33]), false, "entities"), - (Box::new(|c| c.entities = vec![EntityRef { etype: "".into(), id: "i".into(), name: None }]), false, "etype"), - (Box::new(|c| c.entities = vec![EntityRef { etype: "x".repeat(129), id: "i".into(), name: None }]), false, "etype"), - (Box::new(|c| c.entities = vec![EntityRef { etype: "p".into(), id: "x".repeat(129), name: None }]), false, "id"), - (Box::new(|c| c.entities = vec![EntityRef { etype: "p".into(), id: "".into(), name: None }]), false, "id"), - (Box::new(|c| c.entities = vec![EntityRef { etype: "has\u{0007}bell".into(), id: "i".into(), name: None }]), false, "control"), + ( + Box::new(|c| c.body = "x".repeat(16 * 1024 + 1)), + false, + "body", + ), + ( + Box::new(|c| { + c.entities = vec![ + EntityRef { + etype: "p".into(), + id: "i".into(), + name: None + }; + 33 + ] + }), + false, + "entities", + ), + ( + Box::new(|c| { + c.entities = vec![EntityRef { + etype: "".into(), + id: "i".into(), + name: None, + }] + }), + false, + "etype", + ), + ( + Box::new(|c| { + c.entities = vec![EntityRef { + etype: "x".repeat(129), + id: "i".into(), + name: None, + }] + }), + false, + "etype", + ), + ( + Box::new(|c| { + c.entities = vec![EntityRef { + etype: "p".into(), + id: "x".repeat(129), + name: None, + }] + }), + false, + "id", + ), + ( + Box::new(|c| { + c.entities = vec![EntityRef { + etype: "p".into(), + id: "".into(), + name: None, + }] + }), + false, + "id", + ), + ( + Box::new(|c| { + c.entities = vec![EntityRef { + etype: "has\u{0007}bell".into(), + id: "i".into(), + name: None, + }] + }), + false, + "control", + ), // U+001F is the merge-claim body separator: it must never reach an entity key. - (Box::new(|c| c.entities = vec![EntityRef { etype: "p".into(), id: "a\u{001f}b".into(), name: None }]), false, "control"), + ( + Box::new(|c| { + c.entities = vec![EntityRef { + etype: "p".into(), + id: "a\u{001f}b".into(), + name: None, + }] + }), + false, + "control", + ), (Box::new(|c| c.occurred_at = 0), false, "occurred_at"), (Box::new(|c| c.observed_at = 0), false, "observed_at"), // occurred_at > observed_at is ALLOWED (predictions) - (Box::new(|c| { c.occurred_at = 10; c.observed_at = 5; }), true, ""), + ( + Box::new(|c| { + c.occurred_at = 10; + c.observed_at = 5; + }), + true, + "", + ), // valid subject_key + non-empty entities vec - (Box::new(|c| { - c.subject_key = Some("valid-subject".into()); - c.entities = vec![EntityRef { etype: "project".into(), id: "halcyon".into(), name: None }]; - }), true, ""), + ( + Box::new(|c| { + c.subject_key = Some("valid-subject".into()); + c.entities = vec![EntityRef { + etype: "project".into(), + id: "halcyon".into(), + name: None, + }]; + }), + true, + "", + ), ]; for (i, (mutate, ok, why)) in cases.iter().enumerate() { let mut c = tests_base_claim(); @@ -221,7 +373,10 @@ mod tests { match r.unwrap_err() { ClogError::InvalidClaim { index, reason } => { assert_eq!(index, 7); - assert!(reason.to_lowercase().contains(why), "case {i}: {reason} !~ {why}"); + assert!( + reason.to_lowercase().contains(why), + "case {i}: {reason} !~ {why}" + ); } ClogError::ReservedNamespace => assert_eq!(*why, "reserved"), e => panic!("case {i}: wrong error {e:?}"), @@ -238,7 +393,10 @@ mod tests { fn u_val_1_focus_rules() { let tax = KindTaxonomy::default_taxonomy(); assert!(validate_focus(&Focus::uniform().weight("risk", 2.0), &tax).is_ok()); - assert!(matches!(validate_focus(&Focus::uniform().weight("nope", 1.0), &tax), Err(ClogError::UnknownKind))); + assert!(matches!( + validate_focus(&Focus::uniform().weight("nope", 1.0), &tax), + Err(ClogError::UnknownKind) + )); assert!(validate_focus(&Focus::uniform().weight("risk", f32::NAN), &tax).is_err()); assert!(validate_focus(&Focus::uniform().weight("risk", 0.0), &tax).is_err()); assert!(validate_focus(&Focus::uniform().half_life_days(0.005), &tax).is_err()); @@ -247,7 +405,11 @@ mod tests { assert!(validate_focus(&Focus::uniform().half_life_days(0.01), &tax).is_err()); assert!(validate_focus(&Focus::uniform().half_life_days(3650.0), &tax).is_err()); // boost factors: valid, NaN, zero. - let e = EntityRef { etype: "p".into(), id: "x".into(), name: None }; + let e = EntityRef { + etype: "p".into(), + id: "x".into(), + name: None, + }; assert!(validate_focus(&Focus::uniform().boost(e.clone(), 1.5), &tax).is_ok()); assert!(validate_focus(&Focus::uniform().boost(e.clone(), f32::NAN), &tax).is_err()); assert!(validate_focus(&Focus::uniform().boost(e, 0.0), &tax).is_err()); @@ -256,7 +418,10 @@ mod tests { #[test] fn scoring_clamp_only_beyond_24h() { assert_eq!(scoring_clamp(100, 1_000_000), 100); - assert_eq!(scoring_clamp(1_000_000 + 86_400_000, 1_000_000), 1_000_000 + 86_400_000); + assert_eq!( + scoring_clamp(1_000_000 + 86_400_000, 1_000_000), + 1_000_000 + 86_400_000 + ); assert_eq!(scoring_clamp(1_000_000 + 86_400_001, 1_000_000), 1_000_000); } } diff --git a/examples/clog/src/wal.rs b/examples/clog/src/wal.rs index 67141eb..09155c7 100644 --- a/examples/clog/src/wal.rs +++ b/examples/clog/src/wal.rs @@ -29,10 +29,12 @@ impl Wal { /// `self`'s policy afterwards (`OnCommit` calls `sync_data`; `Never` /// does not sync). pub(crate) fn append(&mut self, batch: &Batch) -> Result<(), ClogError> { - let payload = postcard::to_allocvec(batch) - .map_err(|e| ClogError::Corrupt { detail: format!("wal encode: {e}") })?; - let len = u32::try_from(payload.len()) - .map_err(|_| ClogError::Corrupt { detail: "wal record too large".to_string() })?; + let payload = postcard::to_allocvec(batch).map_err(|e| ClogError::Corrupt { + detail: format!("wal encode: {e}"), + })?; + let len = u32::try_from(payload.len()).map_err(|_| ClogError::Corrupt { + detail: "wal record too large".to_string(), + })?; let crc = crc32fast::hash(&payload); let mut frame = Vec::with_capacity(HEADER_LEN + payload.len()); @@ -79,7 +81,11 @@ pub(crate) fn open_dir(dir: &Path, fsync: FsyncPolicy) -> Result<(Wal, Vec Result<(Wal, Vec Result<(Vec, usize), ClogError> { Ok(batch) => { if batch.rev <= last_rev { return Err(ClogError::Corrupt { - detail: format!("wal rev not strictly increasing: {} after {last_rev}", batch.rev), + detail: format!( + "wal rev not strictly increasing: {} after {last_rev}", + batch.rev + ), }); } last_rev = batch.rev; @@ -173,7 +185,10 @@ fn replay(bytes: &[u8]) -> Result<(Vec, usize), ClogError> { /// Appends `tail` to `wal_dir/wal.corrupt`, creating the file if needed. fn quarantine(wal_dir: &Path, tail: &[u8]) -> Result<(), ClogError> { - let mut f = OpenOptions::new().create(true).append(true).open(wal_dir.join("wal.corrupt"))?; + let mut f = OpenOptions::new() + .create(true) + .append(true) + .open(wal_dir.join("wal.corrupt"))?; f.write_all(tail)?; Ok(()) } @@ -184,7 +199,11 @@ mod tests { use crate::engine::{Batch, Event}; fn batch(rev: u64) -> Batch { - Batch { rev, as_of: 1_000 * rev, events: vec![Event::Tick { epoch: rev }] } + Batch { + rev, + as_of: 1_000 * rev, + events: vec![Event::Tick { epoch: rev }], + } } #[test] @@ -222,7 +241,10 @@ mod tests { w.append(&batch(2)).unwrap(); } let (_, replayed) = open_dir(dir.path(), crate::FsyncPolicy::OnCommit).unwrap(); - assert_eq!(replayed.iter().map(|b| b.rev).collect::>(), vec![1, 2]); + assert_eq!( + replayed.iter().map(|b| b.rev).collect::>(), + vec![1, 2] + ); } #[test] @@ -254,7 +276,10 @@ mod tests { w.append(&batch(1)).unwrap(); w.append(&batch(1)).unwrap(); // same rev twice: impossible for one writer } - assert!(matches!(open_dir(dir.path(), crate::FsyncPolicy::OnCommit), Err(ClogError::Corrupt { .. }))); + assert!(matches!( + open_dir(dir.path(), crate::FsyncPolicy::OnCommit), + Err(ClogError::Corrupt { .. }) + )); // rev 0 is likewise impossible: the first committed batch is rev 1. let dir = tempfile::tempdir().unwrap(); @@ -262,7 +287,10 @@ mod tests { let (mut w, _) = open_dir(dir.path(), crate::FsyncPolicy::OnCommit).unwrap(); w.append(&batch(0)).unwrap(); } - assert!(matches!(open_dir(dir.path(), crate::FsyncPolicy::OnCommit), Err(ClogError::Corrupt { .. }))); + assert!(matches!( + open_dir(dir.path(), crate::FsyncPolicy::OnCommit), + Err(ClogError::Corrupt { .. }) + )); } #[test] diff --git a/examples/clog/tests/api.rs b/examples/clog/tests/api.rs index 4844f72..55a72b6 100644 --- a/examples/clog/tests/api.rs +++ b/examples/clog/tests/api.rs @@ -7,7 +7,10 @@ use clog::*; fn cfg(dir: &std::path::Path) -> Config { let mut c = Config::default_for(dir); - c.tick = TickConfig { mode: ClockMode::Manual, interval_ms: 60_000 }; + c.tick = TickConfig { + mode: ClockMode::Manual, + interval_ms: 60_000, + }; c } @@ -59,7 +62,12 @@ fn observe_bumps_rev_and_renders() { let dir = tempfile::tempdir().unwrap(); let c = Clog::open(cfg(dir.path())).unwrap(); c.advance(1_000_000).unwrap(); - let ack = c.observe(vec![claim("a", "first thing", 500_000)], ObserveOpts::default()).unwrap(); + let ack = c + .observe( + vec![claim("a", "first thing", 500_000)], + ObserveOpts::default(), + ) + .unwrap(); assert_eq!(ack.rev, 1); let s = c.situation(None, None).unwrap(); assert_eq!(s.scope, "default"); @@ -72,9 +80,13 @@ fn inv5_duplicate_observe_is_invisible() { let dir = tempfile::tempdir().unwrap(); let c = Clog::open(cfg(dir.path())).unwrap(); c.advance(1_000_000).unwrap(); - let a1 = c.observe(vec![claim("a", "x", 500_000)], ObserveOpts::default()).unwrap(); + let a1 = c + .observe(vec![claim("a", "x", 500_000)], ObserveOpts::default()) + .unwrap(); let s1 = c.situation(None, None).unwrap(); - let a2 = c.observe(vec![claim("a", "x", 500_000)], ObserveOpts::default()).unwrap(); + let a2 = c + .observe(vec![claim("a", "x", 500_000)], ObserveOpts::default()) + .unwrap(); let s2 = c.situation(None, None).unwrap(); assert_eq!(a2.rev, a1.rev, "duplicate batch must not commit"); assert_eq!(s1.rev, s2.rev); @@ -86,8 +98,16 @@ fn inv4_upsert_supersedes() { let dir = tempfile::tempdir().unwrap(); let c = Clog::open(cfg(dir.path())).unwrap(); c.advance(1_000_000).unwrap(); - c.observe(vec![claim("a", "old body", 500_000)], ObserveOpts::default()).unwrap(); - c.observe(vec![claim("a", "new body", 600_000)], ObserveOpts::default()).unwrap(); + c.observe( + vec![claim("a", "old body", 500_000)], + ObserveOpts::default(), + ) + .unwrap(); + c.observe( + vec![claim("a", "new body", 600_000)], + ObserveOpts::default(), + ) + .unwrap(); let s = c.situation(None, None).unwrap(); assert!(s.text.contains("new body")); assert!(!s.text.contains("old body")); @@ -99,7 +119,8 @@ fn inv3_retraction_heals_text() { let c = Clog::open(cfg(dir.path())).unwrap(); c.advance(1_000_000).unwrap(); let empty = c.situation(None, None).unwrap(); - c.observe(vec![claim("a", "temp", 500_000)], ObserveOpts::default()).unwrap(); + c.observe(vec![claim("a", "temp", 500_000)], ObserveOpts::default()) + .unwrap(); c.retract("a").unwrap(); let healed = c.situation(None, None).unwrap(); assert_eq!(norm(&healed), norm(&empty)); @@ -112,12 +133,16 @@ fn inv9_rev_survives_reopen() { { let c = Clog::open(cfg(dir.path())).unwrap(); c.advance(1_000_000).unwrap(); - c.observe(vec![claim("a", "x", 500_000)], ObserveOpts::default()).unwrap(); - c.observe(vec![claim("b", "y", 500_000)], ObserveOpts::default()).unwrap(); + c.observe(vec![claim("a", "x", 500_000)], ObserveOpts::default()) + .unwrap(); + c.observe(vec![claim("b", "y", 500_000)], ObserveOpts::default()) + .unwrap(); } // drop -> clean shutdown let c = Clog::open(cfg(dir.path())).unwrap(); c.advance(2_000_000).unwrap(); - let ack = c.observe(vec![claim("c", "z", 500_000)], ObserveOpts::default()).unwrap(); + let ack = c + .observe(vec![claim("c", "z", 500_000)], ObserveOpts::default()) + .unwrap(); assert_eq!(ack.rev, 3); let s = c.situation(None, None).unwrap(); assert!(s.text.contains('x') && s.text.contains('z')); @@ -132,11 +157,13 @@ fn reopen_reproduces_situation_text_byte_identically() { let before = { let c = Clog::open(cfg(dir.path())).unwrap(); c.advance(1_000_000).unwrap(); - c.observe(vec![claim("a", "first", 500_000)], ObserveOpts::default()).unwrap(); + c.observe(vec![claim("a", "first", 500_000)], ObserveOpts::default()) + .unwrap(); // a different clock reading for the second batch: replaying both // against one reopen-time reading would render a different header c.advance(500_000).unwrap(); - c.observe(vec![claim("b", "second", 900_000)], ObserveOpts::default()).unwrap(); + c.observe(vec![claim("b", "second", 900_000)], ObserveOpts::default()) + .unwrap(); c.situation(None, None).unwrap() }; let c = Clog::open(cfg(dir.path())).unwrap(); @@ -152,7 +179,10 @@ fn reserved_namespace_rejected_and_batch_atomic() { let c = Clog::open(cfg(dir.path())).unwrap(); c.advance(1_000_000).unwrap(); let r = c.observe( - vec![claim("ok", "fine", 500_000), claim("clog:sneaky", "no", 500_000)], + vec![ + claim("ok", "fine", 500_000), + claim("clog:sneaky", "no", 500_000), + ], ObserveOpts::default(), ); assert!(r.is_err()); @@ -166,14 +196,24 @@ fn rules_tier_classifies_at_commit() { let mut config = cfg(dir.path()); for kd in &mut config.kinds.kinds { if kd.name == "risk" { - kd.rules.push(clog::Rule { any_of: vec![clog::Matcher::BodyContains("overdue".into())] }); + kd.rules.push(clog::Rule { + any_of: vec![clog::Matcher::BodyContains("overdue".into())], + }); } } let c = Clog::open(config).unwrap(); c.advance(1_000_000).unwrap(); - c.observe(vec![claim("inv", "invoice 1042 is overdue", 500_000)], ObserveOpts::default()).unwrap(); + c.observe( + vec![claim("inv", "invoice 1042 is overdue", 500_000)], + ObserveOpts::default(), + ) + .unwrap(); let s = c.situation(None, None).unwrap(); - assert!(s.text.contains("- RISK invoice 1042"), "open loops slot should show it:\n{}", s.text); + assert!( + s.text.contains("- RISK invoice 1042"), + "open loops slot should show it:\n{}", + s.text + ); } // Beyond the brief: a batch may carry two versions of one key. Only the @@ -185,14 +225,19 @@ fn only_the_surviving_version_of_a_key_is_classified() { let mut config = cfg(dir.path()); for kd in &mut config.kinds.kinds { if kd.name == "risk" { - kd.rules.push(clog::Rule { any_of: vec![clog::Matcher::BodyContains("overdue".into())] }); + kd.rules.push(clog::Rule { + any_of: vec![clog::Matcher::BodyContains("overdue".into())], + }); } } let c = Clog::open(config).unwrap(); c.advance(1_000_000).unwrap(); // one batch, two versions of "inv": only the first matches the risk rule c.observe( - vec![claim("inv", "invoice 1042 is overdue", 500_000), claim("inv", "invoice 1042 is paid", 600_000)], + vec![ + claim("inv", "invoice 1042 is overdue", 500_000), + claim("inv", "invoice 1042 is paid", 600_000), + ], ObserveOpts::default(), ) .unwrap(); @@ -200,7 +245,11 @@ fn only_the_surviving_version_of_a_key_is_classified() { let s = c.situation(None, None).unwrap(); assert!(s.text.contains("invoice 1042 is paid"), "{}", s.text); // the survivor never matched the rule, so it is unclassified: no open loop - assert!(!s.text.contains("RISK"), "surviving claim must not inherit the superseded version's kind:\n{}", s.text); + assert!( + !s.text.contains("RISK"), + "surviving claim must not inherit the superseded version's kind:\n{}", + s.text + ); assert!(s.text.contains("## open loops\n(none)"), "{}", s.text); } @@ -209,11 +258,23 @@ fn custom_template_and_errors() { let dir = tempfile::tempdir().unwrap(); let c = Clog::open(cfg(dir.path())).unwrap(); c.advance(1_000_000).unwrap(); - c.observe(vec![claim("a", "hello world", 500_000)], ObserveOpts::default()).unwrap(); - let s = c.situation(None, Some("URGENT ONLY\n%{urgent limit=1}")).unwrap(); + c.observe( + vec![claim("a", "hello world", 500_000)], + ObserveOpts::default(), + ) + .unwrap(); + let s = c + .situation(None, Some("URGENT ONLY\n%{urgent limit=1}")) + .unwrap(); assert!(s.text.starts_with("URGENT ONLY\n1."), "{}", s.text); - assert!(matches!(c.situation(None, Some("%{bogus}")), Err(ClogError::TemplateError(_)))); - assert!(matches!(c.situation(Some("nope"), None), Err(ClogError::UnknownScope))); + assert!(matches!( + c.situation(None, Some("%{bogus}")), + Err(ClogError::TemplateError(_)) + )); + assert!(matches!( + c.situation(Some("nope"), None), + Err(ClogError::UnknownScope) + )); } // Beyond the brief: a change confined to items the template's `limit=` hides @@ -227,32 +288,66 @@ fn hidden_item_change_keeps_the_document_but_refreshes_its_inputs() { // Ten claims with identical trust and occurred_at: equal scores, so the // claim_key tiebreak orders them k00..k09 and the default template's // `%{urgent limit=8}` hides the last two. - let batch: Vec = - (0..10).map(|i| claim(&format!("k{i:02}"), &format!("body {i:02}"), 500_000)).collect(); + let batch: Vec = (0..10) + .map(|i| claim(&format!("k{i:02}"), &format!("body {i:02}"), 500_000)) + .collect(); c.observe(batch, ObserveOpts::default()).unwrap(); // Rewrite one hidden item, so that this render and the next both sit on // an unchanged membership and an empty `changes` slot — isolating the // hidden-item edit as the only difference between them. - let settled = c.observe(vec![claim("k09", "rewritten tail", 500_000)], ObserveOpts::default()).unwrap(); + let settled = c + .observe( + vec![claim("k09", "rewritten tail", 500_000)], + ObserveOpts::default(), + ) + .unwrap(); let before = c.situation(None, None).unwrap(); assert!(before.text.contains("… (2 more)"), "{}", before.text); - let ack = c.observe(vec![claim("k08", "second rewrite", 500_000)], ObserveOpts::default()).unwrap(); + let ack = c + .observe( + vec![claim("k08", "second rewrite", 500_000)], + ObserveOpts::default(), + ) + .unwrap(); assert_eq!(ack.rev, settled.rev + 1, "the upsert did commit a batch"); let after = c.situation(None, None).unwrap(); - assert_eq!(after.text, before.text, "a hidden item's body never reaches the document"); - assert_eq!(after.rev, before.rev, "unchanged text must keep its rev (rev skew is the signal)"); - assert_eq!(after.as_of, before.as_of, "and its as_of: nothing material changed"); - assert!(after.rev < ack.rev, "the scope's rev now lags the global rev, as it should"); + assert_eq!( + after.text, before.text, + "a hidden item's body never reaches the document" + ); + assert_eq!( + after.rev, before.rev, + "unchanged text must keep its rev (rev skew is the signal)" + ); + assert_eq!( + after.as_of, before.as_of, + "and its as_of: nothing material changed" + ); + assert!( + after.rev < ack.rev, + "the scope's rev now lags the global rev, as it should" + ); // The stored slot inputs did move, though: lift the cap and both new // bodies are there, under the same rev the default document reports. - let wide = c.situation(None, Some("%{header}\n%{urgent limit=10}")).unwrap(); + let wide = c + .situation(None, Some("%{header}\n%{urgent limit=10}")) + .unwrap(); assert!(wide.text.contains("rewritten tail"), "{}", wide.text); assert!(wide.text.contains("second rewrite"), "{}", wide.text); - assert!(!wide.text.contains("body 08") && !wide.text.contains("body 09"), "{}", wide.text); - assert!(wide.text.starts_with(&format!("default · rev {}", after.rev)), "{}", wide.text); + assert!( + !wide.text.contains("body 08") && !wide.text.contains("body 09"), + "{}", + wide.text + ); + assert!( + wide.text + .starts_with(&format!("default · rev {}", after.rev)), + "{}", + wide.text + ); } // Beyond the brief: nothing above renders the `entities` slot, and it is the @@ -262,7 +357,11 @@ fn entities_slot_shows_believed_summaries_newest_first() { let dir = tempfile::tempdir().unwrap(); let c = Clog::open(cfg(dir.path())).unwrap(); c.advance(1_000_000).unwrap(); - let entity = EntityRef { etype: "project".into(), id: "halcyon".into(), name: Some("Halcyon".into()) }; + let entity = EntityRef { + etype: "project".into(), + id: "halcyon".into(), + name: Some("Halcyon".into()), + }; let mut old = claim("old", "invoice 1042 overdue", 100_000); old.subject_key = Some("halcyon:inv-1042:status".into()); old.entities = vec![entity.clone()]; @@ -272,11 +371,15 @@ fn entities_slot_shows_believed_summaries_newest_first() { let mut other = claim("kick", "kickoff moved to may", 150_000); other.subject_key = Some("halcyon:kickoff".into()); other.entities = vec![entity]; - c.observe(vec![old, new, other], ObserveOpts::default()).unwrap(); + c.observe(vec![old, new, other], ObserveOpts::default()) + .unwrap(); let text = c.situation(None, None).unwrap().text; // display name from the registry; only believed claims; newest occurred_at first - assert!(text.contains("Halcyon: invoice 1042 paid; kickoff moved to may"), "{text}"); + assert!( + text.contains("Halcyon: invoice 1042 paid; kickoff moved to may"), + "{text}" + ); assert!(!text.contains("Halcyon: invoice 1042 overdue"), "{text}"); } @@ -292,19 +395,43 @@ fn select_live_with_filters() { c.observe(vec![a, b], ObserveOpts::default()).unwrap(); let all = c.select(View::Live, Filter::default()).unwrap(); - assert_eq!(all.iter().map(|r| r.claim.claim_key.as_str()).collect::>(), vec!["a", "b"]); + assert_eq!( + all.iter() + .map(|r| r.claim.claim_key.as_str()) + .collect::>(), + vec!["a", "b"] + ); assert!(all[0].recorded_at >= 1_000_000); - let f = Filter { observer: Some(ObserverId::from("twist")), ..Filter::default() }; + let f = Filter { + observer: Some(ObserverId::from("twist")), + ..Filter::default() + }; assert_eq!(c.select(View::Live, f).unwrap().len(), 1); - let f = Filter { occurred_after: Some(500_000), ..Filter::default() }; + let f = Filter { + occurred_after: Some(500_000), + ..Filter::default() + }; assert_eq!(c.select(View::Live, f).unwrap()[0].claim.claim_key, "b"); - let f = Filter { min_score: Some(0.1), ..Filter::default() }; - assert!(matches!(c.select(View::Live, f), Err(ClogError::InvalidFilter { .. }))); - - let rows = c.select(View::Urgent { scope: "default".into() }, Filter::default()).unwrap(); + let f = Filter { + min_score: Some(0.1), + ..Filter::default() + }; + assert!(matches!( + c.select(View::Live, f), + Err(ClogError::InvalidFilter { .. }) + )); + + let rows = c + .select( + View::Urgent { + scope: "default".into(), + }, + Filter::default(), + ) + .unwrap(); assert!(rows[0].score.is_some()); } @@ -313,8 +440,10 @@ fn inv6_revoke_observer_one_batch() { let dir = tempfile::tempdir().unwrap(); let c = Clog::open(cfg(dir.path())).unwrap(); c.advance(1_000_000).unwrap(); - let mut a = claim("a", "alpha", 100_000); a.observer = ObserverId::from("gmail"); - let mut b = claim("b", "beta", 100_000); b.observer = ObserverId::from("gmail"); + let mut a = claim("a", "alpha", 100_000); + a.observer = ObserverId::from("gmail"); + let mut b = claim("b", "beta", 100_000); + b.observer = ObserverId::from("gmail"); c.observe(vec![a, b], ObserveOpts::default()).unwrap(); let ack = c.revoke_observer(&ObserverId::from("gmail")).unwrap(); assert_eq!(ack.rev, 2); // one batch, one rev @@ -327,20 +456,43 @@ fn p7_shape_merge_round_trip_via_api() { let c = Clog::open(cfg(dir.path())).unwrap(); c.advance(1_000_000).unwrap(); let mut cl = claim("about-a", "note about a", 100_000); - cl.entities = vec![EntityRef { etype: "p".into(), id: "a".into(), name: None }]; + cl.entities = vec![EntityRef { + etype: "p".into(), + id: "a".into(), + name: None, + }]; c.observe(vec![cl], ObserveOpts::default()).unwrap(); let before = c.situation(None, None).unwrap(); - let a = EntityRef { etype: "p".into(), id: "a".into(), name: None }; - let b = EntityRef { etype: "p".into(), id: "b".into(), name: None }; + let a = EntityRef { + etype: "p".into(), + id: "a".into(), + name: None, + }; + let b = EntityRef { + etype: "p".into(), + id: "b".into(), + name: None, + }; c.merge_entities(&a, &b).unwrap(); // entity filter follows the alias - let f = Filter { entities: Some(vec![b.clone()]), ..Filter::default() }; + let f = Filter { + entities: Some(vec![b.clone()]), + ..Filter::default() + }; assert_eq!(c.select(View::Live, f).unwrap().len(), 1); // cycle rejected - assert!(matches!(c.merge_entities(&b, &a), Err(ClogError::AliasCycle))); + assert!(matches!( + c.merge_entities(&b, &a), + Err(ClogError::AliasCycle) + )); // merge claim is invisible (INV-8) - assert!(c.select(View::Live, Filter::default()).unwrap().iter().all(|r| !r.claim.claim_key.starts_with("clog:"))); + assert!( + c.select(View::Live, Filter::default()) + .unwrap() + .iter() + .all(|r| !r.claim.claim_key.starts_with("clog:")) + ); // un-merge by retracting the reserved key c.retract("clog:merge:p:a->p:b").unwrap(); let after = c.situation(None, None).unwrap(); @@ -359,7 +511,9 @@ fn select_filters_and_compose_and_limit_is_bounded() { let mut config = cfg(dir.path()); for kd in &mut config.kinds.kinds { if kd.name == "risk" { - kd.rules.push(clog::Rule { any_of: vec![clog::Matcher::BodyContains("overdue".into())] }); + kd.rules.push(clog::Rule { + any_of: vec![clog::Matcher::BodyContains("overdue".into())], + }); } } let c = Clog::open(config).unwrap(); @@ -377,9 +531,27 @@ fn select_filters_and_compose_and_limit_is_bounded() { vec![ row("hit", "invoice overdue", "gmail", "inv:status", 900_000), row("no-kind", "invoice settled", "gmail", "inv:note", 900_000), - row("wrong-observer", "invoice overdue", "twist", "inv:other", 900_000), - row("wrong-subject", "rent overdue", "gmail", "rent:status", 900_000), - row("too-old", "invoice overdue", "gmail", "inv:history", 100_000), + row( + "wrong-observer", + "invoice overdue", + "twist", + "inv:other", + 900_000, + ), + row( + "wrong-subject", + "rent overdue", + "gmail", + "rent:status", + 900_000, + ), + row( + "too-old", + "invoice overdue", + "gmail", + "inv:history", + 100_000, + ), claim("no-subject", "chatter overdue", 900_000), ], ObserveOpts::default(), @@ -394,33 +566,103 @@ fn select_filters_and_compose_and_limit_is_bounded() { ..Filter::default() }; let rows = c.select(View::Live, all.clone()).unwrap(); - assert_eq!(rows.iter().map(|r| r.claim.claim_key.as_str()).collect::>(), vec!["hit"]); - assert_eq!(c.select(View::Live, Filter { kinds: None, ..all.clone() }).unwrap().len(), 2); - assert_eq!(c.select(View::Live, Filter { observer: None, ..all.clone() }).unwrap().len(), 2); - assert_eq!(c.select(View::Live, Filter { subject_prefix: None, ..all.clone() }).unwrap().len(), 2); - assert_eq!(c.select(View::Live, Filter { occurred_after: None, ..all }).unwrap().len(), 2); + assert_eq!( + rows.iter() + .map(|r| r.claim.claim_key.as_str()) + .collect::>(), + vec!["hit"] + ); + assert_eq!( + c.select( + View::Live, + Filter { + kinds: None, + ..all.clone() + } + ) + .unwrap() + .len(), + 2 + ); + assert_eq!( + c.select( + View::Live, + Filter { + observer: None, + ..all.clone() + } + ) + .unwrap() + .len(), + 2 + ); + assert_eq!( + c.select( + View::Live, + Filter { + subject_prefix: None, + ..all.clone() + } + ) + .unwrap() + .len(), + 2 + ); + assert_eq!( + c.select( + View::Live, + Filter { + occurred_after: None, + ..all + } + ) + .unwrap() + .len(), + 2 + ); // a claim with no subject_key cannot answer a subject_prefix filter, // not even the empty one every subject starts with - let f = Filter { subject_prefix: Some(String::new()), ..Filter::default() }; + let f = Filter { + subject_prefix: Some(String::new()), + ..Filter::default() + }; assert_eq!(c.select(View::Live, f).unwrap().len(), 5); // an unclassified claim cannot answer a kinds filter - let f = Filter { kinds: Some(vec!["fyi".into()]), ..Filter::default() }; + let f = Filter { + kinds: Some(vec!["fyi".into()]), + ..Filter::default() + }; assert!(c.select(View::Live, f).unwrap().is_empty()); // occurred_after is strict - let f = Filter { occurred_after: Some(900_000), ..Filter::default() }; + let f = Filter { + occurred_after: Some(900_000), + ..Filter::default() + }; assert!(c.select(View::Live, f).unwrap().is_empty()); // an empty entity list names no entity, so it matches nothing - let f = Filter { entities: Some(vec![]), ..Filter::default() }; + let f = Filter { + entities: Some(vec![]), + ..Filter::default() + }; assert!(c.select(View::Live, f).unwrap().is_empty()); // limit: honoured, clamped rather than rejected, and zero means zero - let f = Filter { limit: Some(2), ..Filter::default() }; + let f = Filter { + limit: Some(2), + ..Filter::default() + }; assert_eq!(c.select(View::Live, f).unwrap().len(), 2); - let f = Filter { limit: Some(100_000), ..Filter::default() }; + let f = Filter { + limit: Some(100_000), + ..Filter::default() + }; assert_eq!(c.select(View::Live, f).unwrap().len(), 6); - let f = Filter { limit: Some(0), ..Filter::default() }; + let f = Filter { + limit: Some(0), + ..Filter::default() + }; assert!(c.select(View::Live, f).unwrap().is_empty()); // View::OpenLoops: the five "overdue" claims classified risk (a loop @@ -428,16 +670,40 @@ fn select_filters_and_compose_and_limit_is_bounded() { // a loop and does not appear. let loops = c.select(View::OpenLoops, Filter::default()).unwrap(); assert_eq!( - loops.iter().map(|r| r.claim.claim_key.as_str()).collect::>(), - vec!["hit", "no-subject", "too-old", "wrong-observer", "wrong-subject"] + loops + .iter() + .map(|r| r.claim.claim_key.as_str()) + .collect::>(), + vec![ + "hit", + "no-subject", + "too-old", + "wrong-observer", + "wrong-subject" + ] + ); + assert!( + loops + .iter() + .all(|r| r.kind.as_ref().is_some_and(|k| k.kind == "risk")) ); - assert!(loops.iter().all(|r| r.kind.as_ref().is_some_and(|k| k.kind == "risk"))); assert!(loops.iter().all(|r| r.score.is_none()), "only Urgent ranks"); // and the same filters compose over it - let f = Filter { observer: Some(ObserverId::from("twist")), ..Filter::default() }; + let f = Filter { + observer: Some(ObserverId::from("twist")), + ..Filter::default() + }; assert_eq!(c.select(View::OpenLoops, f).unwrap().len(), 1); - assert!(matches!(c.select(View::Urgent { scope: "nope".into() }, Filter::default()), Err(ClogError::UnknownScope))); + assert!(matches!( + c.select( + View::Urgent { + scope: "nope".into() + }, + Filter::default() + ), + Err(ClogError::UnknownScope) + )); } // Beyond the brief: `believed` is three-valued, and `EntityState` reports @@ -447,7 +713,11 @@ fn select_believed_flag_and_entity_state_rows() { let dir = tempfile::tempdir().unwrap(); let c = Clog::open(cfg(dir.path())).unwrap(); c.advance(1_000_000).unwrap(); - let entity = EntityRef { etype: "project".into(), id: "halcyon".into(), name: None }; + let entity = EntityRef { + etype: "project".into(), + id: "halcyon".into(), + name: None, + }; let mut loser = claim("a-loser", "invoice overdue", 100_000); loser.subject_key = Some("inv:status".into()); loser.entities = vec![entity.clone()]; @@ -455,12 +725,22 @@ fn select_believed_flag_and_entity_state_rows() { winner.subject_key = Some("inv:status".into()); winner.entities = vec![entity.clone()]; let loose = claim("c-loose", "no subject at all", 200_000); - c.observe(vec![loser, winner, loose], ObserveOpts::default()).unwrap(); + c.observe(vec![loser, winner, loose], ObserveOpts::default()) + .unwrap(); let rows = c.select(View::Live, Filter::default()).unwrap(); - let flags: Vec<(&str, Option)> = - rows.iter().map(|r| (r.claim.claim_key.as_str(), r.believed)).collect(); - assert_eq!(flags, vec![("a-loser", Some(false)), ("b-winner", Some(true)), ("c-loose", None)]); + let flags: Vec<(&str, Option)> = rows + .iter() + .map(|r| (r.claim.claim_key.as_str(), r.believed)) + .collect(); + assert_eq!( + flags, + vec![ + ("a-loser", Some(false)), + ("b-winner", Some(true)), + ("c-loose", None) + ] + ); // entity_state carries only the subject's winner, flagged accordingly let rows = c.select(View::EntityState, Filter::default()).unwrap(); @@ -468,7 +748,10 @@ fn select_believed_flag_and_entity_state_rows() { assert_eq!(rows[0].claim.claim_key, "b-winner"); assert_eq!(rows[0].believed, Some(true)); // and the entity filter reaches it - let f = Filter { entities: Some(vec![entity]), ..Filter::default() }; + let f = Filter { + entities: Some(vec![entity]), + ..Filter::default() + }; assert_eq!(c.select(View::EntityState, f).unwrap().len(), 1); } @@ -479,12 +762,20 @@ fn revoke_of_an_unknown_observer_commits_nothing() { let dir = tempfile::tempdir().unwrap(); let c = Clog::open(cfg(dir.path())).unwrap(); c.advance(1_000_000).unwrap(); - let ack = c.observe(vec![claim("a", "x", 500_000)], ObserveOpts::default()).unwrap(); - assert_eq!(c.revoke_observer(&ObserverId::from("nobody")).unwrap().rev, ack.rev); + let ack = c + .observe(vec![claim("a", "x", 500_000)], ObserveOpts::default()) + .unwrap(); + assert_eq!( + c.revoke_observer(&ObserverId::from("nobody")).unwrap().rev, + ack.rev + ); let done = c.revoke_observer(&ObserverId::from("test")).unwrap(); assert_eq!(done.rev, ack.rev + 1); // and again: now there is nothing left, so it is a no-op - assert_eq!(c.revoke_observer(&ObserverId::from("test")).unwrap().rev, done.rev); + assert_eq!( + c.revoke_observer(&ObserverId::from("test")).unwrap().rev, + done.rev + ); } // Beyond the brief: a self-merge is a cycle too, and a repeated merge is an @@ -494,20 +785,48 @@ fn merge_self_is_a_cycle_and_re_merging_is_a_no_op() { let dir = tempfile::tempdir().unwrap(); let c = Clog::open(cfg(dir.path())).unwrap(); c.advance(1_000_000).unwrap(); - let a = EntityRef { etype: "p".into(), id: "a".into(), name: None }; - let b = EntityRef { etype: "p".into(), id: "b".into(), name: None }; - assert!(matches!(c.merge_entities(&a, &a), Err(ClogError::AliasCycle))); + let a = EntityRef { + etype: "p".into(), + id: "a".into(), + name: None, + }; + let b = EntityRef { + etype: "p".into(), + id: "b".into(), + name: None, + }; + assert!(matches!( + c.merge_entities(&a, &a), + Err(ClogError::AliasCycle) + )); let first = c.merge_entities(&a, &b).unwrap(); - assert_eq!(c.merge_entities(&a, &b).unwrap().rev, first.rev, "identical merge must not commit"); + assert_eq!( + c.merge_entities(&a, &b).unwrap().rev, + first.rev, + "identical merge must not commit" + ); // A self-merge of an *already aliased* entity is still a cycle: the // flattened target of `a` is now `b`, so only an identity check catches // it, and it must not mint an inert `a -> a` claim. - assert!(matches!(c.merge_entities(&a, &a), Err(ClogError::AliasCycle))); + assert!(matches!( + c.merge_entities(&a, &a), + Err(ClogError::AliasCycle) + )); assert_eq!(c.select(View::Live, Filter::default()).unwrap().len(), 0); - assert!(matches!(c.retract("clog:merge:p:a->p:a"), Err(ClogError::UnknownClaim))); + assert!(matches!( + c.retract("clog:merge:p:a->p:a"), + Err(ClogError::UnknownClaim) + )); // §10 still applies to the entity refs a merge names - let bad = EntityRef { etype: "p".into(), id: String::new(), name: None }; - assert!(matches!(c.merge_entities(&bad, &b), Err(ClogError::InvalidClaim { .. }))); + let bad = EntityRef { + etype: "p".into(), + id: String::new(), + name: None, + }; + assert!(matches!( + c.merge_entities(&bad, &b), + Err(ClogError::InvalidClaim { .. }) + )); } // The 8-row cap in §5.3 is scoped "for rendering": the rendered entities @@ -518,11 +837,19 @@ fn entity_state_select_is_uncapped_while_the_rendered_slot_still_summarizes() { let dir = tempfile::tempdir().unwrap(); let c = Clog::open(cfg(dir.path())).unwrap(); c.advance(1_000_000).unwrap(); - let entity = EntityRef { etype: "proj".into(), id: "h".into(), name: Some("Halcyon".into()) }; + let entity = EntityRef { + etype: "proj".into(), + id: "h".into(), + name: Some("Halcyon".into()), + }; // Ten subjects, one believed claim each, all on the same entity. let batch: Vec = (0..10) .map(|i| { - let mut cl = claim(&format!("k{i:02}"), &format!("subject {i:02} update"), 100_000 + i); + let mut cl = claim( + &format!("k{i:02}"), + &format!("subject {i:02} update"), + 100_000 + i, + ); cl.subject_key = Some(format!("s{i:02}")); cl.entities = vec![entity.clone()]; cl @@ -534,21 +861,32 @@ fn entity_state_select_is_uncapped_while_the_rendered_slot_still_summarizes() { let rows = c.select(View::EntityState, Filter::default()).unwrap(); assert_eq!(rows.len(), 10, "select must not inherit the render cap"); assert_eq!( - rows.iter().map(|r| r.claim.subject_key.as_deref().unwrap_or("")).collect::>(), + rows.iter() + .map(|r| r.claim.subject_key.as_deref().unwrap_or("")) + .collect::>(), (0..10).map(|i| format!("s{i:02}")).collect::>() ); assert!(rows.iter().all(|r| r.believed == Some(true))); // the rendered slot still shows the newest 8 summaries on one line let text = c.situation(None, None).unwrap().text; - let line = text.lines().find(|l| l.starts_with("Halcyon: ")).expect("entities slot"); + let line = text + .lines() + .find(|l| l.starts_with("Halcyon: ")) + .expect("entities slot"); let summaries: Vec<&str> = line.trim_start_matches("Halcyon: ").split("; ").collect(); assert_eq!(summaries.len(), 8, "{line}"); assert!(summaries[0].starts_with("subject 09"), "{line}"); // newest first - assert!(!line.contains("subject 00") && !line.contains("subject 01"), "{line}"); + assert!( + !line.contains("subject 00") && !line.contains("subject 01"), + "{line}" + ); // and the caller's own limit is the only cap that applies to select - let f = Filter { limit: Some(3), ..Filter::default() }; + let f = Filter { + limit: Some(3), + ..Filter::default() + }; assert_eq!(c.select(View::EntityState, f).unwrap().len(), 3); } @@ -560,17 +898,55 @@ fn entity_state_select_reports_a_multi_entity_claim_once() { let dir = tempfile::tempdir().unwrap(); let c = Clog::open(cfg(dir.path())).unwrap(); c.advance(1_000_000).unwrap(); - let x = EntityRef { etype: "p".into(), id: "x".into(), name: None }; - let y = EntityRef { etype: "p".into(), id: "y".into(), name: None }; + let x = EntityRef { + etype: "p".into(), + id: "x".into(), + name: None, + }; + let y = EntityRef { + etype: "p".into(), + id: "y".into(), + name: None, + }; let mut both = claim("both", "concerns x and y", 100_000); both.subject_key = Some("s1".into()); both.entities = vec![x.clone(), y.clone()]; c.observe(vec![both], ObserveOpts::default()).unwrap(); // it really is indexed under both entities - assert_eq!(c.select(View::EntityState, Filter { entities: Some(vec![x.clone()]), ..Filter::default() }).unwrap().len(), 1); - assert_eq!(c.select(View::EntityState, Filter { entities: Some(vec![y.clone()]), ..Filter::default() }).unwrap().len(), 1); + assert_eq!( + c.select( + View::EntityState, + Filter { + entities: Some(vec![x.clone()]), + ..Filter::default() + } + ) + .unwrap() + .len(), + 1 + ); + assert_eq!( + c.select( + View::EntityState, + Filter { + entities: Some(vec![y.clone()]), + ..Filter::default() + } + ) + .unwrap() + .len(), + 1 + ); // ...and still yields exactly one row, filtered or not - assert_eq!(c.select(View::EntityState, Filter::default()).unwrap().len(), 1); - let f = Filter { entities: Some(vec![x, y]), ..Filter::default() }; + assert_eq!( + c.select(View::EntityState, Filter::default()) + .unwrap() + .len(), + 1 + ); + let f = Filter { + entities: Some(vec![x, y]), + ..Filter::default() + }; assert_eq!(c.select(View::EntityState, f).unwrap().len(), 1); } diff --git a/examples/clog/tests/g1_agency.rs b/examples/clog/tests/g1_agency.rs index 122eb7b..c2c31e9 100644 --- a/examples/clog/tests/g1_agency.rs +++ b/examples/clog/tests/g1_agency.rs @@ -27,7 +27,10 @@ fn day(n: u64) -> u64 { /// see the comment at its declaration), Manual clock. fn fixture_config(dir: &std::path::Path) -> Config { let mut c = Config::default_for(dir); - c.tick = TickConfig { mode: ClockMode::Manual, interval_ms: 60_000 }; + c.tick = TickConfig { + mode: ClockMode::Manual, + interval_ms: 60_000, + }; c.scopes.insert( "delivery-health".into(), @@ -35,28 +38,50 @@ fn fixture_config(dir: &std::path::Path) -> Config { .weight("risk", 2.5) .weight("question", 1.5) .weight("commitment", 1.5) - .boost(EntityRef { etype: "project".into(), id: "halcyon".into(), name: None }, 1.5), + .boost( + EntityRef { + etype: "project".into(), + id: "halcyon".into(), + name: None, + }, + 1.5, + ), ); c.scopes.insert( "cash-and-collections".into(), - Focus::uniform().weight("risk", 2.0).weight("fyi", 0.5).weight("opportunity", 1.5), + Focus::uniform() + .weight("risk", 2.0) + .weight("fyi", 0.5) + .weight("opportunity", 1.5), ); for kd in &mut c.kinds.kinds { match kd.name.as_str() { "risk" => kd.rules.push(Rule { - any_of: vec![Matcher::BodyContains("overdue".into()), Matcher::BodyContains("slipping".into())], + any_of: vec![ + Matcher::BodyContains("overdue".into()), + Matcher::BodyContains("slipping".into()), + ], + }), + "question" => kd.rules.push(Rule { + any_of: vec![Matcher::BodyRegex(r"\?$".into())], + }), + "fact" => kd.rules.push(Rule { + any_of: vec![Matcher::ObserverIs("bank-feed".into())], + }), + "opportunity" => kd.rules.push(Rule { + any_of: vec![Matcher::BodyContains("inbound".into())], }), - "question" => kd.rules.push(Rule { any_of: vec![Matcher::BodyRegex(r"\?$".into())] }), - "fact" => kd.rules.push(Rule { any_of: vec![Matcher::ObserverIs("bank-feed".into())] }), - "opportunity" => kd.rules.push(Rule { any_of: vec![Matcher::BodyContains("inbound".into())] }), // Beyond the brief's four required rules: without this, claims // 5/6/11 (a kickoff move, a PTO note, a moved 1:1) all land in // `Unclassified`, and cash-and-collections' `fyi = 0.5` damping // — part of this fixture's required story — would never be // exercised by any live claim. Documented in task-17-report.md. "fyi" => kd.rules.push(Rule { - any_of: vec![Matcher::BodyContains("moved".into()), Matcher::BodyContains("PTO".into())], + any_of: vec![ + Matcher::BodyContains("moved".into()), + Matcher::BodyContains("PTO".into()), + ], }), _ => {} } @@ -92,13 +117,25 @@ fn claim( } fn halcyon() -> EntityRef { - EntityRef { etype: "project".into(), id: "halcyon".into(), name: Some("Halcyon".into()) } + EntityRef { + etype: "project".into(), + id: "halcyon".into(), + name: Some("Halcyon".into()), + } } fn samuel() -> EntityRef { - EntityRef { etype: "person".into(), id: "samuel".into(), name: Some("Samuel".into()) } + EntityRef { + etype: "person".into(), + id: "samuel".into(), + name: Some("Samuel".into()), + } } fn sam() -> EntityRef { - EntityRef { etype: "person".into(), id: "sam".into(), name: Some("Sam".into()) } + EntityRef { + etype: "person".into(), + id: "sam".into(), + name: Some("Sam".into()), + } } fn unclassified_keys(c: &Clog) -> String { @@ -200,8 +237,16 @@ fn g1_agency_simulation() { ) .unwrap(); - insta::assert_snapshot!("g1_a_delivery", c.situation(Some("delivery-health"), None).unwrap().text); - insta::assert_snapshot!("g1_a_cash", c.situation(Some("cash-and-collections"), None).unwrap().text); + insta::assert_snapshot!( + "g1_a_delivery", + c.situation(Some("delivery-health"), None).unwrap().text + ); + insta::assert_snapshot!( + "g1_a_cash", + c.situation(Some("cash-and-collections"), None) + .unwrap() + .text + ); insta::assert_snapshot!("g1_a_unclassified", unclassified_keys(&c)); // ====================================================================== @@ -241,26 +286,56 @@ fn g1_agency_simulation() { ) .unwrap(); - insta::assert_snapshot!("g1_b_delivery", c.situation(Some("delivery-health"), None).unwrap().text); - insta::assert_snapshot!("g1_b_cash", c.situation(Some("cash-and-collections"), None).unwrap().text); + insta::assert_snapshot!( + "g1_b_delivery", + c.situation(Some("delivery-health"), None).unwrap().text + ); + insta::assert_snapshot!( + "g1_b_cash", + c.situation(Some("cash-and-collections"), None) + .unwrap() + .text + ); insta::assert_snapshot!("g1_b_unclassified", unclassified_keys(&c)); // `EntityState` reports only believed winners: the deliverable (its // subject's only claim) and claim 3 (the invoice's current winner). - let entity_state_rows = - c.select(View::EntityState, Filter { entities: Some(vec![halcyon()]), ..Filter::default() }).unwrap(); + let entity_state_rows = c + .select( + View::EntityState, + Filter { + entities: Some(vec![halcyon()]), + ..Filter::default() + }, + ) + .unwrap(); assert_eq!( - entity_state_rows.iter().map(|r| (r.claim.claim_key.as_str(), r.believed)).collect::>(), - vec![("halcyon:deliverable:slip", Some(true)), ("halcyon:inv-1042:v2", Some(true))], + entity_state_rows + .iter() + .map(|r| (r.claim.claim_key.as_str(), r.believed)) + .collect::>(), + vec![ + ("halcyon:deliverable:slip", Some(true)), + ("halcyon:inv-1042:v2", Some(true)) + ], ); // `Live` shows every competitor for the invoice subject with its flag: // claim 3 (later occurred_at) beats both claim 2 and the far-better- // trusted claim 4. let live_rows = c - .select(View::Live, Filter { subject_prefix: Some("halcyon:inv-1042:".into()), ..Filter::default() }) + .select( + View::Live, + Filter { + subject_prefix: Some("halcyon:inv-1042:".into()), + ..Filter::default() + }, + ) .unwrap(); assert_eq!( - live_rows.iter().map(|r| (r.claim.claim_key.as_str(), r.believed)).collect::>(), + live_rows + .iter() + .map(|r| (r.claim.claim_key.as_str(), r.believed)) + .collect::>(), vec![ ("halcyon:inv-1042:paid", Some(false)), ("halcyon:inv-1042:v1", Some(false)), @@ -294,15 +369,32 @@ fn g1_agency_simulation() { c.retract("meridian:question:sow").unwrap(); c.retract("halcyon:inv-1042:v2").unwrap(); - insta::assert_snapshot!("g1_c_delivery", c.situation(Some("delivery-health"), None).unwrap().text); - insta::assert_snapshot!("g1_c_cash", c.situation(Some("cash-and-collections"), None).unwrap().text); + insta::assert_snapshot!( + "g1_c_delivery", + c.situation(Some("delivery-health"), None).unwrap().text + ); + insta::assert_snapshot!( + "g1_c_cash", + c.situation(Some("cash-and-collections"), None) + .unwrap() + .text + ); insta::assert_snapshot!("g1_c_unclassified", unclassified_keys(&c)); let healed = c - .select(View::EntityState, Filter { entities: Some(vec![halcyon()]), ..Filter::default() }) + .select( + View::EntityState, + Filter { + entities: Some(vec![halcyon()]), + ..Filter::default() + }, + ) .unwrap(); assert_eq!( - healed.iter().map(|r| r.claim.claim_key.as_str()).collect::>(), + healed + .iter() + .map(|r| r.claim.claim_key.as_str()) + .collect::>(), vec!["halcyon:deliverable:slip", "halcyon:inv-1042:paid"], "belief must flip to the bank-feed claim once the fresher-but-wrong claim 3 is retracted", ); @@ -315,8 +407,16 @@ fn g1_agency_simulation() { c.advance(day(2)).unwrap(); // now = day 20_010 c.merge_entities(&samuel(), &sam()).unwrap(); - insta::assert_snapshot!("g1_d_delivery", c.situation(Some("delivery-health"), None).unwrap().text); - insta::assert_snapshot!("g1_d_cash", c.situation(Some("cash-and-collections"), None).unwrap().text); + insta::assert_snapshot!( + "g1_d_delivery", + c.situation(Some("delivery-health"), None).unwrap().text + ); + insta::assert_snapshot!( + "g1_d_cash", + c.situation(Some("cash-and-collections"), None) + .unwrap() + .text + ); insta::assert_snapshot!("g1_d_unclassified", unclassified_keys(&c)); // `EntityState` orders rows (canonical entity, subject) ascending: @@ -324,9 +424,20 @@ fn g1_agency_simulation() { // so claim 6 (the PTO note) sorts before claim 11 even though claim 11 // occurred later — this is `select`'s subject-key order, not the // newest-first order the *rendered* entities slot uses. - let merged = c.select(View::EntityState, Filter { entities: Some(vec![sam()]), ..Filter::default() }).unwrap(); + let merged = c + .select( + View::EntityState, + Filter { + entities: Some(vec![sam()]), + ..Filter::default() + }, + ) + .unwrap(); assert_eq!( - merged.iter().map(|r| r.claim.claim_key.as_str()).collect::>(), + merged + .iter() + .map(|r| r.claim.claim_key.as_str()) + .collect::>(), vec!["meridian:pto:sam", "meridian:sam:oneone"], "both person:samuel's and person:sam's believed claims now group under one canonical entity" ); diff --git a/examples/clog/tests/props.rs b/examples/clog/tests/props.rs index 318a4bc..b221ad1 100644 --- a/examples/clog/tests/props.rs +++ b/examples/clog/tests/props.rs @@ -46,18 +46,29 @@ fn arb_claim() -> impl Strategy { Credibility::Six, ][cred as usize], entities: if with_ent { - vec![EntityRef { etype: "p".into(), id: "a".into(), name: None }] + vec![EntityRef { + etype: "p".into(), + id: "a".into(), + name: None, + }] } else { vec![] }, - body: words.iter().map(|w| vocab[*w as usize]).collect::>().join(" "), + body: words + .iter() + .map(|w| vocab[*w as usize]) + .collect::>() + .join(" "), } }) } fn open_manual(dir: &std::path::Path) -> Clog { let mut c = Config::default_for(dir); - c.tick = TickConfig { mode: ClockMode::Manual, interval_ms: 60_000 }; + c.tick = TickConfig { + mode: ClockMode::Manual, + interval_ms: 60_000, + }; let h = Clog::open(c).unwrap(); h.advance(1_000_000).unwrap(); h @@ -80,7 +91,11 @@ fn norm(s: &Situation) -> String { } fn live_keys(c: &Clog) -> Vec { - c.select(View::Live, Filter::default()).unwrap().into_iter().map(|r| r.claim.claim_key).collect() + c.select(View::Live, Filter::default()) + .unwrap() + .into_iter() + .map(|r| r.claim.claim_key) + .collect() } proptest! { diff --git a/examples/clog/tests/recovery.rs b/examples/clog/tests/recovery.rs index de10c49..b962867 100644 --- a/examples/clog/tests/recovery.rs +++ b/examples/clog/tests/recovery.rs @@ -25,7 +25,10 @@ const SCRIPT_LEN: u64 = 4; fn manual_cfg(dir: &std::path::Path) -> Config { let mut c = Config::default_for(dir); - c.tick = TickConfig { mode: ClockMode::Manual, interval_ms: 60_000 }; + c.tick = TickConfig { + mode: ClockMode::Manual, + interval_ms: 60_000, + }; c } @@ -51,16 +54,19 @@ fn claim(key: &str, body: &str) -> Claim { fn step(c: &Clog, n: u64) { match n { 1 => { - c.observe(vec![claim("a", "first")], ObserveOpts::default()).unwrap(); + c.observe(vec![claim("a", "first")], ObserveOpts::default()) + .unwrap(); } 2 => { - c.observe(vec![claim("b", "second")], ObserveOpts::default()).unwrap(); + c.observe(vec![claim("b", "second")], ObserveOpts::default()) + .unwrap(); } 3 => { c.retract("a").unwrap(); } 4 => { - c.observe(vec![claim("c", "third")], ObserveOpts::default()).unwrap(); + c.observe(vec![claim("c", "third")], ObserveOpts::default()) + .unwrap(); } _ => unreachable!("script has {SCRIPT_LEN} steps, asked for {n}"), } @@ -102,7 +108,10 @@ fn r1_crash_points() { .env("CLOG_CRASH_AFTER_WAL", crash_after.to_string()) .status() .unwrap(); - assert!(!status.success(), "child must abort (crash point {crash_after})"); + assert!( + !status.success(), + "child must abort (crash point {crash_after})" + ); // A panicking child would also be "unsuccessful", and would mean the // hook never fired — so insist on death by signal, i.e. `abort()`. #[cfg(unix)] @@ -123,7 +132,10 @@ fn r1_crash_points() { let fresh_dir = tempfile::tempdir().unwrap(); let fresh = Clog::open(manual_cfg(fresh_dir.path())).unwrap(); run_prefix(&fresh, crash_after); - let (s1, s2) = (reopened.situation(None, None).unwrap(), fresh.situation(None, None).unwrap()); + let (s1, s2) = ( + reopened.situation(None, None).unwrap(), + fresh.situation(None, None).unwrap(), + ); assert_eq!(s1.text, s2.text, "crash point {crash_after}"); assert_eq!(s1.rev, s2.rev, "crash point {crash_after}"); assert_eq!(s1.as_of, s2.as_of, "crash point {crash_after}"); @@ -132,10 +144,19 @@ fn r1_crash_points() { // rev continues from the durable prefix rather than from a gap. reopened.advance(2_000_000).unwrap(); let ack = reopened - .observe(vec![claim("post", "after the crash")], ObserveOpts::default()) + .observe( + vec![claim("post", "after the crash")], + ObserveOpts::default(), + ) .unwrap(); assert_eq!(ack.rev, crash_after + 1, "crash point {crash_after}"); - assert!(reopened.situation(None, None).unwrap().text.contains("after the crash")); + assert!( + reopened + .situation(None, None) + .unwrap() + .text + .contains("after the crash") + ); } } From c0f7b9c38b591f606f7e7c4157b8c1bb7333fc90 Mon Sep 17 00:00:00 2001 From: hhff Date: Sat, 15 Aug 2026 23:08:58 -0700 Subject: [PATCH 27/28] fix(clog): adversarial-review fix wave for P1 Critical: - wal: a failed append could strand a partial frame mid-log; the next successful append landed after it and reopen's CRC scan then quarantined and truncated everything from the tear onward, silently discarding acked batches. `append` now records the pre-write file length and truncates back to it on ANY error; if the truncation itself fails the Wal is poisoned and refuses further appends rather than compounding the damage. - validate: `scoring_clamp` overflowed `now + 24h` when `now` was within a day of u64::MAX (reachable - the manual clock saturates there), panicking the writer under overflow checks. Now saturating. - actor: an entity's registry display name reached the rendered document verbatim, so a newline in `EntityRef.name` fabricated document lines. It now goes through `render::headline` like every other host string. Important: - actor/types: INV-5's duplicate skip used `Claim: PartialEq`, whose `EntityRef` equality ignores `name`, so a re-observe changing only a display name was swallowed (no commit, registry never updated). New `claims_identical` compares the name lists alongside, leaving EntityRef's identity semantics (load-bearing for every index) untouched. - actor: merge claims stamped occurred_at/observed_at from a clock that reads 0 on a fresh Manual instance, failing spec 10 validation. Now stamps `now.max(1)`. - types/kinds: `Matcher::BodyContains` now carries its own case-sensitivity flag (spec 5.6) instead of hard-coding case-insensitivity. - types: Focus boost docs said "additive"; they are multiplicative (5.4). View::Live and Config.rebuild_on_open docs corrected. - validate: reject `subject_key: Some("")`/whitespace-only, and `decay_buckets_per_half_life == 0` (NaN scores) at open. - validate: focus *value* errors are now Corrupt { "config: ..." } rather than InvalidFilter, matching the bad-regex ruling; UnknownKind unchanged. - actor: `mask_header` string-replace replaced by a structural body/scope comparison, removing the adversarial-scope-name fragility. Tests: WAL rollback + poison, u64::MAX clamp, entity-name injection, display-name re-observe, fresh-clock merge, case-sensitive BodyContains, belief tiers 2/3 in isolation, a real budget-cap + drop-order render test, min_score on Urgent, and config rejection at open. Goldens unchanged. Co-Authored-By: Claude Fable 5 --- examples/clog/README.md | 4 +- examples/clog/src/actor.rs | 88 ++++++++++---- examples/clog/src/belief.rs | 48 ++++++++ examples/clog/src/kinds.rs | 69 +++++++++-- examples/clog/src/lib.rs | 11 +- examples/clog/src/render/mod.rs | 179 ++++++++++++++++++++++++---- examples/clog/src/types.rs | 23 ++-- examples/clog/src/validate.rs | 123 ++++++++++++++++--- examples/clog/src/wal.rs | 157 +++++++++++++++++++++++- examples/clog/tests/api.rs | 198 ++++++++++++++++++++++++++++++- examples/clog/tests/g1_agency.rs | 10 +- 11 files changed, 812 insertions(+), 98 deletions(-) diff --git a/examples/clog/README.md b/examples/clog/README.md index 9acc951..8f6f6ba 100644 --- a/examples/clog/README.md +++ b/examples/clog/README.md @@ -128,11 +128,11 @@ override what you need. | `top_k` | `12` | Default cap on ranked rows per rendered situation. | | `budget_chars` | `6000` | Character budget for a rendered situation document. | | `tick` | `TickConfig { mode: System, interval_ms: 60_000 }` | Clock mode (`System` or `Manual`, INV-10) and tick interval. | -| `decay_buckets_per_half_life` | `4` | Granularity of the bucketed recency-decay clock. | +| `decay_buckets_per_half_life` | `4` | Granularity of the bucketed recency-decay clock. Must be `> 0`; `0` is rejected at `open`. | | `belief_min_credibility` | `Credibility::Six` | The credibility floor below which a claim is never believed. | | `wal_fsync` | `FsyncPolicy::OnCommit` | Whether the WAL fsyncs after every commit or relies on OS buffering. | | `write_queue` | `1024` | Bounded depth of the writer's command channel — backpressure past this blocks the caller. | -| `rebuild_on_open` | `false` | If `true`, discards cached engine state and rebuilds every view from a full WAL replay on open. | +| `rebuild_on_open` | `false` | Accepted; identical to a normal open in P1 (the WAL is always fully replayed). Becomes meaningful with engine snapshots. | ## Caveats diff --git a/examples/clog/src/actor.rs b/examples/clog/src/actor.rs index e5f455a..0f036ac 100644 --- a/examples/clog/src/actor.rs +++ b/examples/clog/src/actor.rs @@ -33,13 +33,12 @@ use crate::engine::{Batch, Engine, Event, StoredClaim, WorldViews}; use crate::engine::{merge_body, merge_key}; use crate::kinds::{self, RuleSet}; use crate::render::template::{DEFAULT_TEMPLATE, Template, parse}; -use crate::render::time::rfc3339_utc; use crate::render::{ChangeItem, EntityItem, LoopItem, SlotInputs, UrgentItem, headline, render}; use crate::types::{ Ack, Claim, ClogError, Config, Credibility, EntityRef, Filter, Focus, ObserveOpts, ObserverId, Reliability, Rev, Row, Situation, View, }; -use crate::validate::{validate_claim, validate_focus}; +use crate::validate::{validate_claim, validate_config, validate_focus}; use crate::wal::{self, Wal}; /// The scope every instance always has (build design §9): a uniform focus, @@ -200,6 +199,7 @@ pub(crate) struct Spawned { /// is no engine-state cache to drop yet (build design §5 defers snapshot /// files to M5), so every open is already a full WAL rebuild. pub(crate) fn spawn(cfg: Config) -> Result { + validate_config(&cfg)?; let clock = Clock::new(cfg.tick.mode); let scopes = resolve_scopes(&cfg)?; let rules = kinds::compile(&cfg.kinds)?; @@ -283,6 +283,26 @@ fn run(mut writer: Writer, rx: Receiver) { writer.shutdown(); } +/// Whether two claims are identical *for the INV-5 duplicate skip*: `a` is a +/// re-send of `b` and nothing at all would change by committing it. +/// +/// This is deliberately stricter than `Claim: PartialEq`. `EntityRef`'s +/// equality is identity-only — `(etype, id)`, ignoring `name` — because that +/// is what keys `by_entity`, the boost map and the display-name registry, and +/// weakening it would break every one of them. But that makes `Claim`'s +/// derived equality blind to a changed display name, and a re-observe that +/// changes only a name is a real change: spec §5.1 step 3 supersedes on *any* +/// difference, and §5.2's latest-name-wins registry can only learn the new +/// name if the claim commits. So the entity *names* are compared alongside, +/// positionally, leaving `EntityRef`'s identity semantics untouched. +fn claims_identical(a: &Claim, b: &Claim) -> bool { + a == b + && a.entities + .iter() + .map(|e| &e.name) + .eq(b.entities.iter().map(|e| &e.name)) +} + // ---- the writer ----------------------------------------------------------- /// The writer thread's state. Owned by one thread; never shared. @@ -426,14 +446,19 @@ impl Writer { } let now = self.clock.now_ms(); + // §10 requires `occurred_at`/`observed_at` > 0, and a fresh `Manual` + // clock reads 0 until the host advances it. A merge is clog's own + // write, so it must not fail validation on a technicality the caller + // never chose: it stamps the earliest legal instant instead. + let stamp = now.max(1); let mut claim = Claim { claim_key: merge_key(&alias_key, &canonical_key), subject_key: None, source_ref: MERGE_SOURCE_REF.to_string(), observer: ObserverId::from(CLOG_OBSERVER), schema_v: 1, - occurred_at: now, - observed_at: now, + occurred_at: stamp, + observed_at: stamp, reliability: Reliability::A, credibility: Credibility::One, entities: vec![alias.clone(), canonical.clone()], @@ -452,7 +477,7 @@ impl Writer { .get(&claim.claim_key) .map(|sc| &sc.claim); let events = match live { - Some(old) if *old == claim => Vec::new(), + Some(old) if claims_identical(old, &claim) => Vec::new(), Some(_) => vec![ Event::Retract { claim_key: claim.claim_key.clone(), @@ -476,9 +501,8 @@ impl Writer { /// that point in the batch*: an identical claim is skipped entirely /// (INV-5 — no rev bump, no WAL record, invisible), a different one /// becomes `Retract(old)` + `Observe(new)` (INV-4). The comparison is - /// full structural equality of the `Claim`; `recorded_at` is not part - /// of a claim, so a re-send with a later arrival time is still a - /// duplicate. + /// [`claims_identical`]; `recorded_at` is not part of a claim, so a + /// re-send with a later arrival time is still a duplicate. /// /// The rules tier then classifies the **surviving** version of each key /// — the one still live when the batch finishes — and appends the @@ -502,7 +526,7 @@ impl Writer { .map(|sc| &sc.claim) }); match live { - Some(old) if old == claim => continue, + Some(old) if claims_identical(old, claim) => continue, Some(_) => events.push(Event::Retract { claim_key: claim.claim_key.clone(), }), @@ -647,10 +671,7 @@ impl Writer { // header included — a fresh timestamp on identical content would // claim a change that did not happen. let retained = previous - .filter(|s| { - mask_header(&s.situation.text, s.situation.rev, s.situation.as_of) - == mask_header(&text, self.rev, now) - }) + .filter(|s| same_document(&s.situation, scope, &text)) .map(|s| s.situation.clone()); let situation = retained.unwrap_or(Situation { scope: scope.to_string(), @@ -892,19 +913,26 @@ fn hydrate(views: &WorldViews, key: &str, stored: &StoredClaim, score: Option String { - text.replacen( - &format!("rev {rev} · {}", rfc3339_utc(as_of)), - "rev _ · _", - 1, - ) +/// Splitting beats masking the rev/timestamp pair out of the string: a claim +/// body is host-supplied text that can contain anything, including the exact +/// bytes of a header, and a mask that matches inside a body would call a +/// changed document unchanged. +fn same_document(previous: &Situation, scope: &str, text: &str) -> bool { + /// Everything after the first line — `""` for a single-line document. + fn body(text: &str) -> &str { + text.split_once('\n').map_or("", |(_, rest)| rest) + } + previous.scope == scope && body(&previous.text) == body(text) } /// Builds one scope's slot inputs from the materialized views (§5.7, §5.8). @@ -948,11 +976,19 @@ fn slot_inputs(views: &WorldViews, scope: &str, rev: Rev, as_of_ms: u64) -> Slot // newest-first). An entity nobody believes anything about contributes // no summaries, and a bare "Name: " line says nothing, so it is dropped // rather than rendered empty. + // + // The display name goes through `headline` exactly as a claim body does. + // It is host-supplied text reaching the document verbatim, so a newline + // in `EntityRef.name` would otherwise fabricate document lines — an + // entity called "Acme\n1. (9.9) ship it now" would render as a urgent + // row nobody claimed. Collapsing whitespace and capping at 120 chars + // makes every entity exactly one line, as §5.8 already requires of every + // other item. let entities = entity_state(views, Some(ENTITY_STATE_ROWS)) .into_iter() .filter(|(_, _, rows)| !rows.is_empty()) .map(|(_, display, rows)| EntityItem { - display, + display: headline(&display), summaries: rows .iter() .map(|(_, stored)| headline(&stored.claim.body)) diff --git a/examples/clog/src/belief.rs b/examples/clog/src/belief.rs index e86beb7..fd72375 100644 --- a/examples/clog/src/belief.rs +++ b/examples/clog/src/belief.rs @@ -127,6 +127,54 @@ mod tests { assert_eq!(resolve(&g, Credibility::Six).unwrap().claim_key, "z"); } + /// Tier 2 in isolation: everything else tied, only reliability differs. + /// + /// The better claim is given the *lexicographically smaller* key and both + /// share a `recorded_at`, so the two tiers below reliability both favour + /// the worse claim. Only a correctly-oriented reliability comparison can + /// produce this answer: drop the tier and the key tiebreak picks + /// `"z-worse"`; invert it and reliability itself picks `"z-worse"`. + #[test] + fn u_belief_1_tier_2_reliability_alone() { + use Credibility::*; + use Reliability::*; + let better = claim("a-better", 100, B, Three); + let worse = claim("z-worse", 100, D, Three); + let g = [ + BeliefInput { + claim: &better, + recorded_at: 7, + }, + BeliefInput { + claim: &worse, + recorded_at: 7, + }, + ]; + assert_eq!(resolve(&g, Six).unwrap().claim_key, "a-better"); + } + + /// Tier 3 in isolation: `occurred_at` *and* reliability tied, only + /// credibility differs. Same trap as tier 2 — the better claim loses + /// every lower tiebreak. + #[test] + fn u_belief_1_tier_3_credibility_alone() { + use Credibility::*; + use Reliability::*; + let better = claim("a-better", 100, C, Two); + let worse = claim("z-worse", 100, C, Five); + let g = [ + BeliefInput { + claim: &better, + recorded_at: 7, + }, + BeliefInput { + claim: &worse, + recorded_at: 7, + }, + ]; + assert_eq!(resolve(&g, Six).unwrap().claim_key, "a-better"); + } + fn b0(base: &Claim, key: &str) -> Claim { let mut c = base.clone(); c.claim_key = key.into(); diff --git a/examples/clog/src/kinds.rs b/examples/clog/src/kinds.rs index 06014ad..20c703c 100644 --- a/examples/clog/src/kinds.rs +++ b/examples/clog/src/kinds.rs @@ -15,12 +15,19 @@ use crate::types::{Claim, ClogError, JudgeSource, KindLabel, KindTaxonomy, Match /// A `Matcher` with any embedded regex pre-compiled at `compile()` time. #[derive(Debug)] enum CompiledMatcher { - /// Case-insensitive substring match (both sides lowercased via - /// `to_lowercase`). This is a v1 simplification: it is not full Unicode - /// case-folding, just `char::to_lowercase` applied to the whole string, - /// which is correct-enough for the ASCII- and common-case text clog - /// expects in claim bodies. - BodyContains(String), + /// Substring match against the claim body. + /// + /// When `case_insensitive`, `needle` is pre-lowered at compile time and + /// the body is lowered per match. That is a v1 simplification: it is not + /// full Unicode case-folding, just `char::to_lowercase` applied to the + /// whole string, which is correct-enough for the ASCII- and common-case + /// text clog expects in claim bodies. + BodyContains { + /// The substring to look for, pre-lowered iff `case_insensitive`. + needle: String, + /// Whether to compare lowercased. + case_insensitive: bool, + }, /// Regex match against the claim body, pre-compiled. BodyRegex(Regex), /// Exact match against the observer's inner string. @@ -38,7 +45,16 @@ struct CompiledRule { impl CompiledRule { fn matches(&self, c: &Claim) -> bool { self.any_of.iter().any(|m| match m { - CompiledMatcher::BodyContains(needle) => c.body.to_lowercase().contains(needle), + CompiledMatcher::BodyContains { + needle, + case_insensitive, + } => { + if *case_insensitive { + c.body.to_lowercase().contains(needle) + } else { + c.body.contains(needle) + } + } CompiledMatcher::BodyRegex(re) => re.is_match(&c.body), CompiledMatcher::ObserverIs(s) => &c.observer.0 == s, CompiledMatcher::EntityType(etype) => c.entities.iter().any(|e| &e.etype == etype), @@ -69,7 +85,14 @@ pub(crate) struct RuleSet { pub(crate) fn compile(tax: &KindTaxonomy) -> Result { fn compile_matcher(m: &Matcher) -> Result { Ok(match m { - Matcher::BodyContains(s) => CompiledMatcher::BodyContains(s.to_lowercase()), + Matcher::BodyContains(s, case_insensitive) => CompiledMatcher::BodyContains { + needle: if *case_insensitive { + s.to_lowercase() + } else { + s.clone() + }, + case_insensitive: *case_insensitive, + }, Matcher::BodyRegex(pat) => { let re = Regex::new(pat).map_err(|e| ClogError::Corrupt { detail: format!("config: bad regex {pat:?}: {e}"), @@ -140,7 +163,7 @@ mod tests { for kd in &mut tax.kinds { match kd.name.as_str() { "risk" => kd.rules.push(Rule { - any_of: vec![Matcher::BodyContains("overdue".into())], + any_of: vec![Matcher::BodyContains("overdue".into(), true)], }), "question" => kd.rules.push(Rule { any_of: vec![ @@ -191,6 +214,34 @@ mod tests { assert!(classify(&rs, &c).is_none()); } + /// §5.6's `BodyContains` carries its own case flag: `false` must compare + /// verbatim, and must not silently fall back to the insensitive path. + #[test] + fn body_contains_honours_the_case_sensitivity_flag() { + let tax_with = |needle: &str, case_insensitive: bool| { + let mut tax = KindTaxonomy::default_taxonomy(); + for kd in &mut tax.kinds { + if kd.name == "risk" { + kd.rules.push(Rule { + any_of: vec![Matcher::BodyContains(needle.into(), case_insensitive)], + }); + } + } + compile(&tax).unwrap() + }; + let mut c = tests_base_claim(); + c.body = "Invoice 1042 is OVERDUE".into(); + + // case-sensitive: only the exact casing matches + assert!(classify(&tax_with("OVERDUE", false), &c).is_some()); + assert!(classify(&tax_with("overdue", false), &c).is_none()); + assert!(classify(&tax_with("Overdue", false), &c).is_none()); + // case-insensitive: any casing of the needle matches any of the body + assert!(classify(&tax_with("overdue", true), &c).is_some()); + assert!(classify(&tax_with("OvErDuE", true), &c).is_some()); + assert!(classify(&tax_with("paid", true), &c).is_none()); + } + #[test] fn bad_regex_rejected_at_compile() { let mut tax = KindTaxonomy::default_taxonomy(); diff --git a/examples/clog/src/lib.rs b/examples/clog/src/lib.rs index 258b484..f9316e0 100644 --- a/examples/clog/src/lib.rs +++ b/examples/clog/src/lib.rs @@ -142,10 +142,13 @@ impl Clog { /// /// - `ClogError::Storage` if the WAL directory cannot be created, read /// or opened; - /// - `ClogError::Corrupt` if a classification rule's regex fails to - /// compile; - /// - `ClogError::UnknownKind` / `ClogError::InvalidFilter` if a - /// configured `Focus` is invalid (spec §10). + /// - `ClogError::UnknownKind` if a configured `Focus` weights a kind the + /// taxonomy does not define (spec §10); + /// - `ClogError::Corrupt { detail: "config: ..." }` for config clog + /// cannot use: a classification rule whose regex will not compile, a + /// `Focus` value that is not finite and positive or whose + /// `half_life_days` is out of bounds, or a zero + /// `decay_buckets_per_half_life`. /// /// A torn or corrupt WAL tail is *not* an error: it is quarantined and /// truncated, and the surviving prefix is replayed (recovery test R2). diff --git a/examples/clog/src/render/mod.rs b/examples/clog/src/render/mod.rs index e88b2d6..989530e 100644 --- a/examples/clog/src/render/mod.rs +++ b/examples/clog/src/render/mod.rs @@ -50,7 +50,9 @@ pub(crate) struct LoopItem { /// An entity summary item (spec §5.8, `%{entities}` slot). #[derive(Clone, Debug)] pub(crate) struct EntityItem { - /// The entity's display name. + /// The entity's display name, pre-normalized through [`headline`] like + /// every other host-supplied string that reaches the document: it is one + /// line, always. pub display: String, /// Summary lines for this entity, newest-first; joined with `"; "`. pub summaries: Vec, @@ -326,30 +328,165 @@ Halcyon: paid; kicked off assert_eq!(out, expected); } + /// A template with no per-slot `limit=`, so budgeting is the *only* + /// thing that can drop an item, and with a literal marker before each + /// slot so the output can be split back into its four blocks. + const TAGGED_TEMPLATE: &str = "[urgent]\n%{urgent}\n[loops]\n%{open_loops}\n[entities]\n%{entities}\n[changes]\n%{changes}\n"; + + /// Three items in every slot, each carrying a tag unique to its slot + /// (`U0..U2`, `L0..L2`, `E0..E2`, `C0..C2`) so a render reads back as + /// "how many of each slot survived". + fn tagged_inputs() -> SlotInputs { + SlotInputs { + scope: "s".into(), + rev: 1, + as_of_ms: 0, + urgent: (0..3) + .map(|i| UrgentItem { + score: 1.0, + headline: format!("U{i}"), + reliability: 'A', + credibility: 1, + claim_key: format!("k{i}"), + }) + .collect(), + open_loops: (0..3) + .map(|i| LoopItem { + kind: "risk".into(), + headline: format!("L{i}"), + claim_key: format!("k{i}"), + }) + .collect(), + entities: (0..3) + .map(|i| EntityItem { + display: format!("E{i}"), + summaries: vec!["x".into()], + }) + .collect(), + changes: (0..3).map(|i| ChangeItem::Added(format!("C{i}"))).collect(), + } + } + + /// `[urgent, open_loops, entities, changes]` survivor counts. + fn slot_counts(out: &str) -> [usize; 4] { + let n = |tag: char| { + (0..3) + .filter(|i| out.contains(&format!("{tag}{i}"))) + .count() + }; + [n('U'), n('L'), n('E'), n('C')] + } + + /// The four rendered blocks of [`TAGGED_TEMPLATE`], in slot order. + fn slot_blocks(out: &str) -> Vec<&str> { + let blocks: Vec<&str> = out.split("\n[").collect(); + assert_eq!(blocks.len(), 4, "template shape changed:\n{out}"); + blocks + } + + /// U-TMPL-2: the budget is a real cap, dropping is ordered, and every + /// truncated slot says how much it hid. + /// + /// Walks *every* budget from the full render down to zero and asserts + /// all three properties at each one, so no single hand-picked budget can + /// make the test vacuous, and asserts the four milestone states are + /// actually reached, so the ordering claim is not vacuous either. #[test] - fn u_tmpl_2_budget_truncation_order() { - // budget small enough to force dropping all changes and one entity summary line + fn u_tmpl_2_budget_is_a_cap_and_truncation_is_ordered() { + let t = parse(TAGGED_TEMPLATE).unwrap(); + let inputs = tagged_inputs(); + let full = render(&t, &inputs, 100_000); + assert_eq!(slot_counts(&full), [3, 3, 3, 3], "{full}"); + let full_len = full.chars().count(); + // Everything droppable dropped: the shortest this document can get, + // and so the smallest budget that can possibly be honoured. + let floor = render(&t, &inputs, 0).chars().count(); + assert!(floor < full_len); + + let mut milestones: Vec<[usize; 4]> = Vec::new(); + for budget in (0..=full_len).rev() { + let out = render(&t, &inputs, budget); + let [urgent, loops, entities, changes] = slot_counts(&out); + + // (a) the budget is honoured wherever it is achievable at all. + if budget >= floor { + assert!( + out.chars().count() <= budget, + "budget {budget} exceeded:\n{out}" + ); + } + + // (b) reverse-priority drop order: a slot may only lose items + // once every lower-priority slot has been drained. + assert!( + entities == 3 || changes == 0, + "entities dropped before changes drained (budget {budget}):\n{out}" + ); + assert!( + loops == 3 || entities == 0, + "open_loops dropped before entities drained (budget {budget}):\n{out}" + ); + assert!( + urgent == 3 || loops == 0, + "urgent dropped before open_loops drained (budget {budget}):\n{out}" + ); + + // (c) each slot's marker counts exactly what that slot hid. + for (block, kept) in slot_blocks(&out) + .into_iter() + .zip([urgent, loops, entities, changes]) + { + if kept == 3 { + assert!(!block.contains('…'), "spurious marker:\n{block}"); + } else { + assert!( + block.contains(&format!("… ({} more)", 3 - kept)), + "wrong or missing marker (kept {kept}):\n{block}" + ); + } + } + + let counts = [urgent, loops, entities, changes]; + if milestones.last() != Some(&counts) { + milestones.push(counts); + } + } + // The staged states really occur: changes drain first, then + // entities, then open_loops, and urgent goes last. + for state in [[3, 3, 3, 0], [3, 3, 0, 0], [3, 0, 0, 0], [0, 0, 0, 0]] { + assert!( + milestones.contains(&state), + "never observed {state:?}; saw {milestones:?}" + ); + } + } + + /// The same cap, exercised against the real default template (whose + /// per-slot `limit=`s and multi-byte `·` separators are the shape that + /// actually ships). + /// + /// The budget is char-based (spec §5.8: "total chars ... not bytes"), so + /// it is derived from `chars().count()`, not `len()`: this document + /// renders three U+00B7 middle dots, and a byte-derived budget of + /// `full.len() - 1` would never be tight enough to force any truncation + /// at all. + #[test] + fn u_tmpl_2_default_template_respects_its_budget() { let t = parse(DEFAULT_TEMPLATE).unwrap(); let full = render(&t, &inputs(), 6000); - // NOTE: the brief's original budget here was `full.len() - 1` - // (bytes). This renders with three middle dots (U+00B7, 2 bytes - // each), so `full.len()` (bytes) exceeds `full.chars().count()` - // by 3, and `render`'s budget check is char-based (spec §5.8: - // "total chars ... not bytes"). A byte-derived budget of - // `full.len() - 1` is therefore never tight enough to trigger any - // truncation at all, which would make every assertion below - // vacuous or false. Using `full.chars().count() - 1` restores the - // test's intent — a budget just barely under the full render — - // against a spec-correct char-counting budgeter. See - // task-9-report.md for the full note. - let tight = render(&t, &inputs(), full.chars().count() - 1); + let budget = full.chars().count() - 1; + let tight = render(&t, &inputs(), budget); + assert!(tight.chars().count() <= budget, "{tight}"); // changes go first, replaced by the marker - assert!(tight.contains("… (") && tight.contains("more)")); - assert!(!tight.contains("- old thing")); - // urgent survives longest - assert!(tight.contains("1. (1.2)")); - // never over budget - assert!(tight.chars().count() < full.len() || tight.contains("more)")); + assert!(tight.contains("… (1 more)"), "{tight}"); + assert!(!tight.contains("- old thing"), "{tight}"); + // everything of higher priority survives + assert!( + tight.contains("1. (1.2)") && tight.contains("2. (0.5)"), + "{tight}" + ); + assert!(tight.contains("- QUESTION Did we sign?"), "{tight}"); + assert!(tight.contains("Halcyon: paid; kicked off"), "{tight}"); } #[test] diff --git a/examples/clog/src/types.rs b/examples/clog/src/types.rs index 3e1830d..1b9672a 100644 --- a/examples/clog/src/types.rs +++ b/examples/clog/src/types.rs @@ -163,7 +163,9 @@ pub struct Claim { pub struct Focus { /// Per-kind weight multipliers. pub weights: BTreeMap, - /// Additive score boosts for specific entities. + /// Multiplicative score boosts for specific entities. Every boost whose + /// entity a claim mentions multiplies into that claim's score, so two + /// matching boosts stack as their product (spec §5.4). pub boosts: Vec<(EntityRef, f32)>, /// The half-life, in days, used for recency decay. pub half_life_days: f32, @@ -194,7 +196,7 @@ impl Focus { self } - /// Adds an additive score boost for an entity. + /// Adds a multiplicative score boost for an entity (spec §5.4). pub fn boost(mut self, e: EntityRef, f: f32) -> Focus { self.boosts.push((e, f)); self @@ -216,7 +218,8 @@ impl Focus { /// Selects which materialized view to read. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum View { - /// The live, ranked feed of recent claims. + /// Every live claim, in `claim_key` order. Unranked and unfiltered by + /// recency: this is the whole world, not a feed. Live, /// The current state of tracked entities. EntityState, @@ -362,8 +365,11 @@ impl Default for TickConfig { /// A single condition used by a `Rule` to match a claim. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub enum Matcher { - /// Matches if the claim body contains this substring, case-insensitively. - BodyContains(String), + /// Matches if the claim body contains this substring (spec §5.6). + /// + /// The `bool` is `case_insensitive`: `true` compares both sides + /// lowercased, `false` compares them verbatim. + BodyContains(String, bool), /// Matches if the claim body matches this regular expression. BodyRegex(String), /// Matches if the claim's observer equals this string. @@ -453,9 +459,10 @@ pub struct Config { pub wal_fsync: FsyncPolicy, /// The bounded write queue depth. pub write_queue: usize, - /// If `true`, drops cached engine state and rebuilds materialized views - /// by replaying the WAL from scratch on open, rather than resuming from - /// the persisted cache. + /// Accepted, and in P1 identical to a normal open: there is no persisted + /// engine state to drop, so every open already replays the whole WAL. + /// Becomes meaningful once engine snapshots exist, when it will mean + /// "ignore the snapshot and rebuild from the log". pub rebuild_on_open: bool, } diff --git a/examples/clog/src/validate.rs b/examples/clog/src/validate.rs index 243e537..4400a86 100644 --- a/examples/clog/src/validate.rs +++ b/examples/clog/src/validate.rs @@ -67,6 +67,12 @@ pub(crate) fn validate_claim( } if let Some(subject_key) = &c.subject_key { + // `Some("")` is not "no subject": it would form a belief group keyed + // on the empty string, silently pooling unrelated claims. A host that + // means "no subject" says `None`. + if subject_key.trim().is_empty() { + return Err(invalid(index, "subject_key must be non-empty after trim")); + } if subject_key.len() > 256 { return Err(invalid(index, "subject_key must be <= 256 bytes")); } @@ -154,44 +160,82 @@ pub(crate) fn validate_claim( /// Every weight key must name a defined kind; weight values and boost /// factors must be finite and strictly positive; `half_life_days` must lie /// in the open interval `(0.01, 3650)`. +/// +/// # Errors +/// +/// A weight key that names no defined kind is `ClogError::UnknownKind` — the +/// host asked for something that could exist and does not. Every *value* +/// error is `ClogError::Corrupt { detail: "config: ..." }`, matching the +/// ruling already applied to an uncompilable taxonomy regex (see +/// [`crate::kinds::compile`]): a `Focus` is config clog must trust, and +/// `InvalidFilter` is reserved for a malformed `Filter` on a *read*. pub(crate) fn validate_focus(f: &Focus, taxonomy: &KindTaxonomy) -> Result<(), ClogError> { fn valid_factor(v: f32) -> bool { v.is_finite() && v > 0.0 } + fn bad(detail: impl Into) -> ClogError { + ClogError::Corrupt { + detail: detail.into(), + } + } for (kind, w) in &f.weights { if !taxonomy.contains(kind) { return Err(ClogError::UnknownKind); } if !valid_factor(*w) { - return Err(ClogError::InvalidFilter { - reason: format!("focus weight for {kind} must be finite and > 0"), - }); + return Err(bad(format!( + "config: focus weight for {kind} must be finite and > 0" + ))); } } for (_, boost) in &f.boosts { if !valid_factor(*boost) { - return Err(ClogError::InvalidFilter { - reason: "focus boost factor must be finite and > 0".into(), - }); + return Err(bad("config: focus boost factor must be finite and > 0")); } } if !(0.01 < f.half_life_days && f.half_life_days < 3650.0) { - return Err(ClogError::InvalidFilter { - reason: "half_life_days must be in (0.01, 3650)".into(), - }); + return Err(bad("config: half_life_days must be in (0.01, 3650)")); } Ok(()) } +/// Validates the scalar knobs of a `Config` that no other check covers. +/// +/// Runs first thing in `Clog::open`, before the WAL is touched: an instance +/// that cannot score is never opened at all. +/// +/// # Errors +/// +/// `ClogError::Corrupt { detail: "config: ..." }`, the same ruling +/// [`validate_focus`] and [`crate::kinds::compile`] use for unusable config. +pub(crate) fn validate_config(cfg: &crate::types::Config) -> Result<(), ClogError> { + // It is the divisor of the decay bucket width (`score::bucket_age_days`): + // zero makes every bucket width infinite and every score NaN, which then + // silently sorts to the bottom of `urgent` rather than failing. + if cfg.decay_buckets_per_half_life == 0 { + return Err(ClogError::Corrupt { + detail: "config: decay_buckets_per_half_life must be > 0".to_string(), + }); + } + Ok(()) +} + /// Clamps a timestamp for scoring purposes only (§10): values more than 24h /// beyond `now` are clamped to `now`. Storage always keeps the verbatim /// value; only scoring consumes this clamped result. +/// +/// Saturating: a `now` within 24h of `u64::MAX` (reachable, since the manual +/// clock saturates there) must not overflow the cutoff and panic the writer. pub(crate) fn scoring_clamp(ts: u64, now: u64) -> u64 { - if ts > now + 86_400_000 { now } else { ts } + if ts > now.saturating_add(86_400_000) { + now + } else { + ts + } } #[cfg(test)] @@ -226,6 +270,18 @@ mod tests { false, "subject_key", ), + // `Some("")` / whitespace-only is not "no subject": it would form + // a belief group keyed on nothing at all. + ( + Box::new(|c| c.subject_key = Some(String::new())), + false, + "subject_key", + ), + ( + Box::new(|c| c.subject_key = Some(" \t ".into())), + false, + "subject_key", + ), ( Box::new(|c| c.subject_key = Some("has\u{0007}bell".into())), false, @@ -397,13 +453,20 @@ mod tests { validate_focus(&Focus::uniform().weight("nope", 1.0), &tax), Err(ClogError::UnknownKind) )); - assert!(validate_focus(&Focus::uniform().weight("risk", f32::NAN), &tax).is_err()); - assert!(validate_focus(&Focus::uniform().weight("risk", 0.0), &tax).is_err()); - assert!(validate_focus(&Focus::uniform().half_life_days(0.005), &tax).is_err()); - assert!(validate_focus(&Focus::uniform().half_life_days(4000.0), &tax).is_err()); + // Every *value* error is config corruption, not an invalid filter. + let bad = |f: Focus| match validate_focus(&f, &tax) { + Err(ClogError::Corrupt { detail }) => { + assert!(detail.starts_with("config: "), "{detail}"); + } + other => panic!("expected Corrupt, got {other:?}"), + }; + bad(Focus::uniform().weight("risk", f32::NAN)); + bad(Focus::uniform().weight("risk", 0.0)); + bad(Focus::uniform().half_life_days(0.005)); + bad(Focus::uniform().half_life_days(4000.0)); // half-life bounds are exclusive: exactly the endpoints must fail. - assert!(validate_focus(&Focus::uniform().half_life_days(0.01), &tax).is_err()); - assert!(validate_focus(&Focus::uniform().half_life_days(3650.0), &tax).is_err()); + bad(Focus::uniform().half_life_days(0.01)); + bad(Focus::uniform().half_life_days(3650.0)); // boost factors: valid, NaN, zero. let e = EntityRef { etype: "p".into(), @@ -411,8 +474,21 @@ mod tests { name: None, }; assert!(validate_focus(&Focus::uniform().boost(e.clone(), 1.5), &tax).is_ok()); - assert!(validate_focus(&Focus::uniform().boost(e.clone(), f32::NAN), &tax).is_err()); - assert!(validate_focus(&Focus::uniform().boost(e, 0.0), &tax).is_err()); + bad(Focus::uniform().boost(e.clone(), f32::NAN)); + bad(Focus::uniform().boost(e, 0.0)); + } + + #[test] + fn zero_decay_buckets_is_rejected_config() { + let mut cfg = crate::types::Config::default_for("/tmp/x"); + assert!(validate_config(&cfg).is_ok()); + cfg.decay_buckets_per_half_life = 0; + match validate_config(&cfg) { + Err(ClogError::Corrupt { detail }) => { + assert_eq!(detail, "config: decay_buckets_per_half_life must be > 0"); + } + other => panic!("expected Corrupt, got {other:?}"), + } } #[test] @@ -424,4 +500,15 @@ mod tests { ); assert_eq!(scoring_clamp(1_000_000 + 86_400_001, 1_000_000), 1_000_000); } + + /// A manual clock saturates at `u64::MAX`, so `now + 24h` is a real + /// overflow: under overflow checks it panics the writer thread mid-write. + #[test] + fn scoring_clamp_saturates_at_the_end_of_time() { + let now = u64::MAX; + assert_eq!(scoring_clamp(now, now), now); + assert_eq!(scoring_clamp(1, now), 1); + // The cutoff saturates, so nothing is ever *beyond* it here. + assert_eq!(scoring_clamp(u64::MAX, u64::MAX - 1), u64::MAX); + } } diff --git a/examples/clog/src/wal.rs b/examples/clog/src/wal.rs index 09155c7..e361a0e 100644 --- a/examples/clog/src/wal.rs +++ b/examples/clog/src/wal.rs @@ -22,13 +22,40 @@ const HEADER_LEN: usize = 8; pub(crate) struct Wal { file: File, fsync: FsyncPolicy, + /// Set when a failed append left a partial frame that could *not* be + /// truncated away. See [`Wal::append`]. + poisoned: bool, + /// Test-only injection: write only this many bytes of the next frame and + /// then fail, simulating a short write (ENOSPC mid-frame). + #[cfg(test)] + fail_after_bytes: Option, } impl Wal { /// Appends `batch` as one `[len][crc32][payload]` frame, fsyncing per /// `self`'s policy afterwards (`OnCommit` calls `sync_data`; `Never` /// does not sync). + /// + /// **A failed append leaves no partial frame.** The log's length is read + /// *before* the write, and any error — a short write, a failed fsync — + /// truncates back to it. Without that rollback a torn frame would sit in + /// the middle of the log: the *next* successful append would land after + /// it, and reopen's CRC scan would stop at the tear and quarantine + /// everything from there on, silently discarding batches that were + /// already acked (spec §6.3, R2). + /// + /// If the truncation *itself* fails, the log is left in exactly the state + /// this method exists to prevent, so the `Wal` is poisoned: every later + /// append fails immediately rather than compounding the damage. Recovery + /// is to reopen the instance, which quarantines the tail and truncates it + /// on the way in. pub(crate) fn append(&mut self, batch: &Batch) -> Result<(), ClogError> { + if self.poisoned { + return Err(ClogError::Storage(std::io::Error::other( + "wal poisoned: a previous append failed and its partial frame \ + could not be truncated; reopen the instance to recover", + ))); + } let payload = postcard::to_allocvec(batch).map_err(|e| ClogError::Corrupt { detail: format!("wal encode: {e}"), })?; @@ -42,7 +69,29 @@ impl Wal { frame.extend_from_slice(&crc.to_le_bytes()); frame.extend_from_slice(&payload); - self.file.write_all(&frame)?; + // Read before writing: this is the byte offset the frame starts at, + // and the length the log is rolled back to if anything below fails. + let pre_offset = self.file.metadata()?.len(); + match self.write_frame(&frame) { + Ok(()) => Ok(()), + Err(e) => { + self.rollback(pre_offset); + Err(e) + } + } + } + + /// Writes one whole frame and applies the fsync policy. Split out so + /// [`Wal::append`] has a single error path to roll back from. + fn write_frame(&mut self, frame: &[u8]) -> Result<(), ClogError> { + #[cfg(test)] + if let Some(n) = self.fail_after_bytes.take() { + self.file.write_all(&frame[..n.min(frame.len())])?; + return Err(ClogError::Storage(std::io::Error::other( + "injected short write", + ))); + } + self.file.write_all(frame)?; match self.fsync { FsyncPolicy::OnCommit => self.file.sync_data()?, FsyncPolicy::Never => {} @@ -50,6 +99,14 @@ impl Wal { Ok(()) } + /// Removes whatever a failed append wrote, poisoning the `Wal` if the + /// truncation cannot be done. + fn rollback(&mut self, pre_offset: u64) { + if self.file.set_len(pre_offset).is_err() { + self.poisoned = true; + } + } + /// Fsyncs the log unconditionally, whatever the policy says. /// /// Called once by the writer thread on clean shutdown (spec §6.1), so @@ -101,7 +158,16 @@ pub(crate) fn open_dir(dir: &Path, fsync: FsyncPolicy) -> Result<(Wal, Vec>(), + vec![1, 2, 3], + "nothing acked may be discarded" + ); + assert!( + !dir.path().join("wal").join("wal.corrupt").exists(), + "a rolled-back append leaves nothing to quarantine" + ); + } + + /// Failing at a *payload* byte rather than in the header is the same + /// contract: the frame's length prefix is already on disk and would + /// otherwise make replay read past the tear. + #[test] + fn failed_append_mid_payload_is_also_truncated() { + let dir = tempfile::tempdir().unwrap(); + { + let (mut w, _) = open_dir(dir.path(), crate::FsyncPolicy::OnCommit).unwrap(); + w.append(&batch(1)).unwrap(); + w.fail_after_bytes = Some(HEADER_LEN + 1); + assert!(w.append(&batch(2)).is_err()); + w.append(&batch(2)).unwrap(); + } + let (_, replayed) = open_dir(dir.path(), crate::FsyncPolicy::OnCommit).unwrap(); + assert_eq!( + replayed.iter().map(|b| b.rev).collect::>(), + vec![1, 2] + ); + } + + /// A `Wal` whose rollback failed refuses every later append rather than + /// writing a good frame after a tear it could not remove. + #[test] + fn a_poisoned_wal_refuses_further_appends() { + let dir = tempfile::tempdir().unwrap(); + let (mut w, _) = open_dir(dir.path(), crate::FsyncPolicy::OnCommit).unwrap(); + w.append(&batch(1)).unwrap(); + // The only way `rollback` poisons is a `set_len` that fails, which no + // portable test can force on a healthy tmpdir; the state it leaves is + // set directly, and the refusal it must produce is asserted here. + w.poisoned = true; + match w.append(&batch(2)) { + Err(ClogError::Storage(e)) => assert!(e.to_string().contains("poisoned")), + other => panic!("expected a poisoned Storage error, got {other:?}"), + } + // Nothing was written: the log still holds only the first frame. + drop(w); + let (_, replayed) = open_dir(dir.path(), crate::FsyncPolicy::OnCommit).unwrap(); + assert_eq!(replayed.iter().map(|b| b.rev).collect::>(), vec![1]); + } + #[test] fn r2_corrupt_crc_never_applied_never_panics() { let dir = tempfile::tempdir().unwrap(); diff --git a/examples/clog/tests/api.rs b/examples/clog/tests/api.rs index 55a72b6..6f5e988 100644 --- a/examples/clog/tests/api.rs +++ b/examples/clog/tests/api.rs @@ -197,7 +197,7 @@ fn rules_tier_classifies_at_commit() { for kd in &mut config.kinds.kinds { if kd.name == "risk" { kd.rules.push(clog::Rule { - any_of: vec![clog::Matcher::BodyContains("overdue".into())], + any_of: vec![clog::Matcher::BodyContains("overdue".into(), true)], }); } } @@ -226,7 +226,7 @@ fn only_the_surviving_version_of_a_key_is_classified() { for kd in &mut config.kinds.kinds { if kd.name == "risk" { kd.rules.push(clog::Rule { - any_of: vec![clog::Matcher::BodyContains("overdue".into())], + any_of: vec![clog::Matcher::BodyContains("overdue".into(), true)], }); } } @@ -512,7 +512,7 @@ fn select_filters_and_compose_and_limit_is_bounded() { for kd in &mut config.kinds.kinds { if kd.name == "risk" { kd.rules.push(clog::Rule { - any_of: vec![clog::Matcher::BodyContains("overdue".into())], + any_of: vec![clog::Matcher::BodyContains("overdue".into(), true)], }); } } @@ -950,3 +950,195 @@ fn entity_state_select_reports_a_multi_entity_claim_once() { }; assert_eq!(c.select(View::EntityState, f).unwrap().len(), 1); } + +// An entity's display name is host-supplied text that reaches the rendered +// document verbatim. A newline in it would fabricate document lines — an +// agent reading the brief cannot tell an invented line from a claimed one. +#[test] +fn entity_display_name_cannot_fabricate_document_lines() { + let dir = tempfile::tempdir().unwrap(); + let c = Clog::open(cfg(dir.path())).unwrap(); + c.advance(1_000_000).unwrap(); + let mut cl = claim("k", "status update", 500_000); + cl.subject_key = Some("s1".into()); + cl.entities = vec![EntityRef { + etype: "p".into(), + id: "x".into(), + name: Some("Acme\nINJECTED: a line nobody claimed".into()), + }]; + c.observe(vec![cl], ObserveOpts::default()).unwrap(); + + let text = c.situation(None, None).unwrap().text; + let carrying: Vec<&str> = text.lines().filter(|l| l.contains("INJECTED")).collect(); + assert_eq!(carrying.len(), 1, "name must render as ONE line:\n{text}"); + assert_eq!( + carrying[0], "Acme INJECTED: a line nobody claimed: status update", + "\n{text}" + ); + assert!( + !text.lines().any(|l| l.starts_with("INJECTED")), + "no fabricated line at column 0:\n{text}" + ); +} + +// INV-5's duplicate skip must not swallow a changed display name. +// `EntityRef` equality is identity-only by design, so `Claim`'s derived +// `PartialEq` is blind to `name` — but spec §5.1 step 3 supersedes on any +// difference and §5.2 is latest-name-wins, which can only happen if the +// claim commits. +#[test] +fn a_changed_entity_display_name_is_not_a_duplicate() { + let dir = tempfile::tempdir().unwrap(); + let c = Clog::open(cfg(dir.path())).unwrap(); + c.advance(1_000_000).unwrap(); + let named = |name: &str| { + let mut cl = claim("k", "status update", 500_000); + cl.subject_key = Some("s1".into()); + cl.entities = vec![EntityRef { + etype: "p".into(), + id: "x".into(), + name: Some(name.into()), + }]; + cl + }; + let first = c + .observe(vec![named("Old Name")], ObserveOpts::default()) + .unwrap(); + let before = c.situation(None, None).unwrap().text; + assert!(before.contains("Old Name: "), "{before}"); + + let second = c + .observe(vec![named("New Name")], ObserveOpts::default()) + .unwrap(); + assert_eq!( + second.rev, + first.rev + 1, + "a changed display name is a real change" + ); + + let rows = c.select(View::Live, Filter::default()).unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!( + rows[0].claim.entities[0].name.as_deref(), + Some("New Name"), + "the stored claim must carry the new name" + ); + + let text = c.situation(None, None).unwrap().text; + assert!(text.contains("New Name: "), "{text}"); + assert!(!text.contains("Old Name"), "{text}"); + + // ...and a genuinely identical re-observe is still invisible (INV-5). + assert_eq!( + c.observe(vec![named("New Name")], ObserveOpts::default()) + .unwrap() + .rev, + second.rev + ); +} + +// A merge is clog's own write, stamped from clog's own clock. A fresh manual +// clock reads 0 and §10 requires timestamps > 0: the merge must not fail on +// a technicality the caller never chose. +#[test] +fn merge_succeeds_on_a_fresh_manual_clock() { + let dir = tempfile::tempdir().unwrap(); + let c = Clog::open(cfg(dir.path())).unwrap(); + // deliberately no `advance`: the clock still reads 0 + let a = EntityRef { + etype: "p".into(), + id: "a".into(), + name: None, + }; + let b = EntityRef { + etype: "p".into(), + id: "b".into(), + name: None, + }; + let ack = c.merge_entities(&a, &b).unwrap(); + assert_eq!(ack.rev, 1, "the merge must commit"); + // the edge really exists: its reserved claim is retractable + c.retract("clog:merge:p:a->p:b").unwrap(); +} + +// `min_score` is the one filter that only means anything on a ranked view, +// and it is inclusive at the boundary (the `select` contract). +#[test] +fn min_score_filters_urgent_at_an_inclusive_boundary() { + const NOW: u64 = 30 * 86_400_000; + let dir = tempfile::tempdir().unwrap(); + let c = Clog::open(cfg(dir.path())).unwrap(); + c.advance(NOW).unwrap(); + // Same trust, different age: recency decay alone separates the scores. + c.observe( + vec![ + claim("fresh", "happened just now", NOW), + claim("stale", "happened ten days ago", NOW - 10 * 86_400_000), + ], + ObserveOpts::default(), + ) + .unwrap(); + + let urgent = || View::Urgent { + scope: "default".into(), + }; + let rows = c.select(urgent(), Filter::default()).unwrap(); + assert_eq!(rows.len(), 2); + let (hi, lo) = (rows[0].score.unwrap(), rows[1].score.unwrap()); + assert!(hi > lo, "{hi} vs {lo}"); + + let with = |min: f32| { + c.select( + urgent(), + Filter { + min_score: Some(min), + ..Filter::default() + }, + ) + .unwrap() + .iter() + .map(|r| r.claim.claim_key.clone()) + .collect::>() + }; + // a threshold between the two excludes the low-scoring row... + assert_eq!(with(lo + (hi - lo) / 2.0), vec!["fresh".to_string()]); + // ...and the comparison is inclusive exactly at either boundary + assert_eq!(with(hi), vec!["fresh".to_string()]); + assert_eq!(with(lo), vec!["fresh".to_string(), "stale".to_string()]); + // above every score: nothing + assert!(with(hi * 2.0).is_empty()); +} + +// Config clog cannot use is rejected at `open` rather than tolerated into +// NaN scores or surfaced as a confusing `InvalidFilter` on some later read. +#[test] +fn unusable_config_is_rejected_at_open_as_corrupt() { + let dir = tempfile::tempdir().unwrap(); + let mut config = cfg(dir.path()); + config.decay_buckets_per_half_life = 0; + let err = Clog::open(config).err().expect("must be rejected"); + assert!( + matches!(&err, ClogError::Corrupt { detail } + if detail == "config: decay_buckets_per_half_life must be > 0"), + "{err:?}" + ); + + // A focus *value* error is likewise config corruption... + let mut config = cfg(dir.path()); + config + .scopes + .insert("bad".into(), Focus::uniform().half_life_days(0.0)); + let err = Clog::open(config).err().expect("must be rejected"); + assert!( + matches!(&err, ClogError::Corrupt { detail } if detail.starts_with("config: ")), + "{err:?}" + ); + + // ...while an unknown *kind* stays its own error: the host named + // something that could have existed. + let mut config = cfg(dir.path()); + config + .scopes + .insert("bad".into(), Focus::uniform().weight("no-such-kind", 2.0)); + assert!(matches!(Clog::open(config), Err(ClogError::UnknownKind))); +} diff --git a/examples/clog/tests/g1_agency.rs b/examples/clog/tests/g1_agency.rs index c2c31e9..7284412 100644 --- a/examples/clog/tests/g1_agency.rs +++ b/examples/clog/tests/g1_agency.rs @@ -59,8 +59,8 @@ fn fixture_config(dir: &std::path::Path) -> Config { match kd.name.as_str() { "risk" => kd.rules.push(Rule { any_of: vec![ - Matcher::BodyContains("overdue".into()), - Matcher::BodyContains("slipping".into()), + Matcher::BodyContains("overdue".into(), true), + Matcher::BodyContains("slipping".into(), true), ], }), "question" => kd.rules.push(Rule { @@ -70,7 +70,7 @@ fn fixture_config(dir: &std::path::Path) -> Config { any_of: vec![Matcher::ObserverIs("bank-feed".into())], }), "opportunity" => kd.rules.push(Rule { - any_of: vec![Matcher::BodyContains("inbound".into())], + any_of: vec![Matcher::BodyContains("inbound".into(), true)], }), // Beyond the brief's four required rules: without this, claims // 5/6/11 (a kickoff move, a PTO note, a moved 1:1) all land in @@ -79,8 +79,8 @@ fn fixture_config(dir: &std::path::Path) -> Config { // exercised by any live claim. Documented in task-17-report.md. "fyi" => kd.rules.push(Rule { any_of: vec![ - Matcher::BodyContains("moved".into()), - Matcher::BodyContains("PTO".into()), + Matcher::BodyContains("moved".into(), true), + Matcher::BodyContains("PTO".into(), true), ], }), _ => {} From 3c278f2525edba5d6eeb1f4a1888dde172dfb307 Mon Sep 17 00:00:00 2001 From: hhff Date: Sun, 16 Aug 2026 17:15:45 -0700 Subject: [PATCH 28/28] feat(clog): interactive demo REPL example with seeded G1 world Boots the agency fixture frozen mid-story (belief competition live, merge pending), re-prints the situation after every command; crash command demos WAL recovery. Store persists at ./clog-repl-data, idempotent reseeding via INV-5. Co-Authored-By: Claude Fable 5 --- .gitignore | 4 +- examples/clog/README.md | 28 ++ examples/clog/examples/repl.rs | 565 +++++++++++++++++++++++++++++++++ 3 files changed, 596 insertions(+), 1 deletion(-) create mode 100644 examples/clog/examples/repl.rs diff --git a/.gitignore b/.gitignore index b1c2892..419dcbd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ /target target/ -.DS_Store \ No newline at end of file +.DS_Store +# clog demo REPL store (created wherever the example is run) +clog-repl-data/ diff --git a/examples/clog/README.md b/examples/clog/README.md index 8f6f6ba..255dc50 100644 --- a/examples/clog/README.md +++ b/examples/clog/README.md @@ -115,6 +115,34 @@ fn claim(key: &str, body: &str) -> Claim { } ``` +## Demo REPL + +For a live, narratable demo (and a fast way to poke at the engine): + +```console +cargo run -p clog --example repl # opens ./clog-repl-data +cargo run -p clog --example repl -- --fresh # wipe the store first +``` + +It boots the G1 agency world (two lenses, eleven claims, three of them +competing over one invoice) frozen mid-story, and re-prints the situation +document after every command. `help` lists the commands. A five-beat stage +script: + +1. `obs Server bill overdue again` — watch the rules tier classify it as a + risk and rank it into `urgent` live, differently per lens. +2. `retract halcyon:inv-1042:v2` — the stale "still overdue" claim dies and + belief on the invoice flips to the bank-feed payment; the entities line + heals in front of you. +3. `merge person:samuel person:sam` — two entity rows consolidate into one. +4. `scope cash-and-collections` — same world, different ranking (fyi damped, + opportunity boosted). +5. `crash`, then relaunch — the process aborts mid-session and the WAL + restores everything, including your own observations. That's INV-11. + +The store persists between runs (`reset` reseeds it); seeding is idempotent +because re-observing byte-identical claims is invisible (INV-5). + ## Config knobs All fields on `Config`; build one with `Config::default_for(path)` and diff --git a/examples/clog/examples/repl.rs b/examples/clog/examples/repl.rs new file mode 100644 index 0000000..5e0730f --- /dev/null +++ b/examples/clog/examples/repl.rs @@ -0,0 +1,565 @@ +//! An interactive demo REPL for clog: feed it observations, watch the +//! situation document reorganize. +//! +//! ```console +//! cargo run -p clog --example repl # opens ./clog-repl-data +//! cargo run -p clog --example repl -- --fresh # wipe the store first +//! ``` +//! +//! The store boots with the G1 "client-services studio" world (see +//! `tests/g1_agency.rs`) frozen at its most interesting moment: three +//! claims are competing over invoice 1042's status, a client question is +//! still open, and `person:samuel` / `person:sam` are not yet merged. A +//! suggested stage script lives in the crate README. +//! +//! The store is a real clog WAL: type `crash` to abort the process +//! mid-session, relaunch, and the world (including your own observations) +//! comes back — that's INV-11 live. + +use std::io::{BufRead, IsTerminal, Write}; +use std::path::{Path, PathBuf}; + +use clog::*; + +const DAY_MS: u64 = 86_400_000; +/// The demo's "now": two days after the last seeded batch. +const BOOT_NOW: u64 = 20_008 * DAY_MS; + +fn day(n: u64) -> u64 { + n * DAY_MS +} + +// --------------------------------------------------------------------------- +// world configuration (mirrors tests/g1_agency.rs) +// --------------------------------------------------------------------------- + +fn demo_config(dir: &std::path::Path) -> Config { + let mut c = Config::default_for(dir); + c.tick = TickConfig { + mode: ClockMode::Manual, + interval_ms: 60_000, + }; + + c.scopes.insert( + "delivery-health".into(), + Focus::uniform() + .weight("risk", 2.5) + .weight("question", 1.5) + .weight("commitment", 1.5) + .boost(entity("project", "halcyon"), 1.5), + ); + c.scopes.insert( + "cash-and-collections".into(), + Focus::uniform() + .weight("risk", 2.0) + .weight("fyi", 0.5) + .weight("opportunity", 1.5), + ); + + for kd in &mut c.kinds.kinds { + match kd.name.as_str() { + "risk" => kd.rules.push(Rule { + any_of: vec![ + Matcher::BodyContains("overdue".into(), true), + Matcher::BodyContains("slipping".into(), true), + ], + }), + "question" => kd.rules.push(Rule { + any_of: vec![Matcher::BodyRegex(r"\?$".into())], + }), + "fact" => kd.rules.push(Rule { + any_of: vec![Matcher::ObserverIs("bank-feed".into())], + }), + "opportunity" => kd.rules.push(Rule { + any_of: vec![Matcher::BodyContains("inbound".into(), true)], + }), + "fyi" => kd.rules.push(Rule { + any_of: vec![ + Matcher::BodyContains("moved".into(), true), + Matcher::BodyContains("PTO".into(), true), + ], + }), + _ => {} + } + } + c +} + +fn entity(etype: &str, id: &str) -> EntityRef { + EntityRef { + etype: etype.into(), + id: id.into(), + name: None, + } +} + +fn named(etype: &str, id: &str, name: &str) -> EntityRef { + EntityRef { + etype: etype.into(), + id: id.into(), + name: Some(name.into()), + } +} + +#[allow(clippy::too_many_arguments)] +fn seed_claim( + key: &str, + subject: Option<&str>, + body: &str, + observer: &str, + reliability: Reliability, + credibility: Credibility, + occurred_day: u64, + entities: Vec, +) -> Claim { + let occ = day(occurred_day); + Claim { + claim_key: key.into(), + subject_key: subject.map(Into::into), + source_ref: format!("{observer}:{key}"), + observer: ObserverId::from(observer), + schema_v: 1, + occurred_at: occ, + observed_at: occ, + reliability, + credibility, + entities, + body: body.into(), + } +} + +/// The G1 world through checkpoint B plus claim 11: belief competition on +/// the invoice is live, the SOW question is open, and samuel/sam are still +/// two entities — so `retract` and `merge` have visible work to do. +fn seed_world(c: &Clog) -> Result<(), ClogError> { + use Credibility::*; + use Reliability::*; + let halcyon = || named("project", "halcyon", "Halcyon"); + c.observe( + vec![ + seed_claim( + "halcyon:deliverable:slip", + Some("halcyon:deliverable:status"), + "Halcyon deliverable is slipping by a week", + "twist", + B, + Three, + 20_000, + vec![halcyon()], + ), + seed_claim( + "halcyon:inv-1042:v1", + Some("halcyon:inv-1042:status"), + "Invoice 1042 is 30 days overdue", + "gmail", + B, + Two, + 20_000, + vec![halcyon()], + ), + seed_claim( + "meridian:kickoff:moved", + None, + "Meridian kickoff moved to Thursday", + "gmail", + B, + Two, + 20_000, + vec![], + ), + seed_claim( + "meridian:pto:sam", + Some("meridian:pto:samuel:status"), + "Sam is on PTO next week", + "slack", + C, + Two, + 20_000, + vec![named("person", "samuel", "Samuel")], + ), + seed_claim( + "meridian:question:sow", + Some("meridian:sow"), + "Did Meridian sign the SOW?", + "gmail", + B, + Two, + 20_001, + vec![], + ), + seed_claim( + "vega:lead:inbound", + None, + "Inbound lead from Vega Labs", + "hubspot", + B, + Two, + 20_001, + vec![], + ), + seed_claim( + "vega:upsell:maybe", + None, + "Vega mentioned maybe expanding scope", + "twist", + C, + Three, + 20_001, + vec![], + ), + seed_claim( + "halcyon:inv-1042:v2", + Some("halcyon:inv-1042:status"), + "Invoice 1042 still overdue per bookkeeper", + "twist", + C, + Three, + 20_004, + vec![halcyon()], + ), + seed_claim( + "halcyon:inv-1042:paid", + Some("halcyon:inv-1042:status"), + "Payment received for invoice 1042", + "bank-feed", + A, + One, + 20_002, + vec![halcyon()], + ), + seed_claim( + "meridian:sam:oneone", + Some("meridian:sam:oneone:status"), + "Sam moved his 1:1 to Friday", + "slack", + B, + Two, + 20_005, + vec![named("person", "sam", "Sam")], + ), + ], + ObserveOpts::default(), + )?; + Ok(()) +} + +// --------------------------------------------------------------------------- +// terminal helpers +// --------------------------------------------------------------------------- + +struct Style { + on: bool, +} + +impl Style { + fn heading(&self, s: &str) -> String { + if self.on { + format!("\x1b[1;36m{s}\x1b[0m") + } else { + s.to_string() + } + } + fn ok(&self, s: &str) -> String { + if self.on { + format!("\x1b[32m{s}\x1b[0m") + } else { + s.to_string() + } + } + fn err(&self, s: &str) -> String { + if self.on { + format!("\x1b[31m{s}\x1b[0m") + } else { + s.to_string() + } + } + fn dim(&self, s: &str) -> String { + if self.on { + format!("\x1b[2m{s}\x1b[0m") + } else { + s.to_string() + } + } +} + +fn print_situation(c: &Clog, scope: &str, style: &Style) { + match c.situation(Some(scope), None) { + Ok(s) => { + println!(); + for line in s.text.lines() { + if line.starts_with('#') { + println!("{}", style.heading(line)); + } else { + println!("{line}"); + } + } + println!(); + } + Err(e) => println!("{}", style.err(&format!("situation error: {e}"))), + } +} + +fn print_rows(rows: &[Row], with_believed: bool) { + for r in rows { + let kind = r + .kind + .as_ref() + .map(|k| format!("[{}]", k.kind)) + .unwrap_or_else(|| "[unclassified]".into()); + let believed = match (with_believed, r.believed) { + (true, Some(true)) => " believed", + (true, Some(false)) => " (losing)", + _ => "", + }; + println!( + " {} {} {}{}", + r.claim.claim_key, + kind, + truncate(&r.claim.body, 60), + believed + ); + } + if rows.is_empty() { + println!(" (none)"); + } +} + +fn truncate(s: &str, n: usize) -> String { + let collapsed: String = s.split_whitespace().collect::>().join(" "); + if collapsed.chars().count() <= n { + collapsed + } else { + collapsed.chars().take(n).collect::() + "…" + } +} + +const HELP: &str = "\ +commands: + obs observe a claim (auto key obs-N, observer 'repl', B/2) + bodies containing overdue/slipping/inbound/moved/PTO + or ending in '?' get classified by the rules tier + retract retract a claim (try: retract halcyon:inv-1042:v2) + merge alias one entity onto another + (try: merge person:samuel person:sam) + scope switch lens (delivery-health | cash-and-collections) + scopes list lenses + sit reprint the current situation + loops open loops view unclassified escalation queue + live all live claims (with believed flags) + tick advance the manual clock; P1 re-scores on the next + write (P2's tick driver makes decay automatic) + crash abort() mid-session — relaunch to see WAL recovery + reset wipe the store and reseed the demo world + help this text quit exit cleanly"; + +// --------------------------------------------------------------------------- +// main loop +// --------------------------------------------------------------------------- + +fn open_world(dir: &Path) -> Result<(Clog, u64), ClogError> { + let c = Clog::open(demo_config(dir))?; + c.advance(BOOT_NOW)?; + let live = c.select(View::Live, Filter::default())?; + if live.is_empty() { + seed_world(&c)?; + } + Ok((c, BOOT_NOW)) +} + +fn next_obs_key(c: &Clog) -> String { + let live = c.select(View::Live, Filter::default()).unwrap_or_default(); + let max = live + .iter() + .filter_map(|r| r.claim.claim_key.strip_prefix("obs-")) + .filter_map(|n| n.parse::().ok()) + .max() + .unwrap_or(0); + format!("obs-{}", max + 1) +} + +fn main() { + let args: Vec = std::env::args().collect(); + let dir = PathBuf::from("clog-repl-data"); + if args.iter().any(|a| a == "--fresh") && dir.exists() { + let _ = std::fs::remove_dir_all(&dir); + } + + let style = Style { + on: std::io::stdout().is_terminal() && std::env::var_os("NO_COLOR").is_none(), + }; + + let (mut world, mut now) = match open_world(&dir) { + Ok(w) => w, + Err(e) => { + eprintln!("failed to open {}: {e}", dir.display()); + std::process::exit(1); + } + }; + let mut scope = "delivery-health".to_string(); + + println!( + "{}", + style.dim(&format!( + "clog demo repl · store: {} · type 'help' for commands", + dir.display() + )) + ); + print_situation(&world, &scope, &style); + + let stdin = std::io::stdin(); + loop { + print!("{}> ", style.dim(&scope)); + let _ = std::io::stdout().flush(); + let Some(Ok(line)) = stdin.lock().lines().next() else { + break; + }; + let line = line.trim(); + if line.is_empty() { + continue; + } + let (cmd, rest) = line.split_once(' ').unwrap_or((line, "")); + let rest = rest.trim(); + + match cmd { + "obs" if !rest.is_empty() => { + let key = next_obs_key(&world); + let claim = Claim { + claim_key: key.clone(), + subject_key: None, + source_ref: format!("repl:{key}"), + observer: ObserverId::from("repl"), + schema_v: 1, + occurred_at: now, + observed_at: now, + reliability: Reliability::B, + credibility: Credibility::Two, + entities: vec![], + body: rest.to_string(), + }; + match world.observe(vec![claim], ObserveOpts::default()) { + Ok(ack) => { + let kind = world + .select(View::Live, Filter::default()) + .ok() + .and_then(|rows| rows.into_iter().find(|r| r.claim.claim_key == key)) + .and_then(|r| r.kind) + .map(|k| k.kind) + .unwrap_or_else(|| "unclassified".into()); + println!( + "{}", + style.ok(&format!("observed {key} [{kind}] rev {}", ack.rev)) + ); + print_situation(&world, &scope, &style); + } + Err(e) => println!("{}", style.err(&format!("observe failed: {e}"))), + } + } + "obs" => println!("usage: obs "), + "retract" if !rest.is_empty() => match world.retract(rest) { + Ok(ack) => { + println!("{}", style.ok(&format!("retracted {rest} rev {}", ack.rev))); + print_situation(&world, &scope, &style); + } + Err(e) => println!("{}", style.err(&format!("retract failed: {e}"))), + }, + "retract" => println!("usage: retract "), + "merge" => { + let parts: Vec<&str> = rest.split_whitespace().collect(); + let parse = |s: &str| s.split_once(':').map(|(t, i)| entity(t, i)); + match ( + parts.first().and_then(|s| parse(s)), + parts.get(1).and_then(|s| parse(s)), + ) { + (Some(alias), Some(canonical)) => { + match world.merge_entities(&alias, &canonical) { + Ok(ack) => { + println!( + "{}", + style.ok(&format!( + "merged {}:{} -> {}:{} rev {}", + alias.etype, + alias.id, + canonical.etype, + canonical.id, + ack.rev + )) + ); + print_situation(&world, &scope, &style); + } + Err(e) => println!("{}", style.err(&format!("merge failed: {e}"))), + } + } + _ => println!("usage: merge "), + } + } + "scope" if !rest.is_empty() => { + if world.situation(Some(rest), None).is_ok() { + scope = rest.to_string(); + print_situation(&world, &scope, &style); + } else { + println!("{}", style.err(&format!("unknown scope: {rest}"))); + } + } + "scope" | "scopes" => { + println!(" delivery-health\n cash-and-collections\n default"); + } + "sit" => print_situation(&world, &scope, &style), + "loops" => match world.select(View::OpenLoops, Filter::default()) { + Ok(rows) => print_rows(&rows, false), + Err(e) => println!("{}", style.err(&format!("select failed: {e}"))), + }, + "unclassified" => match world.select(View::Unclassified, Filter::default()) { + Ok(rows) => print_rows(&rows, false), + Err(e) => println!("{}", style.err(&format!("select failed: {e}"))), + }, + "live" => match world.select(View::Live, Filter::default()) { + Ok(rows) => print_rows(&rows, true), + Err(e) => println!("{}", style.err(&format!("select failed: {e}"))), + }, + "tick" => { + let days: f64 = rest.parse().unwrap_or(1.0); + let ms = (days * DAY_MS as f64) as u64; + match world.advance(ms) { + Ok(()) => { + now += ms; + println!( + "{}", + style.ok(&format!( + "advanced {days} day(s); recency re-scores on the next write \ + (P2's tick driver makes this automatic)" + )) + ); + } + Err(e) => println!("{}", style.err(&format!("tick failed: {e}"))), + } + } + "crash" => { + println!( + "{}", + style.err("aborting mid-session — relaunch to watch the WAL restore the world") + ); + let _ = std::io::stdout().flush(); + std::process::abort(); + } + "reset" => { + drop(world); + let _ = std::fs::remove_dir_all(&dir); + match open_world(&dir) { + Ok((w, n)) => { + world = w; + now = n; + println!("{}", style.ok("store wiped and reseeded")); + print_situation(&world, &scope, &style); + } + Err(e) => { + eprintln!("failed to reopen {}: {e}", dir.display()); + std::process::exit(1); + } + } + } + "help" => println!("{HELP}"), + "quit" | "exit" => break, + other => println!("unknown command: {other} (try 'help')"), + } + } +}