Architecture¶
The canonical record of the package's pipeline shape and the architectural invariants that bind the codebase. The pipeline section captures the validate-and-render flow that gives the package its spine; each invariant entry below states the rule, the rationale, the enforcement mechanism, and the version it locked in.
Pipeline¶
Rule. Input JSONL flows through five stages in fixed order:
Each stage's output is the next stage's only input; no stage reaches
back into a prior stage's data structures. The state stage emits
two parallel outputs — State (structural model + per-entity layout
hints) and Theme (resolved presentational constants). Theme itself
stays at the orchestration layer: the CLI calls Theme.split() to
produce LayoutSettings (consumed by layout) and RendererSettings
(consumed by render). Neither stage imports Theme directly — see
invariant #8. Between state and layout, a cross-cutting validate
step (gitsvg/validate/) runs the end-of-stream checks that need the
fully-applied State and resolved Theme — cross-reference (E400/E401)
and resolved-config conflicts (E221-E224); it emits only errors, not
data for layout, so the transform flow above is unchanged. Other
cross-cutting subpackages (file_format/, errors/, theme/, cli/)
are consumed by pipeline stages without being part of the flow.
Rationale. Pipeline shape is the package's load-bearing architecture. Locking the five-stage flow as an explicit rule prevents drift toward "this stage also peeks at that one's earlier state" coupling, and keeps each stage replaceable in isolation (alternative parse formats, alternative layout strategies, alternative renderers).
Enforcement. Code-review discipline. Import direction is the trigger: a pipeline subpackage importing from a later stage, or any stage reaching into a prior stage's internals beyond its declared output type, is the trigger for review pushback.
Locked in: foundational — predates the per-invariant numbering below.
| # | Invariant | Locked in |
|---|---|---|
| 1 | Layout↔render boundary | v0.1.4 |
| 2 | Position/size field axis classification | v0.1.5 |
| 3 | Signed math convention for offsets | v0.1.5 |
| 4 | Structural-vs-perceptual cleavage | v0.1.5 |
| 5 | Geometry-module routing for coordinate math | v0.1.5 |
| 6 | Op-to-consumer boundary | v0.1.5 |
| 7 | Orientation as renderer-only concern | v0.1.6 |
| 8 | Pipeline-stage settings split (Theme → LayoutSettings + RendererSettings) | v0.1.9 |
1. Layout↔render boundary¶
Rule. The layout engine produces an integer-grid intermediate
representation; the renderer reads RendererSettings (the renderer's
slice of the resolved theme, produced by Theme.split() — see
invariant #8) and converts grid positions to pixel coordinates. No
field crosses the boundary in the other direction. Layout output
carries no pixel coordinates, colors, fonts, strokes, or opacities;
presentational state never flows back into layout.
Rationale. Layout strategies (declaration-order assignment, lane-reuse, future orientations) and theme variants (light/dark, custom palettes, future orientation) vary independently. Keeping each side oblivious to the other lets either evolve without coupling.
Enforcement. Code-review discipline. Pixel/color/font fields
appearing on a Layout* type are the trigger for review pushback.
Locked in: v0.1.4.
2. Position/size field axis classification¶
Rule. Every position/size field on Theme, on any Layout*
dataclass, and on RenderCanvas carries a trailing comment
naming its axis classification. Three classes:
axis-symmetric— magnitude with no axis preference (radii, stroke widths, font sizes, paddings, and the four visual-side margins — resolved screen-space pixels whose grid anchor flips with orientation, so they bind to no axis; see invariant #4's pixel exception).axis-bound: <axis>— magnitude bound to a specific grid axis (branch-axisorcommit-axis). Slot indices, slot counts, spacings.direction-bound: <axis>, <direction>— magnitude with a baked-in direction along an axis. These are the orientation-leaky fields: they survive the bottom-to-top default but break under any rotation.
Non-position/size fields (colors, fonts, IDs, names, booleans) carry no comment.
Rationale. Surfaces orientation-leaky fields at the field declaration so a reader spots them without rendering at rotation or running the code.
Enforcement. Best-effort convention, code-review discipline. No test enforces presence — a missing classification on a new position/size field is a review nit, not a build break.
Locked in: v0.1.5.
3. Signed math convention for offsets¶
Rule. Signed axis-bound fields use the same sign convention:
positive = toward higher index along the named axis. The sign is
the direction; there are no separate _lower / _upper variants
for offset fields (margins, which represent two genuine distances,
are the exception).
Rationale. Lets one field carry both magnitude and direction,
keeps offset semantics orientation-agnostic, and lines the
JSONL surface up with how gitsvg/render/_geometry.py's
offset_position resolves the axis-relative offset to a pixel
position.
Locked in: v0.1.5.
4. Structural-vs-perceptual cleavage¶
Rule. Fields with a natural anchor are stored as ratios of that anchor. The field name carries a suffix naming the anchor:
_in_lanes→branch_spacing_in_rows→commit_spacing_in_grid_units→min(branch_spacing, commit_spacing)_in_font_sizes→ the relevant font_size field
Pixel exception. A field is stored as an absolute pixel value (no ratio suffix) when no single anchor cleanly fits. The exception list:
- Stroke widths and the font sizes themselves — perceptual constants with no structural anchor.
- Char-width factors used by label-width estimation — pure ratios with no spacing anchor.
- The four visual-side margins (
margin_left,margin_right,margin_top,margin_bottom) — the natural anchor flips with orientation, so a single ratio field can't carry it; the per-orientationDefaultTheme._resolve_margin_*classmethods (gitsvg/theme/_default_theme.py) compute the right pixel value from spacings + orientation atTheme.build()time when the user leaves the field unset.
Rationale. Tweaking a spacing or font size then rescales everything anchored to it proportionally; pixel-exception fields either have no natural anchor, or have an anchor that depends on orientation and so resolves at theme-build time rather than at field-storage time.
Locked in: v0.1.5; pixel-exception list widened with margins in v0.1.6.
5. Geometry-module routing for coordinate math¶
Rule. Every coordinate computation in render-side code routes
through the geometry module (gitsvg/render/_geometry.py).
Primitives never assemble coordinates inline — they call
grid_to_pixel, offset_position, branch_line_endpoints,
branch_guide_endpoints, etc. The arithmetic that turns slot
indices and theme offsets into SVG pixel coordinates lives in
exactly one place.
Rationale. Inline y + theme.<offset> and canvas.height -
canvas.margin_… ± const arithmetic at the call site couples
every primitive to the bottom-to-top screen-direction convention. Routing
through helpers means a future orientation rotation lives entirely
inside the geometry module — primitives only know about slot
indices and axis-relative offsets.
Enforcement. Code-review discipline. Pixel arithmetic on
canvas.* or theme spacing/margin fields appearing inside a
_primitives/*.py module is the trigger for review pushback.
Local primitive geometry (pill width/height + centering on the
anchor; label-stack vertical centering; arc segment + sweep math)
stays in the primitives — those are not coordinate transforms.
Locked in: v0.1.5.
6. Op-to-consumer boundary¶
Rule. Layout-engine inputs and renderer inputs live on distinct
ops. The grid: op carries the layout extent (n_commits,
n_branches); the theme: op carries every pixel-side concern
(spacings, margins, fonts, colors, strokes, opacities). No field
appears on both ops, and no field on state.grid flows into the
resolved theme.
Rationale. Spacing and margin had previously lived on both
the canvas: op and the theme: op, with the canvas op winning
(specific over general). The duplication leaked the layout↔render
boundary back into the JSONL surface and forced renderers and
authors alike to know the precedence rule. Splitting the
canvas: op into a slot-counts-only grid: op + a theme-only
spacing/margin surface restores invariant #1 at the user-visible
op level: each op feeds exactly one consumer.
Enforcement. Code-review discipline. The GridOp model
(gitsvg/file_format/ops/impl/_grid.py) carries only the two
slot-count fields; adding a spacing/margin field there is the
trigger for review pushback. The apply_theme_op handler
(gitsvg/theme/_apply.py) accepts the state for signature uniformity
but does not read state.grid.
Locked in: v0.1.5.
7. Orientation as renderer-only concern¶
Rule. The layout engine is orientation-blind: it emits canonical
grid positions and side hints (axis indices, before / after
markers) with no notion of screen direction. The renderer reads
theme.orientation and maps the canonical grid to screen pixels.
No layout-side type carries an orientation field; no pixel-side
code outside the geometry module branches on grid-vs-screen mapping.
Axis-direction conventions. The four orientations follow a single rule: branches stack in the default-positive screen direction (right for vertical orientations, down for horizontal orientations).
| Orientation | Commit-axis grows | Branch-axis grows | Origin (screen corner) |
|---|---|---|---|
bt |
up | right | bottom-left |
tb |
down | right | top-left |
lr |
right | down | top-left |
rl |
left | down | top-right |
Axis-index terminology vs visual direction. before / after
(in label_side field values) refer to branch-axis index, never
to visual screen position. before = lower-index side of the axis;
after = higher-index side. The renderer maps to a pixel side per
orientation:
| Orientation | before side appears... |
|---|---|
bt, tb |
pixel-left of the branch line |
lr, rl |
pixel-above the branch line |
Rationale. A single 4-valued enum with the "branches stack in default-positive screen direction" rule keeps the API tiny. Confining all orientation-specific code to the renderer means future orientations or per-orientation fine-tuning add no surface area to layout, state, or theme application.
Enforcement. tests/architecture/test_orientation_blind.py
AST-scans gitsvg/layout/ and gitsvg/state/ and fails on any
Orientation reference or .orientation access — the executable
form of the rule, with no exemptions. Screen-direction words (x,
y, "above", "below", "left", "right") in those packages stay
code-review discipline. Consulting orientation is unrestricted in
gitsvg/render/ (mapping the canonical grid to pixels) and in
gitsvg/validate/ (feature-support gates such as E223) — neither
produces canonical geometry, so neither is bound by the rule.
Locked in: v0.1.6; renderer-internal scope relaxed in v0.2.0
after clarifying that the invariant exists to keep layout and state
orientation-blind, not to confine orientation logic to a sub-set of
renderer modules. Made test-enforced, and the validate-stage
feature-gate carve-out added, in v0.2.7 when the last state/
orientation branch (the E223 gate) moved to gitsvg/validate/.
8. Pipeline-stage settings split¶
Rule. Theme lives only at the orchestration layer between the
apply pass and the layout / renderer stages. The pipeline stages
consume disjoint slices: LayoutSettings (the home for layout-
affecting fields — currently commit_row_mode, auto_lane_change,
merge_lane_clearance, and pull_request_extend_target_line, which
compute_layout(state, layout_settings) consults) and
RendererSettings (every current theme field). Both slices are
produced by Theme.split(); neither stage imports Theme directly.
Both slices are standalone frozen schemas with concrete
(non-Optional) field types: Theme's T | None means "unset
pre-build," and that unresolved state must not cross the split —
constructing a slice from the resolved theme's dump is also the
runtime check that resolution filled every field. The only Nones
that survive onto a slice are the fields where None is itself a
resolved value (background_color = transparent,
commit_row_band_color = no bands). RendererSettings re-declares
Theme's field block deliberately: a statically declared mirror is
what lets a type checker verify the renderer end-to-end, where a
dynamically derived model would be invisible to it.
Rationale. Pinning each stage to a narrow type lets layout and
renderer vary independently. RendererSettings can grow new visual
fields without bleeding into layout's API surface; future layout-
affecting fields (lane reuse policy, branch-axis hint, etc.) have a
clear home that won't accidentally touch renderer code. The split
also makes the orchestration boundary type-visible — the CLI's
theme.split() is the single point where the two stage views
materialise — and type-sound: ty checks the whole library with no
per-directory exclusions.
Enforcement. Architecture meta-tests under
tests/architecture/test_pipeline_split.py. One parses every module
under gitsvg/layout/ and gitsvg/render/ and asserts none of them
import the Theme name (no exemptions); the other asserts
RendererSettings mirrors Theme field-for-field, so the deliberate
duplication cannot drift.
Locked in: v0.1.9; amended in v0.3.3 (RendererSettings became a
standalone non-Optional schema instead of a Theme subclass; the
meta-test exemption for its module was removed).