Decisions
Where SYMBOL_INTERFACE.html (the guide) is silent, decisions are recorded here, one entry each:
what was decided, why, and what it costs if wrong. Concerns about the guide itself are recorded as
C<n> entries; the guide is never edited.
D1. Symbol carries a trailing lint_allow field
Decided: Symbol(name, kind, status, reference, elements, ports, nodes, paths, anchors, slots,
pole_pitch, lint_allow). The guide's signature stops at pole_pitch.
Why: lint(symbol) must apply exemptions and report allow-unknown / allow-unused, and the
resolved JSON carries lint_allow ("same schema as the source"), so a loaded bundle needs it.
Cost if wrong: one extra trailing field with an empty default; consumers that construct Symbol
positionally up to pole_pitch are unaffected.
D2. Purity split: file IO lives in build.py, pure helpers in extra modules
Decided: every module is pure except build.py, which holds all file IO, including load_library
and load_bundle (the guide lists only write_build and stale_build there, but says build is
the only impure module and both load functions read files). Everything they do besides reading is
pure: load.py parses text and decoded data, serialize.py renders canonical JSON, gallery.py
renders the gallery README. lint/ is a package with one module per rule group.
Why: the purity gate and the guide's "only impure module" rule cannot both hold otherwise.
Cost if wrong: moving two functions and re-exporting them.
D3. The pure core never raises; problems are Findings
Decided: parsing, validation and resolution return findings (schema, metadata, id-format,
part-*, export-unknown) instead of raising. LibraryError (carrying the findings) is raised
only by load_library in build.py. repeat states its preconditions with deal.pre.
Why: CLAUDE.md requires every module-level function in a pure module to be @deal.pure and not
raise; findings are also how the guide says composition rules are reported.
Cost if wrong: a consumer wanting exceptions wraps the result.
D4. The JSON Schema is structural only
Decided: schema/symbol.schema.json checks types, required keys, enums, array shapes and "exactly
one shape key". It does not check the id pattern, the grid, positive sizes or the number pattern.
Why: those are lint rules (id-format, off-drawing-grid, degenerate, metadata); enforcing
them in the schema would make those rules unreachable from a file. The schema carries no $id,
because the guide names no canonical host for it; a consumer refers to the file by path.
Cost if wrong: a stricter schema is a compatible tightening later; an $id is one added key.
D5. A port on a filled circle or filled closed outline counts as "on geometry"
Decided: for port-off-geometry, "on a circle" includes any point of a filled circle's disk, and
"on a closed outline" includes any point of a filled polygon's area.
Why: the guide's connection point S00016 has its ports at the centre of a filled dot and is stated
to pass port-off-geometry without an exemption.
Cost if wrong: S00016 would need one more lint_allow entry.
D6. lint takes a base-orientation symbol and evaluates 8 orientations itself
Decided: lint(symbol) expects a symbol in base orientation. Orientation-invariant rules run once
(orientation = None); orientation-dependent geometric rules (port-on-body-edge,
port-lane-clear, slot-overlap-body, slot-overlap-slot) run on orient(symbol, o) for all 8
o and report the orientation. Base-orientation-only rules (through-axis, pitch-overflow)
run once. A lint_allow entry exempts a rule in every orientation and counts as used if the rule
fired in at least one.
Why: the guide requires "all 8 orientations" and gates "lints clean in all 8 orientations" without
saying who loops.
Cost if wrong: a data repo loops orient itself; findings would then carry no new information.
D7. Bundle keys are exactly the guide's; a loaded bundle has a bare Library
Decided: bundle.json is {"schema": 1, "standard": ..., "symbols": {...}} and nothing else.
load_bundle returns Library(standard, title=standard, number_pattern="", symbols).
Why: the guide fixes the bundle format exactly; title and number_pattern come from
library.toml, which the bundle does not ship.
Cost if wrong: two extra keys in the bundle and one in load_bundle.
D8. The bundle package name is the normalised standard
Decided: src/<package>/bundle.json, with <package> = the standard lowercased with every
non-alphanumeric character removed (IEC 60617 -> iec60617, ISA 5.1 -> isa51).
Why: write_build(library, root) gets no package name and library.toml has no key for it.
Cost if wrong: an optional package key in library.toml; a rename of the output directory.
D9. Small API shape decisions
Slot.boxis a(width, height)tuple;slot_box(slot)returns the resolvedBox.Texthas noanchorfield (guide: always middle-anchored, always upright) and carriesweightso an element round-trips through the file format losslessly; it is not rendered with it.to_svghas nomarginparameter (the guide's signature has none); the margin is a constant.- The old
Anchortext-anchor enum is removed;Anchoris the composition attachment point. InvalidSymbolErrorandDuplicateSymbolErrorare removed with the registry they served.UnknownSymbolErrorstays forLibrary.get.Libraryis a frozen dataclass that holds a mutableMapping, sohash(library)raisesTypeError. A library is never used as a key or in a set, so it stays as it is; the mapping a caller hands in stays theirs to change. Cost if wrong:MappingProxyTypeinload_libraryandlibrary_from_bundle.
D10. Fixture layout
Decided: tests/fixtures/guide/ is a mini data repo (library.toml, symbols/) holding the five
guide examples verbatim plus S00171 (the qualifier the push-button example uses; the guide names
it but does not define it, so its geometry is ours). A test compares each verbatim file with its
code block in the guide. tests/fixtures/broken/<rule-id>/ holds one broken mini library per lint
rule (symbols/S00001.toml is the failing file); tests/fixtures/broken/library.toml is the
default library config.
Why: metadata needs a file stem that matches a number pattern, and composition rules need
sibling files, so one directory per rule is uniform.
Cost if wrong: renames.
D11. Resolved JSON is explicit
Decided: resolved JSON writes every element key that has a default (weight, style, closed,
fill) and every port description, so a consumer in another language needs no defaults. Keys
whose default is "absent" (potential, edition, form, pole_pitch, lint_allow when empty)
are omitted.
Why: the guide says consumers "never implement composition" and should be about thirty lines.
Cost if wrong: byte-level diff of every generated file.
D12. Composite nodes
Decided: a composite's own nodes entries may only restate an inherited node (same port set) to
add a potential; any other node declaration in a composite is a node-invalid finding.
Why: the guide defers merging parts' nodes (section 14) and is silent on composite nodes.
Cost if wrong: one more inheritance rule.
C1. Guide example S00254 (push-button) fails pitch-overflow as written
Concern: S00254 declares no pole_pitch, so its pitch is 4. Its flattened body extends from
x = -2.5 or further (the dashed link from the contact's link anchor at (-0.5, 0) runs 2 M west to
(-2.5, 0), and the actuator sits at that end), and the inherited marking slots reach x = 1.75. The
extent from section 8 point 6 is therefore at least 4.25 M, more than 4, so pitch-overflow fires.
Computed with the fixture S00171 (its geometry is ours, D10; its push bar runs 0.75 M west of its
link anchor): the body box spans x from -3.25 to 0, the slot boxes other than tag reach
x = 1.75, and the extent is exactly 5.0 M against the pitch of 4 (tests/test_guide_lint.py
asserts this). With pole_pitch = 8 the example lints clean.
The guide says the five examples "must load and lint clean" and that only the four atomic ones were
machine-checked. Implemented as written; the guide-verbatim fixture is expected to produce exactly
that one finding, and a variant with pole_pitch = 8 is clean. Owner to decide: add pole_pitch = 8
to the example, or shorten the link.
D13. Box and angle edge cases the guide leaves open
Decided: a symbol without elements has the body box Box(Point(0, 0), Point(0, 0)), so its keep-out
box still contains the origin; an element with no points (a polyline with none) has that same origin
box as element_box but adds nothing to body_box; orient normalises arc angles into [0, 360)
under every orientation, R0 included, so R0 is the identity only for symbols whose arcs are
already normalised.
Why: body_box must not raise on malformed symbols (the linter reports them), and section 3 says
angles are normalised to [0, 360) without exempting R0.
Cost if wrong: a slot-only symbol's keep-out box grows to the origin; a symbol with an arc at
-90 or 360 reads back as 270 or 0 after orient(symbol, R0).
D14. File-format gaps the schema had to close
Decided: the schema (and validate) require height on a text element; fill on a circle,
closed and fill on a polyline, weight and style are optional with the defaults the model
already has. A file must contain elements, parts or both. ports is never required. slots is
required when kind = "symbol", as section 4's key table says (an empty [slots] table satisfies
it; a symbol whose [slots] lacks tag or a marking still reads and trips the slot-missing lint).
Reversal: the first version of this entry made slots never required, which deviated from the guide
without need; the schema now has if kind = "symbol" then required slots, and validate the same.
schema, pole_pitch and repeat accept integral floats (8.0), as JSON Schema's integer does.
repeat >= 1 is not a schema rule; the resolver must report it. Unknown keys are errors on every
table; the only extra top-level keys are the resolved form's body_box and keepout_box.
Why: the guide shows height in every text example and gives it no default; the rest follows
section 4 and D4.
Cost if wrong: height optional (default 1) is a compatible loosening; the rest is unchanged.
D15. Reading reports through schema findings with pointer locations
Decided: every reading problem is a Finding(rule="schema", ERROR): TOML syntax errors, schema
violations, and library.toml problems (missing or non-string standard, title,
number_pattern; a pattern that does not compile). location is a JSON Pointer such as
/ports/0/dir, with ~ and / in keys escaped; a problem with the whole file has location =
None. library.toml may carry unknown keys; they are ignored.
Why: the guide names only the schema rule for "the file fails validation" and says nothing of the
config file; one rule keeps LibraryError messages uniform.
Cost if wrong: a separate config rule id and a check that ignores unknown keys becoming strict.
D16. What repeat does where the guide is silent
Decided: the result lists every node explicitly, one per pole, with the ports prefixed and
potential kept; a port in no declared node becomes a one-port node (nodes_of), so a symbol
without nodes still yields explicit ones. A slot id starting marking. is renamed per pole
(marking.in -> marking.2.in, marking.c.in -> marking.2.c.in); tag, value and any other
slot id come from pole 1 only, unprefixed. The dashed link line is normal weight and is appended
after every pole's elements; lint_allow is carried over unchanged.
Why: the guide names tag and value and the marking. prefix, and says "nodes and paths repeat
per pole" without saying how implicit nodes are treated.
Cost if wrong: an unknown slot id that should repeat is lost on poles 2..n; resolved JSON of a
repeated symbol lists nodes that were implicit before.
D17. Structural problems validate cannot see are schema findings from the resolver
Decided: validate does not know a composite from an atomic file, and repeat states preconditions
as contracts, so resolve_library reports these itself, all as rule schema: repeat below 1,
above 64 (D22), or on a part whose file has no through path (at /parts/<i>/repeat); ports
as an array of tables in a file with parts (/ports); ports as a rename table, or a slot
given as a string reference, in a file without parts (/ports, /slots/<id>); two parts with the same as
(/parts/<i>/as, the later one). A file with any of them has no symbol.
Why: the guide names one rule, schema, for "the file fails validation", and repeat must not be
called in violation of its contracts.
Cost if wrong: a separate rule id and a fixture for it; a second validate pass in the schema.
D18. What part-anchor covers, and where it points
Decided: besides an unknown anchor and anchors that do not face each other (dA == -dT after
orient), part-anchor covers the malformed placements: to that is not part.anchor, names no
earlier part, or names an anchor the part lacks (/parts/<i>/to); an unknown attach anchor or
attach without to (/parts/<i>/attach); via without length > 0 (/parts/<i>/via); a part
after the first with neither or both of attach and at, at together with to, length or
via, and to, length or via without attach (/parts/<i>). The first part with none of
these sits at the origin. A negative length is placed as written (the guide only bounds it with
via). A target that is an anchor of a part that itself failed to place adds no finding.
Why: the guide gives the rule one line; every one of these makes the placement undefined.
Cost if wrong: a finding moves to another rule or location.
D19. Findings are keyed by file stem, never cascade, and sort deterministically
Decided: Resolution.symbols and .findings are keyed by file stem (the number, when the
metadata rule is clean); findings lists only stems that have some. A symbol with only metadata
or id-format findings still resolves. A file whose part file is unknown, in a cycle, or itself
failed has no symbol, and only the file at fault reports: its users get no finding of their own.
part-cycle is reported on every file of the cycle, at the part that leads on, so the result does
not depend on which file the walk reaches first. Order: stems sorted, then the finding order of
D34, generation order after that.
metadata uses re.search with the config's pattern, since the guide's pattern anchors itself; a
pattern that does not compile matches nothing.
Why: findings on a user of a broken file only repeat what the broken file already says.
Cost if wrong: users of broken files also list a finding; a stricter fullmatch for patterns
without anchors.
D20. Composite inheritance where the guide is silent
Decided: a composite's own nodes entry that restates an inherited node (same port set, in the
composite's port ids) replaces it and carries its potential, or the inherited one if it has none;
any other own node is appended, so the linter's node-invalid reports it (D12). A part port
exported under two names yields two ports at the same place in one node; paths use the first name.
When several inherited paths are through and the composite redeclares none with through = true,
the result has no through path (the linter then warns through-missing). Inherited paths keep
part order; the composite's own paths follow, and an own path removes the inherited path between
the same two nodes. Elements are ordered part by part, a part's via link line before its own
elements, then the composite's own elements. The composite keeps its own lint_allow,
pole_pitch, status and kind and drops its parts' (resolve._compose): guide section 7's
inheritance list covers ports, nodes and paths, anchors, slots and elements and is silent on these
four. Section 7 also says the composite "must redeclare the one that stays" when several inherited
paths are through; that is enforced only as the through-missing warning, which is what the
guide's own rule table makes of it (a warning), not as an error.
Why: the guide states the rules of inheritance, not these corners.
Cost if wrong: an order change in resolved JSON; nothing else.
D21. load_library reports what it read, by file
Decided: load_library reads library.toml and symbols/*.toml as UTF-8, files in stem order; a
file that cannot be read (missing, unreadable, not UTF-8, a directory named x.toml) and a
missing symbols/ directory are schema findings located at the file or directory name. It
raises one LibraryError holding every finding: library.toml first, then per file, each message
prefixed <file name>: because a finding has no file of its own. Rule severity comes from the
registry (lint/registry.py, rule_finding): the eight resolver rules were registered with the
resolver, the linter's other 25 rules were added later, and all 33 are in one table.
Why: a data repo gate needs to know which file a finding is about.
Cost if wrong: a file field on Finding instead of a message prefix.
D22. Every number is bounded, repeat is capped, the walk has no recursion limit
Decided: every numeric value in a source or bundle file must be finite with an absolute value of at
most 1e6 module units (1e6 M is 2.5 km). Both validators enforce it and stay in agreement: the
hand-written validate tests not (-1e6 <= x <= 1e6), so NaN and infinity fail; the JSON Schema
has $defs/number and $defs/integer (used by every number and integer key, pole_pitch and
repeat included) written as not { anyOf: [exclusiveMinimum 1e6, exclusiveMaximum -1e6] },
because jsonschema's maximum lets NaN through and this form does not (JSON has no NaN; a TOML
file does). The schema constant and the enum-like values are unaffected. The resolver also
rejects a part repeat above 64, like repeat below 1: a schema finding at
/parts/<i>/repeat, part not resolved; a direct call of repeat(symbol, n) is unchanged. The walk
over part files uses an explicit stack, so a chain of composites of any depth resolves, with no
depth cap; a part-cycle message shows the first 8 files of a cycle and ... when it is longer.
_matches in lint/file.py treats a pattern that raises OverflowError or RecursionError as
matching nothing, as it does one that is not a valid regex.
Why: with bounded input all later arithmetic (orient, boxes, the coming overlap tests, placement
sums) is total, so the pure core can keep its promise never to raise (D3); validate accepted
integers of up to 4300 digits, which overflowed int + float in placement and made repeat hang.
64 poles is far beyond any real switching device.
Cost if wrong: a larger bound is a one-constant change in load.py and the schema; a symbol beyond
2.5 km or a 65-pole device cannot be written until then.
D23. How lint runs the rules, orders findings and treats exemptions
Decided: a rule is one function (Symbol) -> tuple[Finding, ...], one Rule(id, severity, group,
orientation_dependent) row in lint/registry.py and one entry in CHECKS in lint/__init__.py.
run_checks calls an orientation-dependent rule on orient(symbol, o) for the 8 orientations and
stamps o on its findings; any other rule runs once, with no orientation. RULE_IDS and the sort
order derive from GUIDE_ORDER, the 33 ids in the guide table's order (a test reads the table out
of the guide and compares). Findings sort by rule order, then orientation (none first, then R0 to
MR270), then location with numbers as numbers (anchors[2] before elements[0], elements[2]
before elements[10]), then generation order. lint_allow removes a rule's findings, of both
severities and in every orientation, and an entry counts as used when the rule fired in any of
them before the removal. allow-unknown (a rule id outside the 33, or a reason that is empty
or only whitespace; two findings if both) and allow-unused (a known rule that did not fire) are
added afterwards, so they cannot be exempted; an unknown rule is never also unused; a known rule
that does not apply to the symbol (slot-missing on an element) did not fire, so it is unused;
duplicate entries for one rule are each judged alone; an entry with an empty reason still exempts
and is reported once. A rule only the resolver reports (schema, metadata, part-unknown,
part-cycle, part-anchor, part-port-unexported, export-unknown; RESOLVER_RULES in
lint/registry.py) never fires in lint and the resolver does not read lint_allow, so an
exemption naming one changes nothing and is not reported as unused (C3). id-format is not in
that set: lint runs it on a symbol's own ids, so an id-format exemption is judged like any
other (one aimed at a part id is reported unused, since only the resolver reports those). The
package exports the function lint, which shadows the subpackage as an attribute of
graphical_symbols: write from graphical_symbols.lint import ..., never
import graphical_symbols.lint as m.
Why: the guide says who loops over orientations nowhere (D6) and leaves the exemption corners open.
Cost if wrong: a change in ordered, in exempt or in the two exemption functions; a rule row and
a CHECKS entry per rule instead of one registration if the two are ever merged.
D24. What the geometry, id and anchor rules count
Decided: off-drawing-grid tests exactly (value * 8 is integral) every line, polyline and
closed-outline point, circle and arc centre and radius (not arc angles), text position and height,
anchor position, slot point and slot box side; port positions are port-off-wiring-grid's. The
exact test is deliberately not units.on_grid, which is public and tolerant (1e-9): a consumer that
pre-checks with on_grid can accept a value the rule then rejects. It
gives one finding per element, anchor or slot, naming every value off the grid, located at
elements[i], anchors[i] or slots.<id>. degenerate also covers a circle or arc radius of
zero or less, an arc whose angles are equal modulo 360, and an element equal to an earlier one,
which is reported at the later copy (three copies give two findings); "equal" is the dataclass
equality of the whole element, so weight and style count (a reversed line or a dashed copy is
not a duplicate); an element can have more than one finding. Symbol-level id-format checks port
ids and anchor ids (ports[i], anchors[i]; part ids stay the resolver's).
point_on_geometry(point, elements, *, endpoints_only) in lint/geometry.py is the one test of "on geometry", tolerance 1e-9: on a line or polyline
segment, on a circle or arc curve (an arc's ends included), on a closed polyline's outline, inside
a filled circle or filled closed polyline (D5); text is not geometry. anchor-off-geometry uses
it whole; endpoints_only keeps only the ends of a line and of an open polyline, for
port-off-geometry. A polyline of one point is a dot; a filled open polyline has no area.
Why: the guide names the quantities but not the granularity of a finding or the corners.
Cost if wrong: locations and message text change; endpoints_only moves if ports are meant to
count a polyline's interior vertices.
C2. id-format and repeat disagree about port ids
Concern: section 3 says port ids match ^[a-z][a-z0-9_]*$ and the dot is reserved for namespacing,
section 7 says repeat makes the ports 1.in, 2.in, and section 12 says every symbol with a
through path lints clean as repeat(symbol, 3). 1.in does not match the pattern, so the three
cannot all hold as written. repeat also accepts its own result (it has a through path), and
prefixes <k>. again, so repeat(repeat(S, 2), 2) has the ports 1.1.in, 1.2.in, 2.1.in,
2.2.in. Implemented: a port id is valid if it matches the pattern or is one or more pole numbers
([1-9][0-9]*), each followed by a dot, and then such an id ((?:[1-9][0-9]*\.)+ and the
pattern, matched in full); anchor ids and part ids get no such form. A source file with an
authored port 2.in or 1.2.in therefore passes id-format. Owner
to decide: keep this, or have repeat be exempt from the id rule some other way (a lint that knows
a symbol was repeated, which its signature cannot).
D25. An arc with a non-finite angle counts as its centre in the boxes
Decided: element_box and body_box reduce an arc's start and end angle modulo 360 before they
compute its extent, and an arc whose start or end angle is NaN or infinite has no swept extent, so
its extent points are just its centre. Finite angles behave as before.
Why: arc_point calls math.cos on the angle, which raises for infinity; body_box is called by
the linter on hand-built symbols and must be total (D3, D22).
Cost if wrong: a garbage arc shrinks a box to a point instead of being an error; files cannot carry
such angles (D22), so only hand-built symbols are affected. arc_point used to raise for an
infinite angle; since D32 it returns the centre, and the rule above stays in the boxes.
D26. How overlap is computed and what the wire lane is clipped to
Decided: lint/overlap.py tests a shape against an open rectangle (a slot box or a lane). A line and
each segment of a polyline are tested exactly, with Fractions, so touching is decided without
rounding; a circle is tested by squared distances (outline: nearest point closer than the radius,
farthest point farther; filled: nearest point closer), which are exact on the grid; an arc is cut at
the angles where its circle crosses the four lines of the rectangle and at its ends, and the middle
of each piece must be more than 1e-9 inside every edge (only the arc uses the tolerance). A closed
polyline is its outline, and if filled also its area by the even-odd rule (an outline that misses
the rectangle leaves the rectangle wholly inside or wholly outside, so its centre decides); an open
polyline is only its segments, however it is filled; a polyline of one point is a dot. A circle or
arc whose radius is not positive draws nothing. A text element is element_box shrunk by 1e-9 on
every side, because the width factor 0.6 is not a binary fraction and an edge that only touches
must stay touching. A rectangle with no area is overlapped by nothing, and any non-finite number
makes a shape overlap nothing. The lane of a port is the rectangle from the port to the edge of a
frame: the keep-out box united with every port position, grown by 1 M on every side (per symbol).
Why: the guide defines overlap but not the arithmetic, and the lane is unbounded.
Cost if wrong: the frame margin is one constant; a 1e-9 tolerance on segments would be a change in
one function. Consequence to know: an element that overlaps a lane always also pushes the body box
past its port, so port-on-body-edge fires with port-lane-clear; a text element does the same, because its box
is part of the body box too. Only a slot box can break the lane on its own, which is why the lane
fixtures use slot boxes.
D27. What the Ports rules count
Decided: port-duplicate-id, port-off-wiring-grid, port-off-geometry, port-on-body-edge and
port-lane-clear give one finding per port at ports[i] (the later port for a duplicate id); the
lane finding names the first three offending elements or slots and counts the rest.
port-spacing and port-position-shared give one finding per later port, naming the first earlier
port it is out of step with (same dir, gap not a multiple of 2 M; same position, different node).
The grid and spacing tests are exact (% 1, % 2), so NaN and infinity fail them.
port-off-geometry is point_on_geometry with endpoints_only=True (D24): a bend of an open
polyline is not an end. port-on-body-edge compares exactly and requires the other coordinate to
lie in the side's closed extent. Nodes come from nodes_of; a port id listed by two nodes belongs
to the first, and two ports with one id are one node. port-on-body-edge and port-lane-clear are
orientation dependent (text boxes and slot boxes do not turn); the other five are invariant, which
a property test checks for random grid-multiple symbols in all 8 orientations.
Why: the guide names the rules, not their granularity.
Cost if wrong: locations and message text change; a per-pair report for spacing and shared
positions would list more findings for three or more ports on one side.
D28. What the Connectivity rules count
Decided: nodes come from nodes_of (D27); a port id listed by two nodes belongs to the first.
node-invalid gives one finding per offending name at nodes[i]: an unknown port, or a port that
an earlier node lists already; a port named twice inside one node counts as listed twice.
path-invalid gives one finding per path at paths[i], the first of: an unknown port (both
named), two ports of one node (from == to included), a repeat of an earlier path between the same
unordered pair of nodes (the earlier one is named). A path that joins one node to itself is not an
"earlier path". through-count gives one finding per through path after the first, at the later
path. through-axis checks every through path on its own, whatever through-count says: a is
the to port's y, which must be positive and whole (a > 0, a % 1 == 0), from must be at
(0, -a) facing N and to at (0, a) facing S, all compared exactly (-0.0 is 0); a path that
names an unknown port has no through-axis finding (path-invalid reports it), and the first port
with an id is the one used. through-missing is one finding for the whole file (no location):
kind = "symbol", at least two nodes in nodes_of, no potential on any node, no through path.
port-isolated gives one finding per port at ports[i]: the port is named by no path (valid or
not), no declared node lists it together with another existing port, and its (first) node has no
potential. The fixtures of the Ports and Composition rules gained a potential on their single node
or a conductor path so the new warnings do not fire on them; the through-count fixture tolerates
path-invalid, because two through paths that are both right on the axis join the same two nodes.
Why: the guide names the rules, not the granularity, the corners, or which of two overlapping
rules reports a malformed path.
Cost if wrong: locations and message text change; a per-symbol through-count finding instead of
one per extra path.
D29. What the Slots rules count
Decided: slot-missing (kind symbol only) gives one finding per missing slot, at that slot
(slots.tag, slots.marking.<port id>); ports that share an id need one marking slot, and
value is never required. slot-unknown-port (every kind) takes a slot whose id starts marking.
and looks up the rest of the id (only the first prefix is removed, so marking.1.in names the
port 1.in); an empty rest is unknown. slot-overlap-body gives one finding per slot at
slots.<id> naming the first three overlapped elements, using overlaps_rect (D26).
slot-overlap-slot gives one finding per overlapping pair, at the later slot, naming both; boxes
without area overlap nothing. pitch-overflow (kind symbol only, base orientation, run once) has
two parts. A pole_pitch that is not a positive multiple of 4 is a finding at pole_pitch. For a
symbol with at least one through path the extent is max.x - min.x of body_box united with every
slot box whose id is neither tag nor value, and it must be at most the limit: pole_pitch if it
is positive (even when it is not a multiple of 4, so a pitch of 6 with an extent of 7 gives both
findings), otherwise 4. A symbol without a through path has no extent to check, as the guide
says "for a symbol with a through path". A through-path symbol with no elements has the origin
box (D13) as its body box. The two orientation-only fixtures rely on the box being upright: two
boxes 3 M wide and 1 M tall that stand 2 M apart vertically miss each other and overlap by 1 M once
the 2 M step is horizontal; a 1.5 x 3 M box beside a lead lies across it after a quarter turn.
Why: the guide names the rules, not their granularity or the corners.
Cost if wrong: locations and message text change; the limit for a pole_pitch that is positive
but not a multiple of 4 would be 4 instead.
D30. What resolved JSON writes where D11 and the guide are silent
Decided: ports, nodes, paths, anchors, elements and slots are always written, as [] or
{} when empty (slots because the schema requires it for kind = "symbol"; the rest so a
consumer never tests for absence). nodes follows nodes_of: declared nodes in source order, then
one node per port in none. A path always writes through. Slots are a table written in slot-id order,
so a JSON object read back gives the slots in that order; a symbol's slot order is not preserved by
the round trip, and neither is a node list that was implicit. Text is canonical as json.dumps writes it
with indent=2, sort_keys=True and ensure_ascii=False, after every whole float has become an
int (-0.0 prints as 0), so a point is four lines; control characters in strings are escaped, so
no carriage return can occur. Floats print in Python repr form (0.1, 1e-05), which is
valid JSON; grid values (multiples of 0.125) never take an exponent form. NaN and infinity have no JSON form: to_json writes NaN,
Infinity and -Infinity as Python's json does and does not raise; both validators reject them
(D22), so only a hand-built symbol can carry one.
Why: the guide fixes the text rules and the two computed keys and nothing else; json.dumps with
those options is what every language's standard pretty-printer produces plus a key sort.
Cost if wrong: a byte-level diff of every generated file; compact point arrays would need a
hand-written writer.
D31. What the build writes, what stale means, what a bundle may contain
Decided: serialize.py holds build_files(library) -> dict[str, bytes] (path relative to the
repo root with /, in path order), package_name (D8) and GENERATED_DIRS; gallery.py holds
readme. write_build and stale_build in build.py both call build_files, so they cannot
disagree; both return root / <relative path> (relative if root is), ordered by their
relative / path; an unreadable file or unwritable path raises OSError. write_build creates directories, writes bytes (never text mode, so no \r on Windows)
and deletes nothing. stale_build reports a missing file, a directory where a file belongs, a
file with other bytes, and any file, at any depth, under build/resolved, build/svg or
build/annotated that the library does not produce; it looks at nothing else. Text UTF-8 cannot
encode (a lone surrogate, which JSON can carry) is written as ?. The README has the standard, a
one-line note on status and a table (number linking to the resolved JSON, name, kind, status,
plain and annotated image); it uses standard and never title, so a library loaded from a
bundle builds the same bytes as the one loaded from the sources. A bundle is read by parse_json
and validate_bundle (both in load.py, pure) and library_from_bundle; validate_bundle checks
every symbol with validate and requires the resolved form (no parts, ports an array, slots
tables) and a key that is a file stem and equals the symbol's reference.number. A file stem is
letters, digits, _ and - in parts joined by single dots (5.1, ISO-14617-1.1): the guide
says the number is the file stem and later standards (ISA 5.1, ISO 14617) have dots in theirs, so
dots must load; a leading, trailing or doubled dot, a separator, a space or an empty name cannot
name a file safely. write_build and stale_build raise ValueError for a library with a
number that is not a file stem, before reading or writing anything (is_file_stem in load.py
is the one test).
Why: the guide names the two functions and the layout; the rest keeps output byte-exact and keeps
a bundle from a stranger from writing outside the repo.
Cost if wrong: a number with another character (a space, +) cannot be a bundle key (relax
_STEM in load.py); extras outside the three directories stay unreported (add a directory to
GENERATED_DIRS); a data repo that wants stale extras removed writes that loop itself.
D32. to_svg and arc_point are total; what the SVG does with input XML cannot carry
Decided: arc_point returns the arc's centre for a NaN or infinite angle (it used to raise from
math.cos), so to_svg never raises for a hand-built symbol. The boxes keep their own rule
(D25): an arc with a non-finite angle counts as its centre there even when its other angle is
finite, which arc_point alone would not give, so _extent_points still checks it. The number
formatter writes NaN and infinity as 0; finite numbers are unchanged (at most 4 decimals, no
-0), so a huge value such as 1e300 is written out in full. Text in <title> and in every <text>
goes through one function: a character XML 1.0 cannot carry (C0 controls except tab, line feed and
carriage return, lone surrogates, U+FFFE, U+FFFF) becomes U+FFFD, not dropped, so the text keeps
its length; &, < and > become entities; a carriage return becomes (a parser reads
\r back, no \r byte reaches the file and stale_build stays stable on every platform). A line
feed and a tab stay as they are, because XML keeps both in character data. The annotation grid is
left out when the view is not finite or would hold more than 10 000 dots (100 by 100 M, 250 mm
square at the default module), because a dot per module of a 1e300 symbol would never finish.
Why: CLAUDE.md wants every module-level function pure and non-raising; a symbol name can carry
"\r" or a NUL from TOML, and a build the sibling repo compares byte for byte must be well-formed
XML with LF-only bytes.
"Never raises" covers the typed inputs, floats: a Python int beyond float range (10**400) in a
hand-built Point is outside the contract (the fields are float) and unreachable from files
(numbers are bounded to 1e6, D22).
Cost if wrong: a garbage symbol draws garbage numbers instead of an error; a real symbol above
100 by 100 M gets no grid in its annotated SVG (one constant); a \r in a name shows as
in the file.
D33. What the annotated SVG draws, and the sample texts of the gallery
Decided: the guide says annotate=True draws "the grid, ports with ids and lanes, anchors, slot
boxes, and both boxes" and texts maps slot ids to sample strings. Drawn in this order, all inside
<g class="annotation">: the grid (grid, a dot at every whole module, D32); body-box (green)
and keepout-box (purple), both dashed 0.2/0.2, the keep-out one offset by 0.2 so they alternate
where they coincide; per slot, in slot-id order, a translucent slot-box (blue) and its id (slot-id,
size 0.3) just above the box; per anchor a teal diamond of half-size 0.2 (anchor) and its id
(anchor-id, size 0.4) centred 0.75 M out along the anchor direction; the lane of every port, a
red translucent strip 0.5 M wide from the port along its direction to the edge of the view; then
per port a red dot (port) and its id (port-id, size 0.6, centred 1 M out). The elements are
drawn first, under all of it. The view is the keep-out box united with every port label box, every
lane start (0.5 M across), every anchor marker and label and every slot label, plus 1 M on every
side; a port or anchor label box is 1 em wide per character (0.6 M per character at size 0.6, 0.4 M
at 0.4; the code keeps the half-width, 0.3 and 0.2 M per character), a little wider than the
glyphs, a slot label box is 0.6 em per character (0.18 M at size 0.3, the guide's own text width),
and each is one font size high. Plain, the view is the body box, plus the slot boxes of the texts
that are drawn. Sample text (<g class="samples">, drawn last, in both modes, class
sample-text, orange, no stroke, upright, font-family="sans-serif"): the font size is the
largest s <= 1 with s <= h and 0.6 * s * characters <= w, rounded down to 4 decimals; a
slot with no text, an empty one, a box without a positive size, or an id the symbol has no slot
for gets nothing. Vertical placement uses explicit baselines and no dominant-baseline, which
several renderers (MuPDF, cairosvg) ignore: E and W put the baseline 0.36 s below the slot point (a
capital is about 0.72 s tall, so it is centred on the point), N puts it on the point, S 0.72 s
below (the capitals hang from the point). The port id labels use the same 0.36 rule instead of
dominant-baseline="central", so they moved 0.216 M down, and so does a symbol's own Text
element (its baseline is at.y + 0.36 * height, so the capitals are centred in the text box the
guide defines; it was at.y, which put them above the box). Slots are drawn in id order because
resolved JSON keeps them in id order (D30): a symbol loaded from a bundle must give the same
bytes as the one from the sources. The build (build_files) draws build/annotated/<n>.svg with
sample_texts(symbol) from gallery.py: tag is -X1, value is 10 A, marking.<port id> is
the 1-based position of the port in symbol.ports (the first, for ports that share an id); the
plain build/svg/<n>.svg stays the bare definition.
Why: the guide names what is drawn, not how; the gallery must show a filled-in symbol, and a
sample text that does not depend on the symbol name keeps the files small and stable.
Cost if wrong: every annotated SVG changes (colours, sizes, label places are constants); a
different sample changes every annotated file; the 0.6 M per character width is the guide's own
text extent, and a real font is a little wider for some strings (marking.out at size 0.3 fits
its label box only about).
D34. One finding order for the resolver and the linter
Decided: finding_key in lint/registry.py is the one sort key for a Finding, used by
resolve_library (per file) and by lint.ordered. It sorts by the rule's place in the guide's
section 9 table (GUIDE_INDEX; a rule outside the table after all of them, by id), then by
orientation (none first, then R0 to MR270), then by location with numbers compared as numbers
(/parts/2 before /parts/10, elements[2] before elements[10]), then by generation order.
A data repo that concatenates LibraryError.findings and lint results and sorts them with
finding_key gets the table's order across both. The resolver used to sort by rule id in
alphabetical order (id-format before metadata, export-unknown before part-port-unexported).
Why: two orders for the same rules had no single rationale, and the table is the guide's own.
Cost if wrong: a change in one function; resolver findings of different rules swap places.
C3. lint_allow cannot exempt the rules the resolver reports
Concern: guide section 4 calls lint_allow "the only way to be exempt from a lint rule", section 9
says composition and file rules "are reported the same way", and section 8 says an exemption whose
rule would not have fired is itself an error. The resolver's rules (schema, metadata,
part-unknown, part-cycle, part-anchor, part-port-unexported, export-unknown, and
id-format of a part id) run in resolve_library, outside lint, which never reads lint_allow;
the three statements cannot all hold for them.
Implemented as: these rules are not exemptable. An entry that names one of the seven that only the
resolver reports changes nothing, and allow-unused does not fire for it, so a mistaken entry is
inert and does not add a second error next to the original one (id-format is still judged by
lint, see D23). Cost if wrong: applying lint_allow in resolve_library to the composite's own
findings, and reporting an entry there as unused when the rule did not fire; the two exemption
functions in lint/exemptions.py would then serve both places. Owner to decide whether these rules
are formally non-exemptable or the resolver should apply lint_allow.
D35. A standard with no letter or digit cannot be built
Decided: write_build and stale_build raise ValueError when package_name(library.standard)
is empty (a standard such as ... or -), before reading or writing anything, the same error
type as for a number that is not a file stem (D31). load_library and validate still accept such
a standard: the check is in the impure shell (_check_names in build.py), not in validate,
parse_config and the bundle validator, which would each need a rule for it.
Why: D8 makes the package the standard with everything but letters and digits removed, so an empty
package would write src/bundle.json instead of src/<package>/bundle.json, silently.
Cost if wrong: a schema finding on standard in parse_config and validate_bundle, so
load_library reports it, and a matching key in the JSON Schema.
D36. deal is the one runtime dependency, kept for the purity contracts
Decided: every module-level function in a pure module carries @deal.pure, checked at import
time, and deal is a runtime dependency of the package, not a dev dependency. No other
third-party package is added to src/; jsonschema stays a dev-only dependency of the schema
agreement test. This carries forward the reasoning of the Schematika v2 monorepo's decision 0008
from the interval this package spent as packages/graphical-symbols in that workspace, where a
boundary test enforced deal as the package's only allowed third-party import; that test does not
exist here, so this entry is the record now that the package is its own repository again.
Why: scripts/fp_purity_gate.py fails the build when a pure-module function is missing the
decorator, and the decorator is a runtime contract, so it cannot be moved to a dev group.
Cost if wrong: a second third-party import in src/ with no record here, or deal dropped
without a replacement for the purity contracts it checks.
D37. Build-time file access is confined to build.py, load_bundle and load_library
Decided: load_library, load_bundle, write_build and stale_build, and scripts/build.py,
read and write symbol source and build files as a build-time tool whose output is tracked data
(build/, src/graphical_symbols/bundle.json-shaped output for a consumer, the gallery). No
other module reads or writes a file; the pure core takes values and returns values.
scripts/fp_purity_gate.py exempts build.py by name, the same way tests/test_boundaries.py
in the Schematika v2 monorepo exempted it while this package lived there as
packages/graphical-symbols (that workspace's decision 0009, which also recorded
electrical_symbols reading its packaged bundle.json at import -- a fact about that package,
not this one, and no longer this repository's concern).
Why: symbol data is compiled by a developer running the build, not read at render time; the
toolkit itself never touches a file outside that build step.
Cost if wrong: a file read or write anywhere else in src/ needs a new decision record and a
fp_purity_gate.py exemption, which the gate's own can-fail test would then have to prove.
D38. The design guide is package data, read through importlib.resources
Decided: docs/SYMBOL_INTERFACE.html moved (git mv, history kept) to
src/graphical_symbols/docs/SYMBOL_INTERFACE.html. Hatch's wheel config already packages
everything under src/graphical_symbols/, so the file needs no new build-system entry: it sits
at the same relative path in the source tree and in the built wheel. A consumer reads it with
importlib.resources.files("graphical_symbols").joinpath("docs/SYMBOL_INTERFACE.html"), which
resolves the same way whether the package is installed from an editable source checkout, a git
dependency, or a built wheel. tests/test_guide_package_data.py proves the resource resolves and
carries the guide's own <title> (not a byte count, which would break on every edit to the
guide's prose and tests the wrong property).
Why: electrical-symbols compares its data byte for byte against this guide's worked examples
(its own D38, the "verbatim gate"), and until this toolkit left the Schematika v2 monorepo the two
packages shared one copy through a sibling directory. Now they are two repositories; the toolkit's
wheel never shipped docs/, so the sibling path stopped resolving. Moving the guide under the
package directory keeps exactly one copy, and it is the toolkit's own, which is the only copy a
downstream gate can check against and still mean anything (a vendored copy in the downstream
package would only prove agreement with itself).
Cost if wrong: a consumer with a stale copy or a broken resource path silently stops checking
against the real guide; test_guide_package_data.py's can-fail test is the guard against a
resource that resolves but is not this file's content.
D39. to_fragment: a placeable <g> fragment beside the standalone document to_svg
Decided: svg.py gains to_fragment(symbol: Symbol) -> str, returning _GROUP_OPEN, one
_element(e) per symbol.elements and </g>, joined by newlines with a trailing one -- exactly
the plain content group to_svg already builds, reusing _element and _GROUP_OPEN directly
rather than a second element-rendering path. No <svg> root, no <title>, no annotations, and no
sample texts: the function takes no texts parameter at all, so a slot's sample text can never
reach a fragment, not merely because no fixture happens to exercise it. It applies no transform,
scale or orientation of its own; the caller places it in its own coordinate system. Exported from
graphical_symbols.__init__ alongside to_svg.
Why: schematika_render (the Schematika v2 monorepo's render package) assembles a page out of
several placed symbols and needs each one as a reusable piece it positions and scales itself, not
a whole standalone document; the guide (SYMBOL_INTERFACE.html) defines only the standalone
to_svg document and is silent on an embeddable fragment.
Cost if wrong: reusing _element/_GROUP_OPEN means any future change to how an element renders
is automatically picked up here too; if a future edit ever duplicated that logic instead, the
fragment and the document's plain mode could silently drift apart. A wrong function name is a
rename in two files (svg.py, __init__.py) and the tests that check it.
D40. A line binds to a port with port; the file's schema integer does not step
Decided: Line gains a trailing field port: str | None = None, written by line = { ...,
port = "in" }. It is optional, additive and carries no default that changes an existing file's
meaning, so schema (the file-format marker, const 1 in the JSON Schema and
_schema_version) stays 1: nothing that validated before stops validating, and nothing that
validates now would have validated under the old rule differently. A new lint rule
lead-off-port (ERROR, Ports group, guide section 9) fires when the named port does not exist,
or when it does but neither end of the line is at the port's position, or the line does not run
along the port's facing axis (the coordinate perpendicular to the port's direction must be equal
at both ends) -- one finding per offending line, at elements[i]. The resolved JSON writes
port only when set (D11's "absent" pattern, like edition and pole_pitch). orient and
translate carry port along for free through dataclasses.replace. repeat prefixes a bound
lead's port with <k>., the same prefix it gives the port itself, so a repeated symbol's leads
stay bound. Composition namespaces a part's bound lead <part>.<child port> while placing it,
then rewrites that to the rename map's exported id once _export_ports has computed it; a lead
bound to a part port the rename map leaves out keeps the namespaced, unreachable id, which is
moot because _export_ports already fails the file on part-port-unexported for that port. A
part port the rename map exports under more than one new id ({ a = "c.in", b = "c.in" }) gives
a bound lead the first one the map declares: both exported ports still share the child's position
and direction, so the choice only decides which one the lead's own metadata names, never where
either one draws or routes; test_resolve.py's
test_a_lead_on_a_port_exported_twice_follows_the_first_declared_alias pins it.
Why: the package version (pyproject.toml, currently 0.1.2) is this repo's only versioning
mechanism with a minor/major/patch structure; the work order that asked for this expects the
release that carries it to be tagged a minor step (v0.2.0) on that scheme, not on schema,
which is a single flat integer with no such structure and no precedent of ever moving off 1. A
consumer on the old tag simply never emits port; a consumer that needs it takes the new tag,
same as any other public addition here (CLAUDE.md's "coordinated change"). The port-lane-clear
guarantee already makes a wire reaching a port along its own axis safe at t = 0; lead-off-port
only makes sure a claimed binding actually is that wire, so a downstream renderer (or anything
else) can trust element.port without re-deriving it from geometry.
Cost if wrong: an unmapped repeat or composition would leave element.port naming a port that
no longer exists after flattening, silently turning every downstream reader's binding lookup
into "unbound" instead of a loud finding; test_repeat.py and test_resolve.py each gained one
test against exactly that. If the owner ever wants schema itself to carry a minor/major split,
that is a bigger, separate decision: nothing here depends on it.
D41. Public, plain-markdown docs; v0.3.0; exact git tag; no PyPI for now
Decided (owner 2026-10-01): the repo's documentation is plain markdown in the repo, with no
documentation site: README.md (install, one worked example, the concepts), docs/GUIDE.md
(the concepts in more depth: how symbols are defined, built, linted and serialized) and
AGENTS.md (a pointer for any agent: CLAUDE.md holds the rules; the README and
docs/GUIDE.md serve users). Every public name in __all__ carries a short Google-style
docstring. The Python examples of the README and docs/GUIDE.md are run by
tests/test_readme.py, so they cannot drift. The docs may name the toolkit's users; the rule
that nothing names a Schematika package covers code, tests and scripts (designer 2026-10-01).
The release that carries this is v0.3.0; consumers install by exact git tag (uv add
"graphical-symbols @ git+https://github.com/OleJBondahl/graphical-symbols@v0.3.0"). The package
is not published to PyPI for now.
Why: the toolkit is consumed by an exact tag already (D38's package data, Schematika's pin), a
site or an index would add upkeep with no reader today, and the tests keep the examples true.
CLAUDE.md stays the one home of the agent rules: a draft AGENTS.md that restated them
disagreed with CLAUDE.md about docs/ on the day it was written.
Cost if wrong: if a consumer outside the tag workflow appears, PyPI publication is a later,
separate decision (trusted publishing and a release workflow); nothing here blocks it. The docs
are only as true as their tests: their prose is not run.
Open questions for the owner
- C1 (S00254 fails
pitch-overflowas written): addpole_pitch = 8to the guide's example or shorten the link. The realiec60617S00171may change the extent; report the outcome there. - C2 (
id-formatversusrepeat's<k>.port ids): keep the prefix form permanently, or change the guide's id pattern. - C3 (
lint_allowand the seven resolver-only rules): formally non-exemptable, or applylint_allowinresolve_library. - Merging
feat/toolkitintomainis the owner's call; nothing has been merged.
Known limits, decided not to bound
Each is reachable only from the data repo owner's own files, and the guide is silent. Cost if
wrong: add a bound and a schema finding, as D22 did for numbers and repeat.
resolve_library: nested composites with two parts each fan out as 2^depth elements (depth 16 is about 2 s; about 40 tiny files would not finish).- A catastrophic-backtracking
number_patterninlibrary.toml(for example(a+)+$; the time grows by 4 per 2 characters) can hangre.search. It is the only non-terminating path in the core.^S\d{5}$-style patterns are safe. - Lint cost is quadratic in the number of poles of a
repeated device (64 poles about 21 s, 24 poles 2.8 s;dealtracing is about 2.2 times of that). The gates need onlyrepeat(s, 3). load_libraryaccepts a file stem ornumber_patternthat yields a number failing the stem regex (a b.toml, or a Windows device name such asCON);write_buildandstale_buildthen raiseValueError(D31), andbuild/svg/CON.svgwould misbehave on Windows.validatestill raises on a mapping key that is a 4300-digit integer (unreachable from TOML or JSON); some private module-level tables (orient._PARTS,load._ELEMENTS,svg._SAMPLE_ALIGN,geometry._RIGHT_ANGLE_COS_SIN) are mutable dicts.- In the annotated S00227 the sample text
-X1covers the start of thelinkanchor label, because the guide'stagslot ends 1 M west of that anchor. Cosmetic, gallery only.
What ../iec60617 must do because of these decisions
- Add
.gitattributeswith* text=auto eol=lf; without it, on Windows withcore.autocrlf=true, tracked build files become CRLF andstale_buildreports everything stale. - Run
write_buildagain: theTextbaseline fix (D33) changed the bytes of every SVG that contains aTextelement, so any committed build is stale. - Use
finding_key(graphical_symbols.lint.registry, D34) to sort the concatenatedLibraryError.findingsandlintresults; wrapload_bundleat import time forLibraryError. - Lint
repeat(s, 3)for every symbol with a through path, and report the C1 outcome.
D42. The toolkit is symdef, version 0.4.0 (SYMDEF SD1)
Decided: the distribution and the import are both symdef; the first release under the name is
0.4.0, continuing the line of graphical-symbols 0.3.0. The package folder is src/symdef, the
repository URL in pyproject.toml is https://github.com/OleJBondahl/symdef (GitHub redirects
the old URL). Earlier entries keep the old name as they were written.
Why: a short name that is free on PyPI, one name for repository, distribution and import.
Cost if wrong: another rename of the same shape; consumers change their pin and imports once more.
D43. requires-python = ">=3.14", tested on 3.14 in just ci (SYMDEF SD2)
Decided: the package supports Python 3.14 and later. Development stays on 3.15 (.python-version,
ruff and ty targets). just ci ends with just test314: uv run --python 3.14 --isolated pytest
--no-cov, a throwaway environment, so the promise cannot rot. Proof it can fail: a 3.15-only line
(lazy import json) in src/symdef/units.py makes the 3.14 run stop with a SyntaxError.
Why: more consumers install it; the code already ran on 3.14 (2261 tests pass).
Cost if wrong: raise the floor back to 3.15; the extra test run adds about 45 s to the gate.
D44. Published to PyPI by trusted publishing; docs on GitHub Pages (SYMDEF SD3, reverses D41)
Decided (owner 2026-10-07): a tag vX.Y.Z runs .github/workflows/publish.yml: it checks the tag
equals the package version, builds with uv build, and uploads with PyPI trusted publishing and
attestations, from the GitHub environment pypi, with id-token: write and no token secret.
.github/workflows/docs.yml deploys a minimal site (README, guide, decisions and the spec, built
by scripts/build_site.py with the dev dependency markdown) to GitHub Pages on the same tag.
Every action is pinned by full commit SHA with its version in a comment. A release is cut only
from a commit whose local just ci passed; no workflow runs the gate.
Why: consumers install by an exact PyPI version instead of a git tag.
Cost if wrong: delete the workflows and yank the release; the git tag install still works.
D45. Optional package key in library.toml (SYMDEF SD8, amends D8)
Decided: library.toml may carry package = "name", the data package's folder under src/
(letters, digits and _, not starting with a digit; anything else is a schema finding at
/package). LibraryConfig gains package, empty by default. Library does not change:
section 11 of the spec fixes its fields, and a test pins them. write_build and stale_build
already take the repo root, so they read the key from root/library.toml and pass it to
build_files(library, package); a root without a readable library.toml gets the folder D8 makes
from the standard. This is the fix D8 named for itself.
Why: a data package whose folder is not its standard's squashed name (electrical_symbols for
IEC 60617) needs a way to say so.
Cost if wrong: drop the key; the default rule is unchanged. A library built in memory and written
to a root with no library.toml cannot name its folder.
D46. A set declares its own vocabulary and required slots (SYMDEF SD6 and SD7)
Decided: library.toml gains [vocabulary] (path_kinds, potentials, links, each a list of
strings; a missing list is empty) and [rules] required_slots (for example ["tag",
"marking.<port>"], where <port> means one slot per port). The toolkit has no built-in list.
A symbol file using a path kind, node potential or via link not on its list fails to load with a
schema finding that names the word, the file and the line (the first line that quotes the word;
line 1 if the word is spelled with an escape). The check runs in load_library, which has the
file text. The closed enums PathKind and Potential are gone from symdef.__all__:
Path.kind is a str and Node.potential is str | None; JSON output is unchanged. The
structural check and schema/symbol.schema.json accept any string there. Vocabulary is a new
public name; LibraryConfig and Library gain vocabulary and required_slots (empty by
default); lint(symbol, required_slots=()) takes the duty list, and a bundle carries neither.
The pole-pitch multiple-of-4 rule stays fixed. [vocabulary] and [rules] are strict tables: an
unknown key is a schema finding on library.toml.
Why: a set for another domain (pipes, ISA instruments) needs its own words and duties; the
toolkit supplies mechanism, not policy.
Cost if wrong: a consumer of PathKind or Potential compares strings instead; a set that
declares nothing cannot use any path kind.
D47. The surface is every name the docs call; the schema ships in the wheel (SYMDEF SD10 and SD11)
Decided: RULES and finding_key join symdef.__all__ (and finding_key joins
symdef.lint.__all__), and a test reads the python blocks of the README and the guide and fails
for any from symdef... import name whose name is not in that module's __all__.
schema/symbol.schema.json moves to src/symdef/schema/symbol.schema.json, so the wheel carries
it, and gains "$id": "https://olejbondahl.github.io/symdef/symbol.schema.json"; the docs site
publishes the same file at that address. An editor that reads a #:schema line then completes and
checks a symbol file as it is typed. The schema checks structure only; the declared words stay a
load check (D46).
Why: a user should find every documented name on the surface, and an editor needs a schema it can
fetch.
Cost if wrong: move the file back; the $id is only an address.
D48. The newcomer's path: symdef init, build and check (SYMDEF SD9)
Decided: a command line symdef ([project.scripts], also python -m symdef) with three verbs
on a folder, each also a function: init(root) writes library.toml, one symbol
(symbols/resistor.toml) and .gitattributes with LF line ends, and refuses a folder that already
holds any of them; build(root) loads, lints and writes build/ and the bundle, raising
LibraryError and writing nothing if a file fails to load or lint reports an error;
check(root) returns the findings of the gates every set needs and never raises for a bad set.
The starter texts live in symdef.starter, are written by code and are loaded, linted and built
by a test, so they cannot drift from the format. Both starter files open with the #:schema line
of D47. The command exits 0, 1 when it found problems, 2 on bad usage, and prints each distinct
finding once.
check runs: files load and resolve (a number equals its file stem and fits number_pattern
there); every symbol lints clean, warnings included; a symbol with a through path lints clean as
repeat(symbol, 3); the build is not stale (stale-build); the packaged bundle holds the source's
symbols (bundle-differs) and they lint clean. These are the generic gates 1 to 6 of the first data
package's gate file. The bundle already lives in its package (D45), so no relocation exception is
left.
The functions are symdef.init, symdef.build and symdef.check. build would shadow the
module symdef.build, so that module is renamed symdef.files; it stays the impure shell with
project, cli and __main__, which scripts/fp_purity_gate.py lists.
Why: a newcomer needs a first working set in three commands, and a test needs one call for every
gate.
Cost if wrong: drop the verbs; the renamed module only matters to code that imported
symdef.build, which was not on the public surface.
D49. deal is removed; an AST purity gate replaces it (SYMDEF SD12, amends D36)
Decided (owner 2026-10-07): "remove deal from symdef, as we dont want to bug people with a non dev
dependency that is not integral, we should instead just write in the claude and readme that such
and such parts of the library is pure". deal leaves completely: no runtime or dev dependency, no
@deal.pure, no import deal. The wheel has no dependency.
scripts/fp_purity_gate.py now checks purity with the standard library's ast, over every module
under src/symdef/ except its IMPURE_MODULES (files.py, project.py, cli.py, __main__.py
and the top-level __init__.py). In a pure module it fails on a call to print, input, open,
exec or eval; an import of time, datetime, random, secrets, os, io, shutil,
subprocess, socket, tempfile, logging, sys or pathlib; a global or nonlocal
statement; and an assignment, del or mutating call (append, update, ...) that writes into a
name bound at module level. Every kind has a test that makes it fire. Two probes against the real
tree: a print() added to a pure function reports calls print(); a list appended to at module
level reports mutates module state. The README says which modules are impure and why.
The gate is weaker than deal.pure in one way: it cannot prove a function raises nothing, and
the runtime wrapper also caught that. repeat's two deal.pre preconditions are now a documented
ValueError; the tests that expected deal errors expect ValueError. It is stronger in two:
it also forbids file I/O and the clock, and it runs on code no test reaches. The totality of
rendering stays covered by tests/test_svg_total.py.
The TC001/TC003 ruff ignore stays. Its stated reason was deal's inspect.signature call,
but with deal gone ruff's unsafe fixes for those rules still fail: they write lazy import
statements, a SyntaxError on 3.14, and 35 test modules failed to collect. The comment in
pyproject.toml now says that.
Why: users should pay for nothing they did not ask for (lint was 2.6 times slower with the
contracts on, the import 13 ms slower, and the package carried a 1.9 MB dependency).
Cost if wrong: purity is proved statically only; a function that raises by accident is found by
tests, not by a wrapper.
D50. The docs site is built by Zensical; a gallery set ships in docs/ (SYMDEF SD13)
Decided: scripts/build_site.py (run by just site and by docs.yml on a tag) stages the README
as the home page, docs/TUTORIAL.md, docs/GUIDE.md, docs/DECISIONS.md, a generated gallery of
docs/tutorial-set/ and a generated API reference (every name of symdef.__all__ with its
signature and docstring) into the gitignored .site-src/, and builds it with Zensical (dev
dependency, zensical==0.0.68, the latest stable) in --strict mode, so a broken link fails the
build. The interface spec and the JSON schema are published at the site root, the schema at its
$id. tests/test_site.py proves a broken link fails the build; tests/test_tutorial.py runs
every symdef command, edit step and python block of the tutorial in an empty folder. The
markdown dev dependency goes. The gallery set is the six IEC fixtures, plus a gate valve, a pump
and a check valve drawn here (S90001 to S90003, path kind pipe, which shows a set declaring its
own word). S00254 carries pole_pitch = 8, the fix D-concern C1 names, because symdef check
refuses the fixture's pitch overflow; the other five files are byte-equal to the guide fixtures.
No page names a user of the toolkit. The README carries the provenance line for every IEC-style
example: original drawings, unverified. Both workflows run on ubuntu-24.04 with an exact uv
version.
Why: the site must cover everything a newcomer needs, with no other repository to read.
Cost if wrong: Zensical is pre-1.0; the staged Markdown also builds with MkDocs.
D51. The interface spec is amended in place for 0.5.0 (SYMDEF SD14)
Decided (owner C1): SYMBOL_INTERFACE.html opens with a dated change list and changes only
through a designer-approved spec, with a dated entry in that list and in this file. This amendment
records D45 to D49 in it: declared words and required slots (sections 5, 6, 7, 9, 11 and 12), the
API list (Vocabulary, init, build, check, lint(symbol, required_slots), Library's two
fields; PathKind is gone), the schema location, the generic layout and gates of section 12, no
runtime dependency, and a rewrite of sections 1, 12 and 13 that names no predecessor and no user.
Section 14's open items stay. The byte-equality test of the guide's code blocks passes against it.
Why: the spec was the one file still describing the first design.
Cost if wrong: revert the file; code and tests do not read its prose except the code blocks.
D52. The mm output: to_fragment_mm beside to_fragment (SYMDEF-MM)
Decided: to_fragment_mm is a new public function in a new module, svg_mm.py, beside
to_fragment. It is not a keyword on to_fragment: to_fragment's contract is module units with
the caller scaling, and one function with two units and a mapping argument would blur it.
Signature: to_fragment_mm(symbol, *, module_mm, weights_mm, dash_mm, font_family, font_mm,
cap_ratio) -> str. Every coordinate and radius is multiplied by module_mm. Stroke widths come
from weights_mm, one entry per Weight; a missing weight raises KeyError naming it. Dash
lengths come from dash_mm. Text is drawn at font_mm and ignores Text.height; its baseline is
y * module_mm + cap_ratio * font_mm / 2. The caller passes the font family. There is no
transform and no scale. to_svg and to_fragment bytes are unchanged; the golden fixtures under
tests/fixtures/svg_golden prove it. Release 0.6.0.
Why: the owner wants every size set in millimetres and rendered exactly, with nothing scaled.
Cost if wrong: the function is additive; remove it and its export.
D53. stretch(): a cut line moves everything beyond it
Superseded in part by D67 (0.10.0): "at most one per axis" no longer holds; D69 changes how slots move.
Decided: Symbol.stretch holds at most one Stretch(axis, at) per axis, written [[stretch]] in a
file and stretch in the resolved JSON (written only when set, so existing bundles keep their
bytes; the bundle schema version stays 1). stretch(symbol, axis, length, offsets=None) in the new
stretch.py moves every point whose coordinate is greater than at by length minus the body
extent (body_box) along the axis: line and polyline points, ports, anchors and slots; a circle,
arc or text lying wholly beyond the cut moves whole, one wholly before it stays, one across it
raises ValueError. Offsets place each named port, its bound lead and its marking.<port> slot at
a coordinate inside the stretched body on the 1 M wiring grid; the port must face across the axis
(N or S for x, E or W for y), and an offset naming no port raises ValueError too. A length
equal to the natural extent is allowed. The result drops that axis's stretch, so a frame is
stretched on both axes by two calls; no extra API. orient, translate and repeat carry
the field unchanged and do not raise; the stretch is applied before them. The lint driver does not
clear it: every rule ignores it except stretch-cut (D55), which runs once on the base symbol. A
composite does not inherit its parts' stretch; its own [[stretch]] is its own. crosses_cut is
the one predicate the function and the lint rule share.
Why: the owner chose the stretch value: layout passes one length and symdef builds the symbol.
Cost if wrong: the field, the function and the schema key are additive; remove them and the export.
D54. table(): rows with their own ports and slots
Decided: table(row, n, axis, head=None) in the new table.py returns n copies of row, copy k
(from 1) stepping by the row's body extent (body_box) along the axis. Ports, bound leads, nodes
and paths are prefixed <k>., through the helpers repeat uses (now public in repeat.py as
pole_elements and pole_parts). Every slot of every copy is kept: marking.<port> becomes
marking.<k>.<port>, any other <id> becomes <k>.<id>. No through path is needed. The cap is
repeat's 64, now the constant MAX_REPEAT in repeat.py (the resolver imports it). The result
keeps the row's pole_pitch, takes copy 1's anchors and has no stretch. A head keeps its own ids
and comes first in the lists; with a stretch on the table's axis it is first stretched to
n * step, the rows' total extent on that axis (amendment 2: this is how a cable head spans its
k columns stepping along x). A head with no stretch on the axis is not stretched. The head stays
where it is authored, relative to row 1, as built, and is never translated. repeat is unchanged.
Why: a table is cells with their own text, not poles of one device.
Cost if wrong: additive; remove the module and its export. A head that must move is a later keyword.
D55. Lint rule stretch-cut and the schema key
Decided: a second rule beyond the spec's 33, stretch-cut (after lead-off-port, D40): group
Geometry, severity error, not orientation dependent, so it runs once on the base symbol and
lint_allow may exempt it. It reports, at elements[j], a circle, arc or text a cut crosses
(crosses_cut, the predicate stretch itself uses), and, at stretch[i], a cut that does not satisfy
body_min <= at < body_max on its axis (a cut at the far edge moves nothing; one at the near edge
is legal) and every stretch after the first on one axis. The rule count is 35. symbol.schema.json
gains stretch, an array of {axis, at}, both required, axis one of x and y; the file's schema
integer does not step, as for port on a line (D40). Under orient the lint driver leaves the
field alone; no other rule reads it.
Why: a symbol whose cut crosses a circle cannot be stretched, and the error should surface at lint, not
at the consumer's build.
Cost if wrong: the rule is additive; remove its row, check and the 35 in the counts.
D56. Wire styles and wire()
Decided: a symbol set declares wire styles in library.toml as [[wire]] with id, weight
(normal or thick) and style (solid or dashed); the table is optional, and a missing key, an
unknown word or a duplicate id is a finding like those of [vocabulary]. WireStyle(id, weight,
style) is a new public dataclass; LibraryConfig and Library gain a last field wires.
wire(library, style_id, points) in the new wire.py returns an element-kind symbol of one open
Polyline of that style (name wire <id>, reference number wire.<id>), with no ports or slots;
points may be Points or (x, y) pairs. An unknown id raises KeyError naming it (as
to_fragment_mm does for weights), fewer than two points ValueError. The consumer reads a
bundle, not the TOML, so bundle.json carries an optional top-level wires array, written only
when the library has wires (existing bundles keep their bytes; the bundle schema stays 1), checked
by validate_bundle and read by library_from_bundle. A junction dot is an ordinary symbol.
Why: the owner ruled that wires are symdef functions and layout names only a style and points.
Cost if wrong: additive; remove the module, the dataclass, the field and the bundle key.
D57. Tint fill
Decided: Fill gains TINT = "tint". A file may write fill = "tint" on a closed polyline only;
on a circle or an open polyline it is a schema finding, and the JSON schema agrees (a separate
fill definition for polylines, with closed = true required beside tint). to_svg, to_fragment
and to_fragment_mm take a keyword-only tint: str | None = None, the colour string the caller
chooses, drawn as the fill of every tinted shape; with None a tinted shape draws fill="none".
The toolkit chooses no colour. So that no value can inject SVG, a colour must be #rgb,
#rrggbb or a letters-only name; anything else raises ValueError, whether or not a shape uses
it (check_tint in svg.py, shared by the mm output). Output bytes without a tinted shape do not
change; the golden fixtures prove it.
Why: the consumer passes the colour, as it passes slot texts.
Cost if wrong: additive; remove the enum member, the keyword and the check.
D58. The interface spec is amended in place for 0.7.0
Decided (owner C1; model D51): SYMBOL_INTERFACE.html gets a dated change-list row, 2026-10-15,
0.7.0, and the sections that define the new pieces change in place: section 4 ([[stretch]],
fill = "tint"), section 7 (stretch and table beside repeat), section 9 (the rule table and
count, 35), section 10 (the optional stretch key and the bundle's wires) and section 11 (the
API list, [[wire]] of library.toml, tint). docs/GUIDE.md gains "Stretching and tables";
its Python blocks run in tests/test_readme.py. The [[wire]] and wires bundle key are the
toolkit's own additions beyond the spec's data model, recorded in D56. The version bump, tag and
release are not part of this change.
Why: the spec is the one design document and must describe what the code does.
Cost if wrong: revert the file; code and tests read only its code blocks and the rule table.
D59. leads(symbol, ports)
Decided: a public free function in leads.py. It removes every Line whose port is not None
and not in ports; every other element stays in order, and so does every other field. A listed
id that names no port of the symbol raises KeyError naming it; a listed port with no bound lead
is fine. Ids are those the symbol holds, so 1.a after table. The caller passes the wired ports:
symdef knows no net.
Why: a router draws a stub only for a wired port, and a symbol's file cannot know which.
Cost if wrong: additive; remove the module, the export and the test.
D60. Style.DASH_DOT and dash_dot_mm
Decided: Style gains DASH_DOT = "dash_dot", accepted wherever dashed is: element style, a
[[wire]] style, the loader, the JSON schema and the serialized form. to_fragment draws module
units 1 0.25 0.0625 0.25 (the spec fixes none; chosen as a long dash, then a dot of a quarter of
a gap's length). to_fragment_mm gains dash_dot_mm: tuple[float, float, float, float] | None =
None after dash_mm; drawing a dash-dot element while it is None raises ValueError naming the
keyword, and a symbol with none never asks for it. Output without the style is byte-identical.
Why: the style words are the file's, the lengths are the caller's, as for dash_mm.
Cost if wrong: additive; remove the member, the keyword and the schema entries.
D61. muted
Decided: to_fragment and to_fragment_mm gain a keyword-only muted: str | None = None, the
colour of a symbol drawn as not installed (designer ruling, amendment 3: the caller's colour, as
tint is, not a fixed grey). It is checked like tint (check_colour, shared); a bad value raises
ValueError whether or not it is used. Muted draws the group stroke, every text and every solid or
tint fill in that colour; every line style becomes a dash and a gap each of the normal stroke width
(Weight.NORMAL.value, or weights_mm[Weight.NORMAL] in mm, a KeyError if absent); a text has no
stroke and no dash; an unfilled shape stays unfilled. dash_mm and dash_dot_mm are then not
read. With None the bytes are those of 0.7.0.
Why: one look for a thing that is not installed, with the colour a drawing convention of the caller.
Cost if wrong: additive; remove the keyword and the helper.
D62. The interface spec is amended in place for 0.8.0
Decided: SYMBOL_INTERFACE.html gets a change-list row (2026-10-15, 0.8.0); section 4 names
dash_dot and its pattern, section 7 gains leads, section 11 lists leads, muted and
dash_dot_mm and the [[wire]] styles. The rule count stays 35. docs/GUIDE.md gains "Bound
leads and muted symbols". The version bump, tag and release are not part of this change.
Why: the spec is the one design document and must describe what the code does.
Cost if wrong: revert the file; tests read only its code blocks and the rule table.
D63. texts in to_fragment and to_fragment_mm
Decided: both gain the keyword-only texts: Mapping[str, str] | None = None. None gives the 0.8.0
bytes (pinned by tests/test_slot_text_identity.py). Otherwise one <text> per slot with a
non-empty string, in slot id order (_slots_by_id, the preview's rule), after the elements.
Alignment is the preview's: E start and W end, capitals centred on the point; N middle, baseline on
it; S middle, baseline one capital below it. One table (SIDE_ALIGN) feeds both functions and the
preview. to_fragment_mm draws at font_mm with cap_ratio * font_mm as the capital height and
never measures; to_fragment auto-sizes as the preview does (at most 1 M, size 0 draws nothing).
Attributes: fill black or the muted colour, stroke="none", font-family="sans-serif" in
module units and the caller's family in mm, no class (designer, G6). A key that is no slot id raises
KeyError before anything is drawn, also for an empty string.
Why: render must not build slot text itself; the preview already fixes the baseline rule.
Cost if wrong: additive; remove the keyword, the table and the helpers.
D64. Required slots checked when texts are drawn
Decided: both functions gain required_slots: Sequence[str] = (), shaped as lint's argument and
expanded by the same function (required.required_ids, which slot_missing now calls; its findings
are unchanged). A missing slot raises ValueError naming the symbol and the slot ids, only when
texts is not None; an empty string satisfies it. The kind = "symbol" limit of the lint rule comes
with the shared function, so another kind demands nothing here either.
Why: designer ruling G1; one rule in one place.
Cost if wrong: additive; remove the keyword and the check.
D65. turn = true
Decided: Slot.turn: bool = False (last field), the slot key turn (loader, schema), written by
serialize only when true so a set without turned slots keeps its bytes. box is the footprint as
drawn; a turned string runs along its height and to_fragment fits it there. No new lint rule. A
turned string reads bottom-up: N starts at the point, S ends there, E and W centre on it; the
baseline sits half a capital east of the point for N and S, a capital for E and none for W, so the
glyphs lie beside the box. The text carries transform="rotate(-90 x y)" about its baseline
point, the one transform symdef writes (to_fragment_mm's docstring says so). orient never
changes turn; repeat, table, stretch and a composite's slot reference keep it (they copy
slots with replace).
Why: designer rulings G2 to G5.
Cost if wrong: additive; remove the field, the key and the branch.
Known: the to_svg preview ignores turn in 0.9.0, so a turned slot drawn flat misleads a symbol author; the fix comes in the next release through the shared SIDE_ALIGN table (designer 2026-10-16).
D66. The interface spec is amended in place for 0.9.0
Decided: SYMBOL_INTERFACE.html gets a change-list row (2026-10-16, 0.9.0), section 6 the turn key
and two paragraphs on drawing, section 11 the two keywords. docs/GUIDE.md gains "Drawing slot
text". The rule count stays 35. The version bump, tag and release are not part of this change.
Why: the spec is the one design document and must describe what the code does.
Cost if wrong: revert the file; tests read only its code blocks and the rule table.
D67. Weighted cuts
Decided: Stretch.weight: float = 1 (last field), the file key weight (a number above zero; the
loader and the schema refuse zero and below), written by serialize only when not 1. An axis may
carry several cuts. stretch sorts the axis's cuts by at, shares length minus the natural
extent among them in proportion to their weights, and moves a coordinate by the sum of the shares
of the cuts it lies strictly beyond. One cut takes delta itself, not delta * w / w, so a
single-cut result is the 0.9.0 bytes. A line or polyline across a cut grows by that cut's share; a
circle, arc or text across any cut raises ValueError; one wholly beyond some cuts moves by their
shares. offsets is unchanged and runs after the shares. stretch-cut loses its "second stretch
per axis" finding and gains "a second cut at the same at", so the rule count stays 35.
Why: designer ruling on weighted cuts (Q4, Q5). A title block needs a fixed edge and two growing spans.
Cost if wrong: additive; a file without weight and with one cut per axis is unchanged.
D68. Slot side C
Decided: C is not a compass point. Centre (a one-member enum, Centre.C) and
SlotSide = Direction | Centre are new in model.py; Slot.side is a SlotSide, so Direction,
ports and anchors never see C. The loader takes "C" for a slot side only (its own enum), the
schema has a slot_side definition, and serialize writes "C". slot_box of a C slot is [w, h]
centred on position, turned or not (box stays the footprint as drawn). Text aligns through the
same two tables as the other sides: C is ("middle", 0.5), so the baseline sits half a capital
below the point and the capital height is centred on it; a turned C is ("middle", 0.5) east, the
glyphs centred across the reading line. orient leaves a C side as it is and moves the point.
repeat, table, stretch and a composite's slot reference keep it (they copy with replace).
Why: designer ruling Q1 to Q3. A slot side is where text sits and a port side is where a wire
leaves; keeping C out of Direction means a port can never be C.
Cost if wrong: remove the enum, the schema value and the table entries.
Concern (spec): the spec asks for a lint check that a C slot's position is its box's centre. With
the box centred on position by definition (Q3) and stretch recomputing position as the new
centre, no stored slot can fail it. No such check is in 0.10.0 pending the designer's ruling.
D69. Slots follow the cuts
Decided: stretch moves each corner of a slot's box and its position by the shares of the cuts
they lie beyond (stretch_slot.py). A box across a cut grows by that cut's share, and the stored
position and box are recomputed so slot_box of the result is exactly the moved corners: the
side's own edge follows its corner, and the centred axis takes the middle of the moved corners. A box
whose two edges move alike keeps its box and its position moves as in 0.9.0 (same bytes). A turned
slot grows along its authored box. The room a slot has after stretching is the
stretched symbol's slot_box.
Why: designer ruling; a slot that spans a cut must grow with the body.
Cost if wrong: a symbol with a slot across a cut changes size; every other output is unchanged.
D70. between elements
Decided: Line.between and Polyline.between (last field, default false), the file key between
on those two element tables only (a circle, arc or text with it is a schema finding), written by
serialize only when true. table(row, n, ...) draws a between element of the row in rows 1 to
n-1 and leaves it out of row n, so n = 1 draws none. The head is unaffected: a between element in a
head is drawn as authored, and so is one on a lone symbol. repeat and translate keep the flag
(replace). A table without a between element is byte-identical to 0.9.0.
Why: designer ruling; a strip has one tick fewer than it has cells.
Cost if wrong: additive; remove the field, the key and _row_elements.
D71. The interface spec is amended in place for 0.10.0 (D67 to D70)
Decided: SYMBOL_INTERFACE.html gets a change-list row (2026-10-16, 0.10.0), the stretch and
element keys in section 4, side C in section 6, the stretch and table paragraphs in section 7, the
stretch-cut row in section 9 and the types in section 11. docs/GUIDE.md gains "Weighted cuts,
moving slots and ticks between rows". The rule count stays 35. The version bump, tag and release
are not part of this change.
Why: the spec is the one design document and must describe what the code does.
Cost if wrong: revert the files; tests read only the code blocks and the rule table.
D72. A non-C slot keeps its position across a cut (0.10.1)
Decided: stretch_slot moves the position of every slot but C by its own shift, the shares of
the cuts it lies beyond, as in 0.9.0, and grows box by the difference of its edges' shifts. Only a
C slot is placed at the middle of its moved edges (D68). So a W, E, N or S box across a cut
grows while its position stays where 0.9.0 put it; slot_box of such a result is centred on the
position on the centred axis and is no longer the moved corners there. 0.10.0 re-centred every
side, which moved the text of marking.<port> slots across a cut.
Why: designer spec (position moves with the cuts it lies beyond, D69; only C is centred, D68);
found by a byte check against 0.9.0.
Cost if wrong: a non-C slot across a cut sits at its box centre again; revert _place.
D73. Font metrics are library data (0.11.0)
Decided: library.toml takes an optional [font] table with units_per_em, cap_height,
ascent, descent and default_advance (numbers above zero, all required), an optional
thick_factor (default 1: what an advance is multiplied by for Weight.THICK) and a required
[font.advances] table of one number per single character. bundle.json gains an optional font
object of the same keys, advances an object, written only when the library has a font, with
thick_factor only when it is not 1. Library.font and LibraryConfig.font are
FontMetrics | None (frozen, slotted, in symdef.font), None when none is declared.
text_width(font, text, height, weight=Weight.NORMAL) is the sum of the advances in ems times
height; a character not in advances takes default_advance; with font=None it is 0.6 em
per character, the measure of 0.10.1, computed in the same order so existing output is
byte-identical. body_box, keepout_box and element_box take font=None. cap_height(font,
height) is the capital's height in module units (0.72 em with no font). The font is data in the
library, never global state; kerning, ligatures and per-weight advance tables are not modelled.
Why: font metrics are symdef library data, as the rest of the library is.
Cost if wrong: the keys are additive and optional; remove font from the two dataclasses, the
loader and the bundle, and font=None from the three box functions.
D74. Slot text: slot_text, slot_box with a text, TextPlacement (0.11.0)
Decided: the module symdef.slottext holds SIDE_ALIGN and TURNED_ALIGN (moved out of svg,
which reads them from there) and slot_text(slot, text, height, font) -> TextPlacement(point,
anchor, turned). point is the baseline point: the slot point shifted by the table's number in
cap heights (south for an upright slot, east for a turned one); font gives the cap height, and
None gives 0.72 em. text does not move the point, since the anchor does the aligning; it is
in the signature as the ruling gives it. slot_box(slot, text=None, height=None, font=None) with
no text is the reserved box of 0.10.1; with a text it is the union of the reserved box and the
string's extent (its measured width by one cap height, upright from the anchor, or turned
bottom-up with the caps to the west of the point). A text without a height raises ValueError.
Why: the caller reads slot text placement from symdef, not its own copy.
Cost if wrong: additive; the new parameters default to the old behaviour. Revert slottext.py
and the three parameters.
D73 amended: the library's settings are [font] and [grid] in library.toml
Decided: the [font] table of D73 also takes a required name (a string; it changes no
measure), and library.toml gains an optional [grid] table with module_mm (a number above
zero) and divisions_per_module (an integer power of two, so a division is exact in floating
point and the off-grid test stays an exact multiply). Grid(module_mm, divisions_per_module)
(frozen, slotted, in symdef.units, with division = 1 / divisions_per_module) is
Library.grid; a library with no [grid] has DEFAULT_GRID, 2.5 mm and 8 divisions, which are
DEFAULT_MODULE_MM and GRID_DIVISION of 0.10.1, so every constant keeps its value. The bundle
gains an optional grid object, written only when the grid is not the default, so existing
bundles stay byte-identical. lint(symbol, required_slots=(), grid=DEFAULT_GRID) and
off_drawing_grid(symbol, grid=DEFAULT_GRID) check the drawing grid against the library's own;
check and build pass library.grid. The wiring grid (port-off-wiring-grid) is a separate
rule and is unchanged. to_fragment_mm keeps its module_mm keyword; a caller passes
library.grid.module_mm.
Why: one settings file, frozen in the loaded library.
Cost if wrong: both tables are optional; revert Grid, the two keys and the grid argument.
D75: grid divisions are helpers that take the grid
Decided: to_divisions(grid, v) -> int, from_divisions(grid, n) -> float and
ceil_divisions(grid, v) -> int in symdef.units, exported. The grid comes first, as the amended D73 rules;
there is no public DIVISIONS_PER_MODULE constant, a caller reads grid.divisions_per_module.
to_divisions raises ValueError naming the value (repr) when v is not a whole number of
divisions, and for NaN or infinity. ceil_divisions is the one round-up rule: it counts a value
within 1e-9 divisions above a multiple as that multiple, so float noise from a measured extent
adds no division. GRID_DIVISION and snap stay unchanged.
Why: the grid is library data, so the helpers take it.
Cost if wrong: additive. Move the tolerance by editing _DIVISION_TOLERANCE; remove the three
functions to revert.
D76: turning helpers are public; orient_box turns a box about the origin
Decided: orient_point and orient_direction join __all__, and orient_box(box, orientation)
-> Box is new in symdef.orient. It turns the box's two corners as points (mirror x first for an
MR orientation, then clockwise quarter turns) and re-bounds them, about the symbol's origin, as
orient does. For any symbol and all eight orientations orient_box(body_box(s), o) equals
body_box(orient(s, o)); a test asserts it on a non-square symbol.
Why: a caller turns a box once, in symdef, not by its own copy of the maths.
Cost if wrong: additive; remove orient_box and the two exports.
D77: small accessors, and nodes_of public
Decided: Box.union(*boxes) (a static method; one box gives itself, none raises ValueError),
slots_box(symbol) (the union of the slots' reserved boxes without the body; the origin box when
there are no slots), pole_pitch(symbol) (the effective pitch: the symbol's own, else the default
4 that repeat already uses, imported from one constant) and pole_port(k, port_id) (the id a
port takes in pole k, "<k>.<port_id>"; repeat now builds every pole id through it). All four
and nodes_of (every node, the declared ones then one per unlisted port) join __all__, each with a
docstring and a test.
Why: the caller reads these facts from symdef, not a copy.
Cost if wrong: additive; repeat's output is byte-identical (the tests of D0 to D72 stay green).
D78: a loaded library's symbols are read-only
Decided: load_library and load_bundle wrap Library.symbols in a MappingProxyType, so
assigning into it raises TypeError. The field is typed Mapping[str, Symbol] (it already was);
no writer of .symbols exists in the repository. A Library built by hand keeps whatever mapping
its caller passes.
Why: a library is a frozen value, as the other records are.
Cost if wrong: a caller that edited a loaded library's symbols must copy them first; drop the wrap
to revert.
D79: the grid helpers read the grid, and Grid checks itself
Decided: Grid.__post_init__ raises ValueError naming the value and the fix when
module_mm is not a finite number above zero or divisions_per_module is not a power of two; the
two rules are units.module_error and units.divisions_error, and the loader's [grid] checks
call them, so there is no second copy. snap(v, grid) and on_grid(v, tol, grid) take the grid as
a trailing argument (as lint does), defaulting to DEFAULT_GRID; a call without a grid is
byte-identical to 0.10.1. FontMetrics.advances and SIDE_ALIGN/TURNED_ALIGN are read-only
mappings (MappingProxyType). This replaces D75's "snap stays unchanged".
Why: a library's grid is data, so every grid function must read it; a grid that is built by hand
must not be able to hold an inexact division; frozen records hold no mutable contents.
Cost if wrong: additive for callers; drop the trailing argument to revert.
D80: symdef raises its own errors
Decided: the base error is SymdefError (renamed from GraphicalSymbolsError, no alias). Under it
sit InvalidValueError (a bad argument or malformed data; replaces every ValueError raised by
symdef), UnknownNameError (a name not in the set it was looked up in; replaces every KeyError),
UnknownSymbolError and LibraryError. They are plain subclasses: none inherits the builtin it
replaces, so except ValueError no longer catches symdef. A message gives the cause, the value
and the valid values or the fix. No FileError: symdef raises no OSError itself, the reads and
writes of files let pathlib's own propagate and the docstrings say so; project.init raises
InvalidValueError for an existing file (review round 1; it first kept FileExistsError). The
OSError that write_build and stale_build let propagate (disk errors) is a documented exception. to_fragment_mm
raises UnknownNameError (not a bare dict KeyError) for a weight missing from weights_mm.
tests/test_errors.py walks the package with ast and fails on a raise of any builtin
exception class. The loader's own except ValueError clauses around standard-library
parsers stay.
Why: one base to catch, named causes, and a clean break while there are no other callers to keep.
Cost if wrong: a consumer that caught the builtins must catch SymdefError or the subclass; add a
second base to a class to revert one.
D82: measure, and the font holds one advance table per weight
Decided (font part, committed first): FontMetrics.advances is a read-only mapping from Weight
to a read-only mapping of character to advance, one table for every Weight; a font missing one
raises InvalidValueError naming it. thick_factor is removed. text_width reads the table of the
weight it is given, so a bold text differs glyph by glyph from a regular one. library.toml's
[font] takes [font.advances.normal] and [font.advances.thick], both required, each a table of
single characters to numbers above zero; the bundle's font.advances is an object with the keys
normal and thick, each such a table. default_advance stays one number for both.
Why: a bold face is not a scaled regular face, so a factor mismeasures it.
Cost if wrong: a library with a font must write a second table; a bundle written by 0.11 does not
load (its advances is flat). Fonts were new in 0.11, so the break is free.
D81: GridPoint, GridBox and GridPort are their own types
Decided: symdef.gridtypes holds three frozen slotted types whose coordinates are whole grid
divisions: GridPoint(x, y), GridBox(min, max) with width, height and GridBox.union
(the shape of Box.union), and GridPort(id, x, y, direction). A coordinate that is not an int
(a bool too) raises InvalidValueError. They do not subclass Point or Box.
Why: divisions are a second unit; a float-typed Point could carry either unit unnoticed.
Cost if wrong: additive; drop the module to revert.
measure(symbol, orientation, library, texts=None, text_height=None) -> Measure (module
symdef.gridmeasure, so the function does not shadow its own module). It orients the symbol, then
reads body_box, slot_box (with library.font) and the ports, and returns a frozen Measure:
body, keepout (the body united with every slot box, text included) as GridBox; slots as id
to GridBox; ports as id to GridPort with the direction after the turn; slots and ports
are read-only. Choices: slot text comes from the optional texts (slot id to string) with
text_height; a text given without a height raises InvalidValueError naming text_height, a
key that is no slot raises UnknownNameError; a slot with no string is its reserved box. An
extent rounds outward (min = -ceil_divisions(-min), max = ceil_divisions(max)), so a body
already on the grid is exact; a port is a position and goes through to_divisions, so an off-grid
port raises naming the port and the value. A slot's text is measured in the normal weight; a body
Text uses its own weight's table.
Why: SB12: layout gets the data from symdef and converts nothing.
Cost if wrong: additive; delete the module and the exports to revert.
D83: to_placed_fragment draws a symbol at a grid position
Decided: to_placed_fragment(symbol, orientation, at, library, *, tint, muted, texts,
required_slots) in symdef.svg_placed orients the symbol, draws it with to_fragment, and wraps
it in one <g transform="translate(X Y)">, X and Y being at (a GridPoint) turned into module
units by from_divisions(library.grid, n). to_fragment, to_fragment_mm and to_svg are
unchanged, so every golden is byte-identical; to_fragment_mm is listed in the changelog for
removal in 0.13, once its callers use the placed call.
Why: SB12: the renderer places a symbol with no unit conversion of its own.
Cost if wrong: additive; delete the module and the export to revert.
D84: two doors, symdef for consumers and symdef.authoring for authors
Decided: symdef.authoring holds nine names: build, check, init, lint, write_build,
stale_build, RULES, finding_key and LibraryConfig. They leave symdef.__all__; the two
__all__ sets are disjoint and from symdef import build raises ImportError. The rule: a name
goes to the author door when no consumer-side call takes or returns it. Fransys's import census
(2026-10-16) found no consumer import of these nine. LibraryError stays on the consumer door:
load_library and load_bundle raise it. Finding and Severity stay there too, since they ride
on LibraryError.findings; so does Status, a Symbol field a consumer building one sets. The
symdef CLI imports from symdef.project, unchanged. symdef.authoring only re-exports, so the
purity gate needs no entry for it.
Why: a consumer's surface is what it draws with; the authoring verbs are noise there and a second
reason for a major bump when one changes.
Cost if wrong: breaking for an author who imports from symdef: change the import path.
D85: a surface snapshot and a changelog
Decided: surface.toml at the repo root lists, sorted, the __all__ of symdef and
symdef.authoring; scripts/write_surface.py writes it and tests/test_surface.py fails on any
difference. docs/CHANGELOG.md has one section per version, newest first, with one line per name
added, removed, moved or widened. tests/test_changelog.py requires a section for
LIBRARY_VERSION, as the newest, with a line. The rule: a surface.toml diff comes with a
changelog line in the same commit. The first entry is the retroactive Slot.side widening to
Direction | Centre, in 0.10.0.
Why: a consumer pins a version and needs to see what moved; the snapshot makes a silent move a
red test.
Cost if wrong: additive; delete the file, the script and the two tests to revert.
D86: two wire pitches in Grid, and a map of text heights in the font
Decided: Grid gains wire_pitch (default 8) and wire_pitch_fine (default 4), in divisions;
module_mm stays the fixed symbol size. [grid] takes wire_pitch and wire_pitch_fine and
refuses a third pitch key (unknown key), a pitch that is not a whole number above zero, and a
fine pitch that does not divide the standard one. Every port of a resolved symbol must sit on the
standard pitch: resolve_library adds the port-off-wiring-grid finding (the lint rule, reused,
exempt through lint_allow) at load, so a library with an off-pitch port does not load.
electrical_symbols was measured first: 76 symbols, 117 ports, none off a 1 M pitch. FontMetrics
gains heights, a read-only map from a name to a capital height in millimetres, read from
[font.heights] and carried by the bundle (written only when not empty). symdef does not interpret
the names. measure is unchanged and takes no module_mm.
Why: Fransys's GRID-HOME needs the pitches and the text heights as library data. A default of 8/4
with 8 divisions is 1 M and 0.5 M; a library with 4 divisions and no pitch keys gets a standard
pitch of 2 M.
Cost if wrong: additive for a library that sets nothing; the loader check is new for one that has
an off-pitch port (set wire_pitch or move the port).
D87: measure_font, and [font] may name its files
Decided: measure_font(regular: bytes, bold: bytes) -> FontMetrics (new module fontmeasure)
measures two TTFs with fontTools. The advances are hmtx widths for ASCII, Latin-1 and eight
symbols, the regular face for Weight.NORMAL and the bold face for Weight.THICK. cap_height is
the top of the glyph H, descent the depth of the glyph p, ascent the OS/2 typographic
ascender, all from the regular face. default_advance is the largest regular advance of the
covered set (1843 for the house face). name is the regular face's family name (name id 1). A face
without H or p, faces that differ in units per em, and bytes that are not a TTF raise
InvalidValueError; the first names the glyph. [font] takes either the literal metrics or
name and files = { regular, bold }, never both; symdef build reads the files, relative to
library.toml, in files.py and writes the measured table into the bundle. The measure takes
bytes, so symdef reads no file at runtime. fonttools==4.67.0 is a runtime dependency, ruled by the
designer (spec SETTINGS ST5); CLAUDE.md's "no runtime dependency" line is out of date and the
designer edits it. fontmeasure.py is in the purity gate's IMPURE_MODULES only because fontTools
needs a BytesIO; it touches no file.
Why: a consumer font must come from its files with one function (spec SETTINGS ST5).
Cost if wrong: the tables of the house font are tested exactly against an independent copy; a
different default-advance rule moves only characters outside the covered set.
D88: Library.with_settings
Decided: Library.with_settings(*, module_mm=None, font=None, heights=None, lines=None) returns a
replaced library. font is the pair of TTF bytes, measured by measure_font; the library's
heights are kept unless heights is given. Grid and FontMetrics run their own checks, a
height or width must be a number above zero, a wire weight needs a width, and then the rule
text-outside-body runs over every symbol of the result (D90, which replaced text-too-large here) and a hit raises
InvalidValueError naming the symbol. The logic is the free function symdef.settings.with_settings; the method delegates
through a deferred import because settings imports model. The concern that text-too-large reads no font was ruled in D90: text-outside-body measures with the font.
Why: spec SETTINGS ST4.
Cost if wrong: additive; the font-aware rule would be a lint-rule change in a later release.
D89: [lines], a width in millimetres per weight
Decided: library.toml takes [lines] with normal and thick as millimetres above zero.
Library.lines is a read-only Mapping[Weight, float], empty when the table is absent, and the
bundle carries lines only when not empty, so existing bundles keep their bytes. With the table
present, a [[wire]] weight without a width is a load error (lines_problems), and
with_settings(lines=...) runs the same check. Nothing in drawing reads lines.
Why: spec SETTINGS ST4; the millimetres lived in the consumer. Weight.value * module_mm already
equals them (0.1 and 0.2 of 2.5 mm).
Cost if wrong: additive.
D90: text-outside-body, the font-aware check that replaces text-too-large at build
Decided: text-outside-body (symdef.lint.textbody.text_outside_body(symbol, font, module_mm)) checks a fixed
Text anchored inside the symbol's body, the union of its non-Text elements, and requires its extent to lie wholly
inside that box, measured with the library font at the text's height and weight. A text anchored outside the body,
or a symbol with no other element, is not checked. It is a library-level check outside the per-symbol lint registry:
not one of the 35 guide-table rules, not in __all__: lint() takes no font, so a registered rule could not measure.
Two call sites run it: load_library (and so symdef build) with the library font when it has one, raising
LibraryError naming the symbol; and Library.with_settings, instead of text-too-large;
text-too-large stays for authoring.
Why: designer ruling 2026-10-16 (b), spec SETTINGS ST4 amendment 2. text-too-large compared height with 1 M and read
no font, so a consumer font could never fail the build.
Cost if wrong: additive; the free function can move into the lint registry later.
D91: measure(attach=), a coil's contact-table room is measured by symdef
Decided: measure(symbol, orientation, library, texts=None, text_height=None, *, attach=None) takes attach, a map of
slot id to a Symbol. The attached symbol's keepout_box size is placed at the turned slot's point and side, as a slot's
reserved box is (E grows right and W left, centred on y; N grows up and S down, centred on x; C centred both ways),
and united with the slot's box (slot_box, with its text when given). The result is Measure.slots[slot_id], and so part
of Measure.keepout. As with a slot's box, only the point and side are turned with the view, never the attached box. A key of
attach that is not a slot id of the symbol raises UnknownNameError naming the key and the slot ids, as texts
does. An empty or absent attach returns exactly what measure returned before. The caller builds the attached symbol
with table(row, n, "y"), and tiers as table(that, tiers, "y").
Why: designer ruling 2026-10-16 (SYMDEF-LAYER Part D Q1 a). The room for a coil's contact table is a drawn size, and
symdef owns drawn sizes (owner 2026-10-16), so the Fransys layer only copies fields.
Cost if wrong: additive; a new keyword with a default.
D92: FontMetrics.family, read from the font file
Decided: FontMetrics has a field family, the family name a renderer resolves the font by. An empty family at
construction means name, so .family is never empty. measure_font(regular, bold) sets it from the regular face's name
ID 16 (typographic family), else name ID 1; name stays ID 1. The literal-table [font] form, which has no bytes, takes
an optional family, default name. The files form takes its family from the bytes; a family key beside files is a
schema finding saying the family comes from the font file. The bundle's font object writes and reads family.
Why: designer ruling 2026-10-16. Typst resolves a font by its typographic family (ID 16); symdef recorded ID 1, which is
"DejaVu Sans Condensed" for the house face (ID 16 is "DejaVu Sans", ID 17 "Condensed"), and Typst does not resolve it.
A family read from the bytes cannot disagree with the face that is drawn and measured.
Cost if wrong: additive; a new field with a default.
D93: [font] heights beside files
Decided: [font] with files takes heights, a table of names to capital heights in mm, each above zero, beside name
and files. LibraryConfig.font_heights holds them between the parse and the read; files.py puts them on the measured
FontMetrics.heights. symdef does not interpret the names (D73). family beside files stays refused (D92). heights
and files without name is still "missing required key 'name'". Any other key beside files is refused with a message
that names the key and the fix: "K is not a key of the files form (name, files, heights); remove it, or drop
files and give the measured tables". The bundle needed no change: heights already round-trips.
Why: designer ruling 2026-10-16 (SYMDEF-0.16). The consumer's text sizes are not in the font bytes; the files form could not
carry them, so a files-form library had no heights.
Cost if wrong: additive; a key that was refused is accepted. The reworded error is the only changed text.