docs/projection-rules.md
Projection rules — the explanation cannot drift
The manifest's transform was hand-written prose while scope, warrant and
discarded were machine-checked. One round later it was already false.
src/lib/projection/rule.ts, tests/rule-divergence.test.ts.
What was shipped wrong
| implemented | explained | |
|---|---|---|
| height | 2 + sqrt(bytes / peakBytes) * 26 |
"logarithmic scale" |
| activity | min(1, commits / peakChurn) |
"divided by a declared ceiling" |
Both wrong. log10 is the estate's rule; the generated world uses sqrt.
And the generated world has no declared ceiling at all — it divides by the
observed peak.
The defect was predicted the round it was introduced, and it still shipped, because prose beside machine-checked fields reads as if it were checked too. Seventh instance of declared-authority versus actual-authority.
The fix is one authority, not two that agree
A rule is data:
HEIGHT_RULE = {
source: "repo/bytes",
operation: "sqrt",
normalization: "peak",
offset: 2, scale: 26,
clampMin: 0.4, clampMax: null,
}
applyRule is the only thing that computes the value. describeRule
generates the sentence from the same fields. There is no edit that changes
one without the other:
sqrt → 10.22 · "divided by the largest value currently observed, then square root,
then scaled by 26 and offset by 2, then floored at 0.4"
log10 → 3.08 · "divided by the largest value currently observed, then log base 10 of (x + 1),
then scaled by 26 and offset by 2, then floored at 0.4"
That is the mutation test asked for. Changing operation moves the pixels and
the explanation, in the same edit, because both read the same field.
The vocabulary is a pipeline, not fixed fields
A rule is now a list of steps, and both applyRule and clause switch
exhaustively over Step:
7 operations, every one with an implementation and a clause
A new operation cannot ship without a sentence. The compiler refuses.
That is stronger than a test that checks agreement: divergence is
unrepresentable rather than detected. Adding { op: "cbrt" } fails to
compile in two places until both are written.
"repo/bytes, divided by the largest value currently observed, square-root
transformed, scaled by 26, offset by 2, floored at 0.4"
Entirely generated. No prose survives in the manifest — a test fails if the words logarithmic, square root, constant per subtype or deterministic hash reappear there.
Decorative is a different KIND, not an empty rule
HUE_RULE and FOOTPRINT_RULE are DecorativeRule: no source, no steps,
semanticClaim: null. A rule with an empty pipeline would still read as a
derivation; these cannot, and the test asserts they have neither field.
hue: "distinction between subjects, by a deterministic hash of the identifier
— carries no semantic claim"
That is the colour disclosure: a viewer is told not to read a category into it.
Three guards, not one
- The sentence is generated — a test fails if
manifest.tscontains the word logarithmic, square root or log base again. - No call site recomputes — a test fails if
from-facts.tscontainsMath.sqrt(b / peakBytes)alongside the rule. That is the bypass a structural fix has to catch. - The arithmetic is unchanged —
applyRuleis checked against the literal expressions it replaced, at five inputs each. No pixel moved.
The eighth instance, and the fix
The previous round measured 1 of 4 on the deliberate bad rule: the sentence
followed the steps, and scope and warrant did not — they were flat fields on
the rule, which is the same defect one layer down. Mechanism/explanation drift
was fixed; mechanism/authority drift was not.
scope and warrant are no longer fields. They are folded from the steps:
scopeOf(step) // exhaustive: a new op must declare how far it reaches
warrantOf(step) // exhaustive: a new op must declare what licenses it
ruleScope(rule) = the widest reach any step introduced
ruleWarrant(rule) = evidence only if EVERY step was evidential
A test asserts the fields have not returned. The rule literally cannot announce a warrant its steps do not support, because there is nowhere to write one.
The normalize basis is now structured rather than a string — a free-text
basis would have put the contribution back out of reach:
Reference =
| { kind: "observed-peak" } // world · evidence
| { kind: "stale-peak"; asOf } // world · convention
| { kind: "declared-ceiling"; value; basis } // subject · convention
The bad rule, re-run
transform: "repo/bytes, divided by the largest value as of yesterday, square-root
transformed, scaled by 26, offset by 2, floored at 0.4"
stipulations now: divided by the largest value as of yesterday · floored at 0.4
The stale reference names itself as a stipulation, with nobody editing text.
And it caught a claim I had written
height declared "evidence" · folded "mixed" — floored at 0.4
activity declared "evidence" · folded "mixed" — capped at 1
Both rules declared warrant: "evidence" and both were wrong. A clamp is a
stipulation: every subject below floor 0.4 renders at the same height, which
asserts a saturation no observation makes. The fold found it immediately, in
values I had hand-written one round earlier.
The criterion for warrantOf is stated rather than assumed: a step contributes
convention when it introduces a chosen reference that changes what the value
means. Order-preserving arithmetic — sqrt, log10, scale, offset — does
not. A clamp does.
Standing
| Measurable defect exposed? | yes — two false explanations, shipped |
| Measurable improvement? | yes — value and sentence share one authority, asserted |
| Pixels changed? | no — verified against the replaced arithmetic |
What is still not established
- Two channels of five are ruled.
position,identity hueandfootprintstill describe themselves in prose, and position's is the most complex — "shortest-path metric, symmetrised, solved by MDS" — so it is the most likely to go stale next. - The estate's
resolveHeightis untouched. It still computeslog10inline inbuild.ts, so the calibration path has the old shape. Only the generated world — the product path — is ruled. Operationhas three members. A projection needing a fourth would have to extend the union, and nothing yet forces a new operation to arrive with a sentence clause.- The inspector panel remains unverified visually: the app is passkey-gated and signing in is the user's action.
272 → 278 tests, 26 DOM, 40 mutations, tsc clean.