Scope note (2026-07-22): open or future work described in this document is re-scoped by the
five-port-parityfreeze to the active ports (Rust, Swift) plus theworkspace_interpreter/reference — seePOLICY.md§1. Completed work described four- or five-wide is historical fact as of the tag.
Status: increments 1–2 + 3a + 3b built — the generator engine + format + cross-language gate
(regular_polygon, spiral, star, gear, the §6.3 flagship), the floor/mod parity
primitives, the concept registry, the LiveVariant::Generated document arm (model + eval
- codecs + tests) in all four apps (golden-pinned, byte-identical through test_json and binary),
and Concepts-panel Slices 1 AND 2 functional end-to-end in all four native apps: open Concepts →
select a concept → Place → a default-param
Generatedinstance is placed and renders; then select it and edit its parameters in the panel and the geometry re-generates live (the §6.4 parametric heart). Slice 2 = the dual-mode panel (registry list when nothing is selected; the selected instance's editable params otherwise) driven byactive_document.selected_concept+ the nativeset_concept_parameffect (find the single selectedGenerated, writeparams[name]=value, one undo), in Rust/Swift/OCaml/Python, each with a controller + view test mirroring the same fixtures. Flask exposes the registry list and both panel foreaches but has no interactive editor (its legacy server renderer can't interpolate loop-var text). Remaining: operations, the fitter, constraints (§7); anop_applyreplay arm forplace_concept_instance/set_concept_param(parity, no fixture exercises it yet). · Implements:VISION.md§6.3 ("domains as declarative packs") and closes its named "decisive gap" — the expression language can now generate geometry, so a parametric concept is data. · Builds on: the geometry-generator functions (sin/cos/tan/pow/range/fold) pinned by the expression conformance corpus (SCHEMA.mdExpression Language;VISION.md§10 item 3).
A naming note (as everywhere): this is a vector illustration application; we never name a commercial product.
A concept (gear, polygon, eye, connector, hatch, …) is data, not native
code — the §6.3 vision. Per VISION.md §5.3 it has four parts:
- a generator — parameters → geometry (this increment);
- a fitter — raw selection → parameters/roles (the "promote" dual of
release/expand; deferred); - operations — its edit verbs (deferred);
- constraints — its invariants (deferred).
The discipline is the recurring one (VISION.md §5): a concept instance is a
source description evaluated against a context, never a baked snapshot. The
generator is that source; the parameters are the context; the geometry is the
evaluation.
| # | Fork | Decision |
|---|---|---|
| 1 | What is a generator? | An expression, in the existing language, evaluated with the concept's parameters bound under the param namespace (already a runtime-context namespace). No new evaluator — concepts ride the engine extended for VISION.md §6.3. |
| 2 | Generator output shape | A list of [x, y] points. Maps directly onto PolygonElem.points (Vec<(f64,f64)>) — the regular_polygon_points precedent already returns exactly this, so no new geometry machinery and instant cross-language serialization (points_json → [[x,y],…]). A per-concept closed flag selects polygon (closed) vs polyline (open). Richer outputs (subpaths, curves, multi-element) are a later generalization. |
| 3 | Where concepts live | workspace/concepts/*.yaml, one file per concept — the same authored-data discipline as workspace/tools/*.yaml and workspace/panels/*.yaml. |
| 4 | How the engine is pinned | A shared conformance corpus (workspace/tests/concepts.yaml) compiled to test_fixtures/concepts/conformance.json and self-checked in all five apps — exactly the expression-corpus pattern. Concept evaluation == "bind params, evaluate the generator expression, read the points", so the gate reuses the expression evaluator each app already has. No app-evaluator code changes in increment 1. |
| 5 | Document integration timing | Engine first, wire later. Increment 1 pins the generator semantics with zero document/UI coupling (VISION.md §4: "don't chase features; pin the interpreter"). A concept instance becomes a LiveVariant::Generated { concept_id, params } arm in a later increment (§6). |
workspace/concepts/<id>.yaml:
id: regular_polygon
name: Regular Polygon
# Comprehensive, human-readable English (the workspace convention).
description: >
A regular N-sided polygon centered on the origin, with the first vertex on
the +x axis. `sides` vertices are placed at equal angular steps of 360/sides
degrees on a circle of radius `radius`.
params:
- { name: sides, type: number, default: 6 }
- { name: radius, type: number, default: 50 }
closed: true # the point list forms a closed polygon (vs an open polyline)
generator: |
map(range(0, param.sides), fun i ->
let a = 360 * i / param.sides in
[param.radius * cos(a), param.radius * sin(a)])params— each{name, type, default}.typeisnumberin v1 (the field exists so non-numeric params are addable later). Defaults make a concept instantiable with no arguments.generator— an expression that readsparam.<name>and evaluates to a list of[x, y]points. Uses only the shared, corpus-pinned language; trig is in degrees (SCHEMA.md).closed—true⇒ the points close into a polygon;false⇒ an open polyline (e.g. a spiral).
Concepts shipped: regular_polygon (closed) and spiral (open) in
increment 1; star and gear (closed; the §6.3 flagship) in increment 2,
once the floor/mod parity primitives landed.
workspace/tests/concepts.yaml — cases reference a concept by id, override
params, and pin the expected geometry:
tests:
- concept: regular_polygon
params: { sides: 4, radius: 10 }
expected: [[10, 0], [0, 10], [-10, 0], [0, -10]]scripts/compile_concept_corpus.py resolves each case against its concept file
(inlining the generator expression and merging param defaults with overrides)
into a self-contained test_fixtures/concepts/conformance.json:
{ "concept": "regular_polygon", "generator": "<expr>",
"params": { "sides": 4, "radius": 10 }, "closed": true,
"expected": [[10,0],[0,10],[-10,0],[0,-10]] }scripts/check_concept_corpus.sh is the freshness gate (mirrors
check_workspace_json.sh). Each app's cross_language_test loads the JSON,
binds params under param, evaluates the inlined generator with its own
expression evaluator, reads the resulting points, and compares to expected
component-wise within 1e-9 (the same tolerance the expression corpus uses;
trig results are not exact). Point count is part of the contract.
Because a generator is an expression, this gate is a thin specialization of the expression conformance gate — it inherits the same cross-language guarantee.
Evaluation is a pure function of (concept generator + parameter values), so it is
deterministic and replayable (VISION.md §8). The only cross-language subtlety
is floating-point trig, handled by the 1e-9 tolerance — already validated by the
expression corpus. Recompute order is intrinsic to the generator's range/map,
not hashmap order, so the §8 determinism trap does not apply.
A concept instance in a document is a parametric live element. It fits the
existing LiveElement framework as a fourth LiveVariant arm, alongside
CompoundShape / Reference / Recorded (live.rs):
LiveVariant::Generated { concept_id: String, params: <json map>, common }
- Evaluation plugs into the single
evaluate_with(precision, resolver, visiting)seam: resolve the concept by id, bindparams, evaluate the generator, turn the points into the output geometry. (Generatedhas no by-id inputs, so theresolveris unused — simpler thanRecorded.) - Serialization mirrors
Recorded:type: "live",kind: "generated", withconcept_id+ a canonical-JSONparamsmap on the wire (binary slots 8/9; test_json keys; SVG emits the evaluated geometry likeCompoundShape). - Touch points per app: the
LiveVariantenum + theLiveElementtrait methods + the eval dispatch + the three codecs, propagated Rust → Swift → OCaml → Python.
This needs a per-app concept registry (load the concepts from the compiled
workspace.json). Increment 3a — ✅ done: the loader now bundles
workspace/concepts/*.yaml into workspace.json under a concepts key, and every
app exposes a concept(id) accessor (Python loader.concept, Rust/OCaml/Swift
Workspace*.concept), each with a load test that resolves a concept and evaluates
its generator to geometry. The Generated element arm (3b) builds on this.
- Generator engine + format + cross-language gate (this increment).
regular_polygon+spiral; no document coupling. - Parity primitives + gear/star — ✅ done.
floor(x)andmod(a, b)(the flooreda - b*floor(a/b), defined identically in every interpreter rather than the host%, whose sign convention differs) were added to the math family and corpus-pinned;starandgearare authored and gated across all five apps. This completed theVISION.md§6.3 flagship example as data. - Concept registry + the
LiveVariant::Generatedarm (§6). (a) ✅ done — the registry: concepts compiled intoworkspace.json+ a per-appconcept(id)accessor, each with a load-and-evaluate test (increment 3a). (b) theGeneratedelement arm so a concept instance lives in a document, renders, round-trips, with editable parameters (the live "tune the same parameters without redoing anything" of §6.4). Rust lead ✅: theGeneratedElemmodel, the 13LiveElementdispatch methods,evaluate_with(concept resolved via a registry-extendedElementResolver→ generator → points → geometry), the test_json/binary/SVG codecs, and round-trip + eval tests. All four apps ✅ — Swift (inline-commonRecordedElemlayout), Python (LiveElementdataclass + class-based resolver), and OCaml (aconcept_resolverclosurestring -> (params -> points) option, sincelib/geometrycan't import the evaluator — the caller builds it, geometry stays decoupled). All pinned by the shared goldenexpected/generated_polygon.json(byte-identical through test_json and binary). The render-resolver wiring is done in all four apps (each production resolver reads the cachedWorkspaceconcept registry, so aGeneratedinstance draws its geometry on the canvas). The creation action is a Concepts panel modeled on the Symbols panel: a registry-driven list + a Place Instance action that appends a default-paramGeneratedelement. Slice 1 is complete end-to-end in all four native apps —workspace/panels/concepts.yaml, theconcepts_panel_select(generic) +place_concept_instance(native, id value-in-op, one undo) actions, the Window-menu entry,data.conceptsexposed per app, and the native place effect + render wiring — open Concepts → select → Place → it renders. (Flask exposes the list but has no interactive editor.) Slice 2 is also complete end-to-end in all four native apps — the dual-mode panel (bind.visibleonactive_document.selected_concept: registry list when null, the selected instance's editable params otherwise), theactive_document.selected_conceptview field ({concept_id, name, params:[{name,value,min,max}]}— registry schema merged with instance values; null unless exactly oneGeneratedis selected), and the nativeset_concept_parameffect (find the single selectedGenerated, writeparams[name]=value, one undo via the self-bracketing controller). The param field's committed value is injected asevent.valueby abehavior: [{event: change, action: set_concept_param}]block — wired in each app'snumber_inputcommit path (Rust's widget framework already dispatchedchange; Swift/OCaml/Python needed the commit-time dispatch added). Each app carries a controller test (set_concept_paramupdates the param in place) and a view test (selected_conceptpresent for a single selectedGenerated). Closing Python's Slice-1 gap, theplace_concept_instancepanel action — which had only a controller method, never a native panel-dispatch arm — is now intercepted natively too (concepts_apply.py+ a_dispatch_concepts_actionarm). Theop_applyreplay arm is done in all four native apps — both verbs now route their native handler throughop_applyinside the one-undo bracket (so the LIVE action JOURNALS a realPrimitiveOp, value-in-op: concept id + resolved default params + minted id for place; path + name + committed value for set), with matching replay arms next toplace_instance. Each app carries a focused checkpoint_equivalence test (place a hexagon + tune sides 6→8 → the journal replays byte-identically to the live snapshot, twice). Document state is unchanged (same controller calls); only the journal gains the replayable entry. (SVGdata-jas-paramsis not byte-compared by any fixture; its serialization stays per-app-native.) - Operations — ✅ done (all 4 native apps). A concept's named edit verbs
(
add_tooth/remove_toothon gear,add_side/remove_sideon polygon), declared in the pack asset:expression transforms of the instance'sparamsand journaled as the single op-log verbapply_concept_operation(effect RESOLVED value-in-op at production time; replay merges, never re-evaluates). Pinned by the operations conformance corpus + a checkpoint_ equivalence replay test per app; the Concepts panel renders a button per operation. Detailed design + the conformance gate: §9. - The fitter (
promote) — ✅ done (all 4 native apps). Raw selection → parameters/roles via a regular-polygon detector. A fitter is an expression (the dual of the generator) over ashapenamespace, needing one new builtinatan2;promote_to_conceptreplaces a raw Polygon/Polyline with aGeneratedcarrying the recovered params + atranslate·rotateplacement transform. Pinned by the fitter conformance corpus + a generator/fitter round-trip + a checkpoint_equivalence replay per app. The gate also caught three latent bugs (a Rust dispatch gate, a Swift NSNumber/Bool eval bug, an OCaml list-dot-access eval bug). The fuzzy/AI tier stays later (VISION.md§7 frontier). Detailed design + the conformance gate: §10. - Constraints — ✅ done (all 4 native apps). A concept's invariants as boolean
checkexpressions overparam(min_teeth/outer_exceeds_rooton gear,min_sides/positive_radiuson polygon), surfaced advisorily — the panel shows the violated constraints' messages, never blocking the edit. A read-only checker (no op-log verb, no controller):selected_concept.violationscollects the constraints whosecheckis not truthy. Pinned by the constraint conformance corpus + a view test per app. Bidirectional constraint solving (IK) stays the separate, harder layer (VISION.md§6.2). Detailed design + the conformance gate: §11.
All six increments are complete — a concept's four parts (generator, operations, fitter, constraints) all ship as declarative data across the 4 native apps, each cross-language-pinned.
- Generator output is points-only in v1 — curves (a gear with rounded teeth) and multi-element concepts need a richer output shape; designed as a later generalization of decision 2, not a rewrite.
- Float equivalence — trig across libms; mitigated by the 1e-9 tolerance the expression corpus already proves out. Keep concept expected-values clean.
- The corpus inlines generators — until increment 3's registry, the concept files are exercised only by the compile step, not loaded by any app. State this so no one assumes runtime concept loading exists yet.
- No constraint representation yet — the §6.3 downside is real and unbuilt; v1 concepts are pure generators with no invariants.
The second of a concept's four parts (VISION.md §5.3, §1 here): its edit
verbs. The generator answers "params → geometry"; an operation answers "this
named edit → new params". A gear's add_tooth is the canonical example: a
single, meaningful verb the artist invokes, distinct from hand-typing a number
into the teeth field. Operations are data, not native code — the recurring
discipline — so a new domain ships its verbs in its pack with zero app changes.
| # | Fork | Decision |
|---|---|---|
| 1 | What is an operation's effect? | A set: map of param-name → expression, each expression evaluated in the existing language with the instance's CURRENT params bound under param (exactly the generator's namespace). The result is the new value for that param; unnamed params are unchanged. No new evaluator, no JS — operations ride the same corpus-pinned engine as generators. |
| 2 | Arguments? | None in v1. An operation is a self-contained named transform (add_tooth, remove_tooth, add_side). A parameterized operation ("set tooth count to N") is just set_concept_param and already exists; argument-carrying operations (with a prompt/dialog) are a later generalization. |
| 3 | Where do operations live? | In the concept pack: a new top-level operations: list in workspace/concepts/<id>.yaml, bundled into workspace.json by the existing loader (the registry already carries the whole concept, so concept(id).operations needs no loader change). |
| 4 | Op-log representation (the determinism fork) | One verb, apply_concept_operation, with the effect RESOLVED at production time. The native handler reads the instance's current params, evaluates the operation's set: expressions, and bakes the resulting changes map into the op value-in-op ({path, op_id, changes}). Replay merges changes — it never re-evaluates an expression nor consults the registry, so it is byte-identical and survives a later edit to the operation's definition (the OP_LOG §7 rule, exactly as set_concept_param bakes its committed value). op_id rides along as journal metadata (semantic readability for the op-log spine); changes is authoritative. |
| 5 | How it is pinned | A dedicated operations conformance corpus — workspace/tests/concept_operations.yaml → test_fixtures/concept_operations/conformance.json — self-checked in all five apps, mirroring the concept corpus. A case is (concept, op, params) → expected changes; each app evaluates the operation's set: expressions over params and compares the resolved changes within 1e-9. Operation resolution IS expression evaluation, so this is again a thin specialization of the expression gate. |
# appended to workspace/concepts/gear.yaml
operations:
- id: add_tooth
label: "Add Tooth"
description: >
Add one tooth to the gear: increases `teeth` by one. The geometry
re-derives from the generator at the new tooth count.
set:
teeth: "param.teeth + 1"
- id: remove_tooth
label: "Remove Tooth"
description: >
Remove one tooth, clamped to the 3-tooth minimum a gear needs to be a
gear: sets `teeth` to max(teeth - 1, 3).
set:
teeth: "max(param.teeth - 1, 3)"Each operation is {id, label, description, set}. set is a map (param name →
expression over param); only the named params change. description follows the
workspace convention (comprehensive, human-readable). Operations are optional — a
concept with no operations: key simply offers none.
- Declare — operations in the pack; the registry exposes
concept(id).operations. - Expose —
active_document.selected_conceptgains anoperationslist ([{id, label, description}]) alongsideparams, so the panel can render a button per operation (params mode, beneath the param fields). - Invoke — a button dispatches
apply_concept_operation { op_id }. - Resolve (production-time, value-in-op) — the native handler finds the
single selected
Generated, looks up the operation in the registry byop_id, evaluates eachset:expression withparam= the instance's current params, and builds{op: apply_concept_operation, path, op_id, changes}. - Journal + apply — routed through
op_applyinside the one-undo bracket (the §7.3 pattern). The replay arm callsController.apply_concept_operation(path, changes), which mergeschangesinto theGenerated's params (a multi-param generalization ofset_concept_param); the geometry re-derives at the next render.
Authored before any controller/op_apply code:
- the operations corpus (§9.1 decision 5) — the cross-language equivalence gate;
- a controller test —
apply_concept_operationmerges achangesmap into aGenerated's params in place; - an op_apply replay test —
apply_concept_operationjournals one op and replays byte-identically (checkpoint_equivalence), reusing the §7.3 harness; - a view test —
selected_concept.operationslists the registry's operations for a single selectedGenerated.
Operation arguments (parameterized verbs with a prompt), operations that read
more than param (e.g. the current geometry or sibling elements), and
multi-instance / batch operations are out of scope for v1 — each a clean later
generalization, none a rewrite.
The third of a concept's four parts (VISION.md §5.3): its fitter, the dual
of the generator. The generator answers "params → geometry"; the fitter answers
"geometry → params" — "this drawing IS a hexagon; recover its sides and
radius." promote runs the fitter over a selected raw shape and, on a match,
replaces it with a live Generated instance carrying the recovered parameters —
the release/expand inverse (VISION.md §5.3), and the deterministic floor of
the §7 frontier (the fuzzy/AI tier is layered on top later, never underneath).
A generator is an expression (format decision 1); by symmetry a fitter is too.
A capability audit of the expression engine confirmed it can express a
regular-polygon detector — list indexing (p[0]), .length, fold with a list
accumulator, map/range, all/any, hypot, min/max, let, lambdas,
booleans, if/then/else, and null — with exactly one missing primitive:
atan2(y, x) (to recover the placement angle). So the fitter rides the same
corpus-pinned engine as the generator, atan2 (degrees, mirroring sin/cos) is
added to the math family as a small, in-pattern extension (corpus-pinned), and
concepts stay data, not native code.
| # | Fork | Decision |
|---|---|---|
| 1 | What is a fitter? | An expression over a new shape namespace (shape.points = the selected shape's vertices as [[x,y],…]), evaluated by the same engine. It returns null (no match) or a flat result list (decision 3). |
| 2 | The one language gap | Add atan2(y, x) in degrees to the expression language (all 5 evaluators + the expression corpus). Nothing else is missing. |
| 3 | The fitter's output contract | null | [<param0>, <param1>, …, cx, cy, rotation] — the first K values are the concept's K declared params in params: order, followed by the fixed placement triple cx, cy, rotation (degrees). No per-concept output map is needed: K is read from the concept's params:. |
| 4 | How promote places it |
The recovered params build the Generated; the placement triple builds common.transform = translate(cx,cy) · rotate(rotation) (applied generically at render — verified). The generator stays origin-centered/first-vertex-on-+x; the transform overlays it on the drawing. A regular polygon's CW/CCW winding yields the same vertex SET, so a convex match is visually exact either way. |
| 5 | Op-log representation | One verb promote_to_concept, fully value-in-op: the native handler runs the fitter(s) at production time and bakes {path, concept_id, params, transform} into the op. Replay rebuilds the Generated and replaces the element — it never re-runs a fitter nor consults the registry (the OP_LOG §7 rule). |
| 6 | Which concept? | promote tries every registered concept's fitter over the selection and takes the first match (registry order). With one fitter (regular_polygon) in v1 this is unambiguous; multi-fitter scoring/disambiguation is later work. |
| 7 | How it is pinned | A fitter conformance corpus — workspace/tests/concept_fitters.yaml → test_fixtures/concept_fitters/conformance.json — self-checked in all five apps: a case is `(concept, points) → expected (null |
# appended to workspace/concepts/regular_polygon.yaml — the dual of `generator`
fitter: |
let pts = shape.points in
let n = pts.length in
if n < 3 then null else
let cx = fold(pts, 0, fun (acc, p) -> acc + p[0]) / n in
let cy = fold(pts, 0, fun (acc, p) -> acc + p[1]) / n in
let dists = map(pts, fun p -> hypot(p[0] - cx, p[1] - cy)) in
let r0 = dists[0] in
let rtol = 0.000001 * r0 + 0.0000001 in
let radii_equal = all(dists, fun d -> abs(d - r0) < rtol) in
let edges = map(range(0, n),
fun i -> hypot(pts[mod(i + 1, n)][0] - pts[i][0],
pts[mod(i + 1, n)][1] - pts[i][1])) in
let e0 = edges[0] in
let etol = 0.000001 * e0 + 0.0000001 in
let edges_equal = all(edges, fun e -> abs(e - e0) < etol) in
if radii_equal and edges_equal and r0 > 0 then
[n, r0, cx, cy, atan2(pts[0][1] - cy, pts[0][0] - cx)]
else nullA regular polygon ⇔ all vertices equidistant from the centroid (equal radii) AND
all edges equal — both expressible with the available list ops. The result is
[sides, radius, cx, cy, rotation]. fitter: is optional — a concept with none
can't be promoted to.
- Declare —
fitter:in the pack; the registry already carries it. - Extract —
promotereads the selected element's vertices (aPolygon/Polylinein v1) intoshape.points. - Detect (production-time, value-in-op) — evaluate each registered concept's
fitterovershape; the first non-nullresult wins. Split it into params (first K, byparams:order) and the placement triple; build the transform. - Journal + apply — build
{op: promote_to_concept, path, concept_id, params, transform}and route throughop_applyin the one-undo bracket. The replay arm callsController.promote_to_concept(path, concept_id, params, transform), which replaces the element with aGenerated { concept_id, params, common: { transform } }. - Invoke — an
Object-menu item "Promote to Concept", enabled for a single eligible element.
Authored before the promote code:
atan2expression-corpus cases — the language extension, gated in all 5 apps;- the fitter conformance corpus (§10.2 decision 7) — the detector, cross-language;
- a round-trip property test — generate
regular_polygon{sides,radius}, feed the points back through the fitter, assert it recovers[sides, radius, 0, 0, 0](canonical placement) within tolerance; - a controller test —
promote_to_conceptreplaces the element with the expectedGenerated(params + transform); - an op_apply replay test —
promote_to_conceptjournals one op and replays byte-identically (checkpoint_equivalence).
Path inputs (curve sampling), non-canonical detectors beyond regular polygon
(star/gear fitters, ellipse, line), multi-fitter scoring (when several concepts
match), tolerance configurability, and the fuzzy/AI tier (VISION.md §7) are
all later — the deterministic regular-polygon detector is the floor everything else
builds on.
The fourth and final part of a concept (VISION.md §5.3): its constraints —
the invariants its parameters must satisfy. A gear with teeth < 3 or
outer <= root is not a gear. Constraints are declared data (the recurring
discipline) and advisory (VISION.md's deterministic-core + artist-primacy):
the checker SURFACES violations; it never blocks the artist's edit. This closes
the §6.3 "downside" (concepts had no invariants). Bidirectional solving — move a
handle, back-solve the params (IK) — is the separate, harder layer (VISION.md
§6.2), explicitly out of scope.
| # | Fork | Decision |
|---|---|---|
| 1 | What is a constraint? | A boolean expression over param (the same engine), plus a human-readable message. param.teeth >= 3. No JS — the §6.3 downside closed with the corpus-pinned language. |
| 2 | Advisory or blocking? | Advisory. The checker returns the violated constraints; the panel surfaces them under the params. An edit is NEVER rejected (artist-primacy). Auto-clamping already lives in operations (remove_tooth clamps to 3); op-time enforcement is a later option, not v1. |
| 3 | Where do constraints live? | A new constraints: list in the concept pack — {id, message, check}. Bundled into workspace.json by the existing loader (the registry already carries the whole concept, so no loader change). |
| 4 | The checker | A pure function check(concept, params) → [{id, message}]: evaluate each check over param and collect the constraints whose result is not truthy (reusing each interpreter's existing if-truthiness, so the verdict agrees cross-language). READ-ONLY — no op-log verb, no mutation, no controller change. This makes §7.6 the lightest of the four parts. |
| 5 | How it is pinned | A constraint conformance corpus — workspace/tests/concept_constraints.yaml → test_fixtures/concept_constraints/conformance.json — self-checked in all five apps: a case is (concept, params) → expected violated ids. Checking is boolean expression evaluation, so this is again a thin specialization of the expression gate. |
# appended to workspace/concepts/gear.yaml
constraints:
- id: min_teeth
message: "A gear needs at least 3 teeth."
check: "param.teeth >= 3"
- id: outer_exceeds_root
message: "The tooth-tip radius must exceed the root radius."
check: "param.outer > param.root"
- id: positive_root
message: "The root radius must be positive."
check: "param.root > 0"Each constraint is {id, message, check}. check truthy ⇒ satisfied; not truthy
⇒ violated (the message explains why, the workspace-convention English).
constraints: is optional — a concept with none simply has no invariants.
- Declare — constraints in the pack; the registry exposes them.
- Check — the checker evaluates each
checkover the instance's current params and collects the violations[{id, message}]. - Surface —
active_document.selected_conceptgains aviolationslist, so the Concepts panel shows a warning block under the params when it is non-empty. No op-log verb — checking is read-only and recomputed from params on each render.
- the constraint corpus (§11.1 decision 5) — the cross-language equivalence gate;
- a checker unit test — valid params ⇒ no violations; an out-of-range param ⇒ exactly the right constraint id;
- a view test —
selected_concept.violationslists the violated constraints (id + message) for a single selectedGenerated.
Enforcement (blocking or auto-clamping an edit), cross-instance / geometric
constraints beyond param (e.g. "no self-intersection", relations between
elements), severity levels (error vs warning), and bidirectional solving /
IK (VISION.md §6.2) are all later — v1 is a declarative invariant + an advisory
checker, nothing more.