Skip to content

Commit 4cb5475

Browse files
5000Stadiaclaude
andcommitted
NUMERIC-QUANTITIES-V1: implementation complete (accrue deltas + numeric predicates)
Implements the GREEN spec end-to-end, both sides wired. - Data structure: value_type 'delta' (signed increment) beside 'literal' (absolute). VALUE_TYPES + extraction schema + dump validation inherit it. - Phase 1 accrue fold: fold_policy=accrue (RFC-001's reserved axis lit up). _fold_accrue = latest numeric literal baseline + sum of strictly-later deltas -> FoldResult.quantity (derived total) + _ledger_rows (audit). Deterministic, append-only, as-of-correct, concurrency-safe. int+float; exact decimal deferred (documented). delta->STATE classifier guardrail. - Derived totals stay OUT of stored facts: Materialization.quantities channel; projector routes accrue results there; porcelain snapshot/state/ ask/frame_diff surface the total (never a raw delta). - Phase 2: porcelain where(attribute, op, value) numeric predicate (fold-then-compare in Python; non-numeric never matches). - Docs (founder directive): ADOPTION, HOST-DISCIPLINE, LEXICON, WHITEPAPER updated with delta/accrue/quantity/ledger nuances for adopters. Post-impl Codex review (RED -> all fixed): (1) fold_key reordering restored so a stray delta/EVENT row can't suppress an unresolved thunk (+ regression test); (2) state() surfaces the total as fact.value (consistent with ask/snapshot); (3) resolver constraint-inheritance + promote_accruals skip accrue/delta (never inherit/promote a delta). Codex spec-GREEN (3 rounds) + post-impl review. Defaults-preserve: full suite green. 168 tests (+11). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent b775dfa commit 4cb5475

14 files changed

Lines changed: 531 additions & 13 deletions

docs/ADOPTION.md

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,11 +67,25 @@ Domain outcomes are values you must branch on:
6767
never guesses below its confidence floor.
6868
- `FoldResult.conflicted` — the key is under an open truth-maintenance flag;
6969
`winner` is the engine's holding answer, `conflicting` lists both sides.
70+
- `FoldResult.quantity` — for attributes declared `fold_policy="accrue"`,
71+
the folded numeric total. The `winner` is provenance for the latest ledger
72+
row, not the value a host should read.
7073
- `resolve()` returns the module-level sentinel `UNKNOWN` in
7174
`observe_or_unknown` worlds when nothing was observed. "I don't know" is a
7275
representable answer; treat it as one.
7376
- `Materialization.unresolved` names the frontier in scope; `defaults` are
7477
render-coherence fills marked `default` — never facts.
78+
- `Materialization.quantities` carries derived totals as `(entity,
79+
attribute, value)` tuples. These totals are not stored assertions.
80+
81+
### Numeric quantities
82+
83+
For fungible counts such as gold, ammo, liters, or charges, declare the
84+
attribute with `fold_policy="accrue"` before its first data row. A `literal`
85+
numeric row is an absolute baseline; a `value_type="delta"` row is a signed
86+
increment. The fold computes `baseline + later deltas`, with the ledger
87+
available as `FoldResult._ledger_rows` for audit. `int` and `float` are
88+
supported; exact decimal/fixed-point money is deferred.
7589

7690
## MUST / NEVER
7791

@@ -106,8 +120,10 @@ p.ingest_structured(items, frame=None) -> Receipt
106120
p.resolve(entity, aspect, frame="canon") -> {status: resolved|unknown|denied, facts}
107121
p.retract(assertion_id, reason) -> Receipt
108122
p.snapshot(scope_ids, frame=, as_of=, lens=, budget=, since=) -> dict
109-
# contractually ZERO model calls and ZERO writes; id-only scopes
110-
p.state(entity, attribute, frame=, as_of=) -> {status: known|unknown|conflicted, fact}
123+
# contractually ZERO model calls and ZERO writes; id-only scopes; includes quantities
124+
p.state(entity, attribute, frame=, as_of=) -> {status: known|unknown|conflicted, fact, quantity?}
125+
p.where(attribute, op, value, frame="canon", as_of=None) -> [entity_id]
126+
# op in >=, >, <=, <, ==; compares folded numeric values
111127
p.locate / p.contents / p.path
112128
p.events(kind=, participants=str|list, since=, until=, frame=) -> [Event]
113129
p.frame_diff(a, b, scope, as_of=) -> [Fact] # semantic diff; divergent values marked

docs/HOST-DISCIPLINE.md

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,17 +58,22 @@ findable and correlatable.
5858
for what a thing is) and stable domain vocabulary otherwise. Know whether
5959
a key is **functional** (one value at a time — `color`, `in`: later
6060
supersedes earlier) or **set-valued** (`alias`, `connects_to`: values
61-
accumulate, never conflict). **Why:** the fold is key-local. Two names
61+
accumulate, never conflict), and whether a numeric quantity is
62+
**accrue** (absolute baseline plus signed deltas). **Why:** the fold is key-local. Two names
6263
for the same attribute (`wall_color` vs `color`) split a fact across keys
6364
the corroborate/conflict machinery will never see meet. (The gate
6465
canonicalizes a built-in set for you — lean on it, but don't fragment
6566
what it doesn't know.)
6667

6768
3. **Value & value_type.** `entity` when the value is another id (so walks
6869
and correlations traverse it); `literal` for scalars/structured;
70+
`delta` for a signed numeric increment on an `accrue` attribute;
6971
`unresolved` for a deliberately-deferred aspect (a *thunk* — see §below).
7072
Express approximate quantities as **structured bounds** (`{"gte":
7173
40000}`) so a later precise reading *refines* rather than *contradicts*.
74+
For fungible quantities, use `fold_policy="accrue"` and numeric
75+
`literal`/`delta` rows; the engine sums int/float values and keeps the
76+
ledger append-only.
7277

7378
4. **Time — both axes, deliberately.** `valid_from`/`valid_to` is **world
7479
time** (when it was true in the world); the engine separately records
@@ -187,6 +192,7 @@ perspective, and reading what comes back honestly.
187192
|---|---|---|
188193
| one fact (a key's value) | `state(entity, attribute, …)` | the folded winner + conflict flag |
189194
| everything about a subject | `snapshot(ids, lens=…)` / `materialize` | a coherent bundle of folded facts |
195+
| entities satisfying a numeric bound | `where(attribute, op, value, …)` | ids whose folded numeric value matches |
190196
| where something is | `locate(entity)` | containment chain, nearest container first |
191197
| what's inside / co-located | `contents(container)` | members (emptiness = `[]`, derived) |
192198
| is X reachable from Y | `path(a, b)` | a route, or `None` (no false connectivity) |
@@ -242,7 +248,8 @@ perspective, and reading what comes back honestly.
242248
7. **Honor the typed outcomes.** `underdetermined` refer → your ask to the
243249
user. `UNKNOWN` from `resolve` in a tracking world → "I don't know" is a
244250
representable, correct answer. `Materialization.unresolved` names the
245-
frontier (thunks in scope) and `defaults` are coherence fills, not facts.
251+
frontier (thunks in scope), `defaults` are coherence fills, not facts,
252+
and `quantities` are derived totals, not stored ledger rows.
246253
Branch on these; don't coerce them into a guess.
247254

248255
8. **Never cache derived truth host-side.** Location, emptiness, current

docs/LEXICON.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ The working vocabulary for pattern-buffer. One name per concept, used identicall
1818
| **`Frame`** | Perspective scope on every assertion: `canon`, `knows:<entity>`, named frames. Carries knowledge, contested truth, and privacy by structural absence. | RDF named-graph heritage; kept plain deliberately. |
1919
| **`Thunk`** | An explicitly unresolved aspect with a resolution policy. Forced exactly once; memoized forever. Thunks can move without resolving. | CS-canonical (lazy evaluation). |
2020
| **`frontier`** | The explicitly-unresolved region: the thunk table plus everything below the resolution floor. Where nothing has been established, the system serves no invented detail. | Robotics-canonical (occupancy-grid mapping); heritage is the lidar ingestion parallel. |
21+
| **`delta`** | A `value_type` for a signed numeric increment. It is admitted like any other row and only folds into totals for attributes declared `accrue`. | Plain math/database vocabulary. |
22+
| **`accrue`** | A fold policy for fungible quantities: latest absolute numeric baseline plus later signed deltas. | Accounting-canonical. |
23+
| **`quantity` / `ledger`** | The derived numeric total / the append-only rows that produced it. Totals surface through `FoldResult.quantity` and `Materialization.quantities`; the ledger remains ordinary assertions. | Plain accounting. |
2124
| **`Anchor`** | Composite identity signals on an entity: names/aliases, roles, recurring locations, distinguishing features. | Plain. |
2225
| **`Scene cursor`** | The ingest-time "where is the narrated action happening" pose. The largest single precision multiplier in ingestion. | Heritage: lidar pose estimation, not Trek. |
2326
| **`arch`** | The operator's inspection CLI: dump the buffer, query as-of, audit provenance — without touching the world. | Trek: "Computer, arch!" — the operator's control interface inside the simulation. Allowed in code as a tool nickname, not an API symbol. |

docs/WHITEPAPER.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,7 @@ The complete write surface is small:
274274
```
275275
assert(entity, kind) bring a thing into existence
276276
set(entity, attribute, value, …) attach a fact (one assertion)
277+
delta(entity, attribute, signed_number) append a numeric change for an accrue fold
277278
relate(subject, relation, object) add an edge; for single-parent relations
278279
this IS the move operation (supersedes prior)
279280
event(kind, agents, patients, t, effects) reify an occurrence; effects are relates/sets
@@ -323,6 +324,13 @@ materialize(scope, as_of, frame, lens, budget) → materialization
323324

324325
**Algorithm:** select in-scope, in-frame assertions valid at `as_of` per lens → fold STATE by supersession per (entity, attribute, frame) key — *per frame; a belief fold never overwrites canon* → walk the containment tree for the spatial spine, ordered by depth and salience → fill gaps from kind-defaults, every fill marked `default` → resolve forced thunks via the resolver (which feeds new assertions back through classification — the system is closed under its own operations) → shape to budget.
325326

327+
**Numeric quantities:** an attribute may declare `fold_policy=accrue`. Its
328+
fold ignores durability and computes a derived total from the latest numeric
329+
`literal` baseline plus later signed `delta` rows. The total is served as a
330+
quantity, not as a stored assertion; the append-only ledger remains the audit
331+
trail. Integers and floats are supported in v1; exact decimal arithmetic is a
332+
future extension.
333+
326334
**The budget invariant:** the CONSTITUTIVE spine is budget-exempt. Compress DISPOSITIONAL color, summarize peripheral STATE, digest EVENT chains — never compact identity and structure. (Anchored summarization with the anchor formally bound to the constitutive layer.)
327335

328336
**Salience is a projection-time ranking, never a stored truth:** recency + reinforcement count + reference frequency + delta-from-baseline, computed from the log, cacheable in a derived index, never authoritative. Durability and salience are different axes: a fact unmentioned for three campaign-years has zero salience and full validity — ranked low until the drawer opens, then served at full confidence.

src/patternbuffer/classify.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,8 @@ def __init__(
7878

7979
def _guardrails(self, row: Assertion) -> tuple[str, float] | None:
8080
"""Deterministic short-circuits. Return None to defer to the model."""
81+
if row.value_type == "delta":
82+
return STATE, 1.0
8183
if row.entity.startswith("event:") or row.attribute == "caused_by":
8284
return EVENT, 1.0
8385
if row.attribute in {"name", "alias"}:
@@ -274,9 +276,10 @@ def promote_accruals(self, threshold: int = 3) -> int:
274276
if (
275277
row.entity.startswith("a:")
276278
or row.entity.startswith(ATTR_PREFIX)
277-
or row.value_type == "unresolved"
279+
or row.value_type in ("unresolved", "delta")
280+
or self._semantics.is_accrue(row.attribute)
278281
):
279-
continue
282+
continue # deltas / accrue quantities are summed, never a habit
280283
if self.durability(row.id) != STATE:
281284
continue
282285
key = (row.entity, row.attribute, row.frame, repr(row.value))

src/patternbuffer/indexes.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ class FoldResult:
4545
corroborated_by: tuple[str, ...] = ()
4646
values: tuple = ()
4747
_value_rows: tuple[Assertion, ...] = field(default=(), repr=False, compare=False)
48+
quantity: int | float | None = None
49+
_ledger_rows: tuple[Assertion, ...] = field(default=(), repr=False, compare=False)
4850

4951

5052
@dataclass
@@ -128,6 +130,10 @@ def _values_agree(old: object, new: object) -> bool:
128130
return True
129131
return False
130132

133+
@staticmethod
134+
def _is_numeric(value: object) -> bool:
135+
return isinstance(value, (int, float)) and not isinstance(value, bool)
136+
131137
def _is_event_effect(self, row: Assertion, asserted_as_of: int | None) -> bool:
132138
"""True iff the row carries a caused_by edge to an EVENT (spec §9.1)."""
133139
return bool(
@@ -162,6 +168,20 @@ def fold_key(
162168
if not candidates:
163169
return FoldResult(winner=None)
164170

171+
# Accrue reads raw numeric literal/delta rows (it filters to numbers
172+
# internally and ignores unresolved/thunk machinery), so it branches
173+
# before the thunk filter. Deltas are STATE by guardrail, never EVENT,
174+
# so the gather-loop EVENT drop above leaves the ledger intact.
175+
if self._semantics.is_accrue(attribute):
176+
return self._fold_accrue(candidates)
177+
178+
# A `delta` on a NON-accrue attribute is not an absolute value — drop
179+
# it before the thunk filter so it can neither suppress an unresolved
180+
# placeholder nor compete in _fold_state.
181+
candidates = [r for r in candidates if r.value_type != "delta"]
182+
if not candidates:
183+
return FoldResult(winner=None)
184+
165185
# Resolution IS the supersession (whitepaper §8 — forcing memoizes
166186
# into the log; the fold serves the memo): a spent thunk (one with a
167187
# resolved_by marker) never competes, and once concrete rows exist
@@ -210,6 +230,42 @@ def fold_key(
210230
return FoldResult(winner=winner)
211231
return FoldResult(winner=None)
212232

233+
def _fold_accrue(self, rows: list[Assertion]) -> FoldResult:
234+
"""Accrue quantities: latest absolute numeric baseline plus later
235+
signed numeric deltas. The ledger is provenance-only; the derived
236+
quantity is surfaced separately from stored facts."""
237+
def recency(row: Assertion) -> tuple[float, int]:
238+
return (
239+
row.valid_from if row.valid_from is not None else float("-inf"),
240+
row.asserted_at,
241+
)
242+
243+
literals = [
244+
r for r in rows
245+
if r.value_type == "literal" and self._is_numeric(r.value)
246+
]
247+
deltas = [
248+
r for r in rows
249+
if r.value_type == "delta" and self._is_numeric(r.value)
250+
]
251+
baseline = max(literals, key=recency) if literals else None
252+
baseline_value: int | float = baseline.value if baseline is not None else 0
253+
baseline_key = recency(baseline) if baseline is not None else None
254+
contributing = [
255+
r for r in deltas if baseline_key is None or recency(r) > baseline_key
256+
]
257+
contributing.sort(key=recency)
258+
if baseline is None and not contributing:
259+
return FoldResult(winner=None)
260+
total = baseline_value + sum(r.value for r in contributing)
261+
winner = contributing[-1] if contributing else baseline
262+
ledger_rows = ((baseline,) if baseline is not None else ()) + tuple(contributing)
263+
return FoldResult(
264+
winner=winner,
265+
quantity=total,
266+
_ledger_rows=ledger_rows,
267+
)
268+
213269
def _fold_set_valued(self, rows: list[Assertion]) -> FoldResult:
214270
"""Set-valued keys accumulate current members instead of conflicting.
215271

src/patternbuffer/ingest.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@
5454
"entity": {"type": "string"},
5555
"attribute": {"type": "string"},
5656
"value": {},
57-
"value_type": {"enum": ["entity", "literal", "unresolved"]},
57+
"value_type": {"enum": ["entity", "literal", "unresolved", "delta"]},
5858
"frame": {"type": "string"},
5959
"status": {"enum": ["stated", "observed", "inferred", "assumed"]},
6060
"timeless": {"type": "boolean"},

src/patternbuffer/model.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@
6060
SET_VALUED_ATTRIBUTES = frozenset({"name", "alias", "connects_to", "adjacent_to",
6161
"maybe_same_as", "same_as"})
6262

63-
VALUE_TYPES = frozenset({"entity", "literal", "unresolved"})
63+
VALUE_TYPES = frozenset({"entity", "literal", "unresolved", "delta"})
6464

6565
CANON = "canon"
6666

0 commit comments

Comments
 (0)