docs/decomposition-experiment.md
Can the decompositions be implemented without changing core?
Empirical result. The question was whether the decompositions in
types-classification.md can be implemented without extending the core
vocabulary — and if not, what exact phenomenon the vocabulary cannot express.
Answer: yes, core needs no change. But one of my stated claims was wrong, and the implementation is what proved it.
What was measured first
Before implementing, the blast radius:
| Measurement | Result |
|---|---|
DistrictKind referenced outside types.ts |
0 files |
BuildingKind referenced outside types.ts |
0 files |
LinkKind referenced outside types.ts |
1 (facts.ts) |
WorldEvent.kind values produced |
alert (3), handoff (2) |
"deploy" / "commit" producers or consumers |
none — dead members |
Consumers compare string literals directly (kind === "department") rather than
importing the taxonomy types. deploy and commit are declared, never produced,
never consumed.
They were not deleted. Deleting a member because it is presently unused would reduce intended expressive power on the basis of current usage alone — the same error as inferring a boundary from a single consumer.
Step 1 — derive the union from a const (kept: zero cost)
core/condition.ts derives ConditionId from a CONDITIONS const by indexed
access. The same construction applied to events:
export const EVENT_KINDS = ["alert", "handoff", "meeting", "deploy", "commit"] as const;
export type EventKind = (typeof EVENT_KINDS)[number];
Result: tsc clean, no other file touched. The pattern transfers from core
unchanged. This is retained.
But it does not perform the decomposition. The platform still names the members. It only changes where they are written.
Step 2 — the application supplies the members (reverted: both variants cost)
The actual decomposition requires the vocabulary to come from the application. Implemented as a type parameter, in the two available variants.
Variant A — with a default (K extends string = string)
tsc clean. And that cleanliness is the defect:
const bogus: WorldEvent = { kind: "banana", /* … */ }; // COMPILES
Exhaustiveness is lost. The default silently widens the contract to any string. Nothing constrains a consumer to the declared vocabulary, and no error is produced anywhere.
Variant B — no default (K extends string)
Exhaustiveness is preserved, and the parameter must then be threaded through every consumer:
112 errors across 16 files — WorldScene, WorldView, CityConsole,
RuntimePanel, useWorld, build.ts, answers.ts, constitution.ts, the
stress route, the page, and six test files.
The claim that was wrong
types-classification.md §12.1 states:
Expressive power is preserved, and exhaustiveness is not lost, because the application's set is still closed: it is closed by declaration rather than by being written into the platform.
That is false as implemented. Variant A loses exhaustiveness outright; variant B preserves it only by propagating a type parameter to every consumer. There is no zero-cost variant. The claim was reasoning about the mechanism, and the measurement contradicts it.
The phenomenon, stated precisely
The thing that resists implementation is not expressible-vs-inexpressible in the architectural vocabulary. It is narrower:
A closed vocabulary whose members are chosen by the consumer cannot be expressed in a shared TypeScript type without either losing closure (a default type parameter) or propagating that parameter to every consumer.
Core never encounters this because core's vocabulary is universal. The 20
conditions and 6 subject kinds apply to a hospital and a fleet unchanged, so
core owns them outright and never needs to be parametric. world/types.ts has a
domain taxonomy, which is why the same construction does not transfer.
This is a type-system constraint, not an architectural gap. The architectural
vocabulary describes the situation without strain: the World is a projection
whose codomain is application-determined; the taxonomy is an application-owned
information-bearing structure; the platform's obligation is to validate rather
than to enumerate.
By the standing rule — architectural change requires a measured phenomenon the current vocabulary cannot faithfully represent — this does not qualify. The vocabulary represents it faithfully. Only the type system makes one encoding of it expensive.
Where exhaustiveness actually belongs
The 112 errors came from trying to make the platform type carry the application's exhaustiveness. That is the wrong location, and core already demonstrates the right one:
ingestvalidates emissions against a declared manifest at runtime;- the compile-time union is derived from a const owned by whoever declares it.
Applied here: the platform type carries kind: string and validates against the
application's declared vocabulary; the application's renderer switches
exhaustively on its own const, with no generics, because HQ's renderer is
HQ's code and already knows HQ's vocabulary.
Exhaustiveness is therefore not lost — it moves, from the shared contract to the consumer that owns the taxonomy. That is the same split the boundary document reached from the other direction.
Not implemented here. It requires deciding the runtime-validation seam for the world layer, and that is a design decision rather than a measurement.
Status of the other decompositions
Unattempted, and each is now known to carry a different risk profile:
| Decomposition | Expected friction |
|---|---|
12.2 productId → SubjectRef |
low — a field swap; core already has SubjectRef/urn |
12.3 revenue → MeasureFact[] |
low — World.measurements already exists |
12.5 stats → keyed counts |
medium — every consumer reads stats.* by name |
12.6 sources.ok → three-valued |
low, and it recovers a distinction rather than preserving one |
12.6 is the one to do first if any: it fixes a measured defect, and unlike 12.1 it increases expressive power rather than trading it.