Status: normative for a document whose schema is loop-studio/graph/3. Issue #471; design record kinds-units-design.md rev 3 (decided 2026-11-03). Extends SEMANTICS-M.md §M4 (the advisory resourceType) and SEMANTICS-M2.md (the model layer). Numbering continues the model-semantics axis: loop-model/1 → /2 → /3.
A loop-studio/graph/3 document may carry a top-level kinds array:
"kinds": [
{ "name": "water", "conserved": true, "baseUnit": "m³" },
{ "name": "power", "conserved": false, "baseUnit": "kWh" }
]nameis a kind: aresourceTypeas §M4.1 identifies it (trim Unicode white space, NFC, ≤ 64 UTF-8 bytes, case-sensitive). AresourceTypeon a Pool or a connection that is in the table is declared; one that is not is undeclared and behaves exactly as underloop-model/2.conserved(boolean) opts the kind into per-kind conservation (M3-3). It is opt-in: the bundled game economies create and destroy by design, and nothing is conserved because it has a name.baseUnit(optional string, trimmed NFC, ≤ 32 UTF-8 bytes) is the kind's base unit, which is also its input unit. It is a label: the engine never converts a unit. (Display units with a factor and a step duration are a later stage and are not part ofloop-model/3.)- Defensive read (
readKinds): a non-array is no table; an entry that is not an object with a usable name is dropped; the first of two equal names wins;conservedis true only when literallytrue; an over-capbaseUnitis dropped, not truncated. File order is kept. The table is written in its read form (canonicalKinds), so a file never carries an entry its reader would drop. - Version boundary. A document with a kinds table is a new model meaning and is written as
loop-studio/graph/3(modelSemantics: "loop-model/3"in its projection). An older app fails closed on it, as a pre-loop-model/2app does on/graph/2(§M2-1). Aloop-model/2document never gains a table by being opened; declaring the first kind is the person's act and promotes the document, one-way, as the first@reference promotes v1 to v2 (§M2-1.1). M3-INV-1: a/graph/3document with no kinds table computes, event for event, whatloop-model/2computes for the same graph.
Every resource connection has exactly one kind, or none, by this resolution (resolveKinds), in this order:
- A Pool end. A connection with a Pool end has that Pool's
resourceType. A connection whose two ends are Pools of two kinds is a conflict. - The connection's annotation. A connection with no Pool end has its own
resourceTypeas its kind, when set — the explicit assignment for Converter → Drain / End, Source → Gate, Converter → Gate and router → router lines. On a connection that also has a Pool end the annotation is a note, never the kind: where it differs from the Pool's kind it is a conflict (the §M4.3 mismatch, kept with its text), and where that Pool has no kind the connection is undetermined (rule 4). - Propagation through Gates. A Gate never changes a kind, so all its connections carry one kind. After 1–2, in synchronous rounds to a fixpoint: every Gate carrying exactly one kind offers it to its undetermined connections, every offer of a round reading the state at the round's start. A connection two Gates offer two kinds to in the same round is contested: it stays undetermined, and both Gates carry both kinds from then on. A Gate carrying two kinds is a conflict and propagates nothing. The result does not depend on the order of ids: two kinds entering a chain of Gates from its two ends meet at one Gate (the conflict) or on one connection (undetermined, a conflict at both its Gates), never as the kind of a connection the first-visited Gate happened to reach.
- None. A connection none of 1–3 reaches has no kind; it is never of a conserved kind. Converters, Sources, Drains and Ends propagate nothing: a Converter changes kinds by its nature; a Drain or an End may take several kinds.
- A Pool that declares none. After 1–3, a determined connection whose Pool end has no
resourceTypeis a conflict naming that Pool: its kind came from the other Pool end (rule 1) or through a Gate (rule 3), and it would enter or leave a Pool that no per-kind sum counts.water Pool → Gate → Poolandwater Pool → Poolwith the second Pool untyped are this conflict; two untyped Pools through a Gate are not (nothing determined reaches them).
A conserved kind is involved at a node when one of its connections has a determined kind the table marks conserved. Everything that refuses (M3-4) refuses only there.
For each Converter where a conserved kind is involved and each conserved kind k among its connections, the balance in k is computable when every connection of the Converter has a kind and every connection of kind k has a numeric rate (a const, or a @param reference resolved to one — the same resolution step performs, §M2-3). When computable it must hold:
Σ inRate(k) = Σ outRate(k) (exactly — SEMANTICS-M4.md §M4-5), deliveries into Drains and Ends counted among the outputs
(Until loop-model/4 this read "to 1e-9", over doubles. Since §M4-5 the test is exact over the authored decimals, for every version; a recipe that balanced only within the tolerance now reports its exact difference.)
Because every connection of a Converter run scales by the one run fraction f (SEMANTICS.md I2), a recipe balanced in rates is balanced in every step and in both modes. M3-INV-2 (I1-K): per step and per conserved kind, Σ Pools(after) = Σ Pools(before) + Source pushes − Drain / End intake, the sums over the Pools of that kind; no net-loss term exists for a conserved kind, and any other difference is an engine defect. The sum holds only if the kind never crosses into a Pool outside it, which is why a determined connection at a Pool with no kind refuses (M3-2 rule 5). A kind not declared conserved is consumed or produced freely. Gates, Sources, Drains, pull all / pull any, Parameters in rates (checked after resolution) and the run fraction are untouched.
kindFindings is a pure function of the graph, its kinds table and its model version, in a stable order (the Pool-end and annotation conflicts by connection id, the no-kind Pool conflicts by connection id, the Gate conflicts by Gate id; then per Converter by id: each non-numeric rate by connection id, each connection with no kind, each broken conserved kind). Three classes, each stating the measured fact:
| class | what | refuses when |
|---|---|---|
| conflict | two Pool ends of two kinds; an annotation against its Pool; a determined connection at a Pool that declares no kind; a Gate carrying two determined kinds | one of its kinds is conserved |
| not computable | a Converter connection whose rate is not a number (all, a percent, a random form, an unresolved reference); a connection with no kind on a Converter where a conserved kind is involved |
a conserved kind is involved at the Converter (the no-kind finding exists only then) |
| broken | a Converter whose computable balance in a conserved kind differs, with Σ in and Σ out |
always (it exists only where a conserved kind is involved) |
A not-computable finding is the whole finding: no Σ in is formed from the connections that do have numbers (a feed line whose rate is all yields "cannot be computed", never "100 ≠ 0").
Refusal is what it is for a split that rejoins (SEMANTICS.md §6, #473 F1): Play, Step and Monte Carlo do not start; the run bar says so with Show; the Inspector of the node says what to change; and step itself, given the kinds table, returns the unchanged state with one diagnostic per refusing finding, so no caller can run around the check. Where no conserved kind is involved, the same facts are findings only, and the run is loop-model/2's: the §M4.3 mismatch keeps its text, a non-numeric rate on a Converter keeps the run-time refusal of that connection (#396, #471 item 5), an undetermined line is simply untyped.
An intended loss is written as an output into a Drain or an End on a line that has a kind (an annotation on a Converter → Drain line; propagation out of a Gate). The step report carries boundaryOutflow: a step's deliveries into Drains and Ends grouped per kind and per exit node (ascending kind, null last, then node id; amounts to 9 decimals). The report names none of it a loss: permeate delivered to "demand", brine to "outfall" and water to "evaporation" are all outflows, and the Drain's label says which is which. The list is empty for a document with no kinds table (loop-model/2 reports are unchanged).
- Model:
src/model/kinds.ts(readKinds,canonicalKinds,resolveKinds,kindFindings,refusingKindFindings). Engine:src/engine/step.ts(the refusal, after the split-rejoin refusal;boundaryOutflowin the report),src/engine/boundaryOutflow.ts. - File:
kindsafterdataImportsin the envelope, written only when non-empty, and then only withschema: "loop-studio/graph/3". Projection and digest (canonicalContent,canonicalGraphString):kindsafterdataImports, beforemodelSemantics, under the model layer; a different table hashes differently. Engine revision (SEMANTICS-W2.md §W2-1) is not raised: no number changes for a document whose meaning did not change. - Pins:
src/model/kinds.test.ts(the case table per connection and Pool),src/engine/kinds.test.ts(what runs, what is refused, M3-INV-1 and M3-INV-2, the outflow), the serializer and revision tests for the envelope and the digest.