Status: shipped as the first city slice (packages/city, codegraph city).
Companion docs: model-metamodel.md and
model-encoding.md describe the interchange model this
one is derived from; CLAUDE.md holds the visualization rules the renderer
must obey.
This doc records the choices — the data structures, the algorithms, the entry points, and what was deliberately left out. Layout and 2D bin packing are not part of this slice; CM-8 states the contract they will consume.
The transform produces data, in its own vocabulary, and stops there. It does not position anything, does not choose colours, does not emit geometry, and does not import Three.js.
Three consequences, and they are the reason the package exists at all:
- It is testable without a renderer. Every claim in the artefact is a
number or an id that a test can assert on. A bug in "how tall is
OrderService" is caught by a unit test, not by squinting at a screenshot. - The renderer becomes replaceable.
packages/vizreads this and draws it; a second renderer (SVG, a print plan, a diff view) reads the same thing. Nothing that matters lives inside the renderer. - The boundary is enforceable.
citysits besideanalyzerunder the no-DOM, no-threeESLint rule;vizis still the only package allowed to depend onthree.
Why a package and not a folder in analyzer. The analyzer's vocabulary is
entities, edges, folds, views and metrics. Districts and buildings are a
different vocabulary, and mixing them would invite analyzer code to reason in
city terms. The dependency runs one way: city → analyzer → core.
module (TModule) -> DISTRICT the landscape a building stands on
type (TType) -> BUILDING dimensions from configurable metrics
type dependency -> ARROW drawn roof to roof, weighted by count
The city does not select these itself — the analyzer's fold does, and that matters:
- Buildings are the nodes of
foldGraph(graph, { level: "type", view }). - A building's district is
folder.containingModule(id)— theTChildOfchain walked to the nearestTModuleancestor, memoized. - Arrows are the folded edges of that same graph.
So membership follows the model's own containment, never a parsed name or id (CLAUDE.md invariant 7). The city inherits the fold's guarantees for free: aggregation, view filtering, deterministic order, and the diagnostics for what could not be folded.
Arrows attach at the roof. That is stated once, in
CityModel.conventions.arrowAttachment: "roof", rather than repeated per
arrow: it is a property of the whole drawing, and a renderer needs to be told
it exactly once. heightAxis: "y" and groundPlane: "xz" are declared beside
it for the same reason.
One flat, sorted, JSON-shaped tree. No classes, no cycles, no Maps in the
artefact — it must survive JSON.stringify unchanged and arrive intact in a
browser.
CityModel {
kind: "codegraph.city/1" // an artefact, never a model.jsonl
generatedBy: "@codegraph/city"
view: ViewDescriptor // a city without its view is not a fact
conventions: { arrowAttachment: "roof", heightAxis: "y",
groundPlane: "xz", units: "city" }
bindings: ResolvedBinding[] // channel -> metric, scale, range, domain, misses
districts: District[] // sorted by id
buildings: Building[] // sorted by id
arrows: Arrow[] // sorted by (from, to)
diagnostics: CityDiagnostics
}
District { id, name, kind, isStub, parent?, buildings: EntityId[], footprintDemand }
Building { id, name, kind, isStub, district,
height, footprint: { width, depth },
source?: { file, span? }, // the anchor, for source links (M10a)
metrics: Record<string, number | null> }
Arrow { from, to, count, kinds[], provenances[], inferred, crossDistrict, feedback }Two later additions to the shape (details in CM-8's nesting note):
District.parent— the nearest ancestor module that is itself a district, when the model declares module containment. Absent for roots and for models without containment.Building.roleandCityModel.roles(M10d) — the ARCHITECTURAL ROLE a framework profile assigns a type (service,repository,controller,configuration,component, METAMODEL §9.1), plus the legend block naming which framework spoke and which roles it used. Opt-in (--framework spring): without it the city says nothing about roles, which is the honest default for a corpus using no framework the profile describes. The classification is an INFERENCE over written annotations and the renderer says so in the legend; a building with no role stays neutral rather than being colored "plain", which would claim the framework had ruled on it.BuildingAttribute.value(M10c) — the declared constant the model states (TWithValue, METAMODEL §1.6), rendered as one line of text: an id inside a value is shown as the referenced entity's NAME, exactly as a field's declared type is. Absent when the field has no declaration-site value, which is a different claim from an empty one. Presentation only: the city states no value the model did not carry, and evaluates nothing.corpus.repositoryandBuilding.source(M10a) — the header's repository facts (remote,commit, repo-relativeroot,provider?) and each building's anchor (file,span?), carried so a renderer can project a permalink. The city projects NO url: a blob template belongs to one host, and the artefact states facts (METAMODEL §8a/§9).repositoryis present only when every model in the union that states one states the SAME one — a building carries no model of origin, so half a city linked to the wrong repository is the alternative. A replay city has the file but no span (the history keys entities to files) and links at the scrubbed tick's sha.CityModel.districtArrows— module-level dependencies between districts, from the analyzer's fold atlevel: "module"under the same view: the fan-in/fan-out a landscape view renders. SameArrowshape; kept in the artifact so no renderer re-derives module facts by aggregating type arrows (it would get stub folding and view rules wrong). Self-dependencies and arrows whose endpoint is no district are counted indiagnostics.selfDistrictArrows/droppedDistrictArrows.
Choices worth naming:
- Buildings reference their district by id, and districts list their buildings. Both directions are stored in the artefact even though one is derivable. This is not a violation of invariant 4 (no serialized inverse indexes) — that rule governs the interchange model, whose consumers must be able to re-derive. A renderer walks both ways per frame, and a JSON consumer in a browser should not have to build an index before drawing.
metricscarries the RAW measurement next to the dimension it produced. A height of17.596means nothing on its own;loc: 24beside it is what a tooltip, a legend and a bug report need.nullmeans "the model does not say" (CM-6).bindingsis the artefact's legend, in machine form. Channel, metric name, unit, description, scale, output range, observed domain, and how many buildings could not be measured. A renderer can print an honest legend without knowing anything about codegraph.kindsandprovenancesare sorted arrays, notSets.Setdoes not surviveJSON.stringify, and sorting is what makes two runs byte-identical.inferredis precomputed from the provenance set (truewhen any base edge is notdeclared) so the renderer cannot get the rule wrong; the raw set stays for anyone who wants the detail.crossDistrictis precomputed for the same reason: it is the flag a layout uses to decide which arrows must survive district packing.feedback— the arrow is in the minimum feedback set of its strongly connected component at its own fold level (the analyzer'scycles()tangle cut, computed per level inbuildCity). Precomputed so a renderer draws the recommendation without re-deriving graph facts. A boolean "in tangle" metric CHANNEL for buildings/districts is deliberately deferred: height and footprint are continuous channels and a membership bit would mislead there; the open metric-source registry (CM-4) can host one later.
A MetricSource is { name, unit, describe, value(context) }. The context is
everything a source may read about one building:
MetricContext { graph, node, entity, members, coupling }node— the folded type node (carriesmembers, the fold count).entity— the type entity itself, orundefinedwhen an edge referenced an id nothing declares.members— every base entity that folded into the building: the type, its methods, fields, parameters and locals.coupling— the type-level coupling row (fan-in/fan-out under this view).
Built-ins: loc, members, methods, fields, fanIn, fanOut, degree,
one.
A source may answer "I do not know." value returns undefined, never a
substitute zero — a stub type has no anchor, so it has no line count, and a
zero there would be a measurement nobody took. CM-6 covers what happens next.
The open forms are the extensibility, and they are how cyclomatic complexity
arrives. A measure lives in the entity's TMetrics map (METAMODEL.md §3.8),
whose keys are deliberately open, and either form reads it by name:
| Form | Reads |
|---|---|
attribute:<key> |
the type's own measure |
sum:<key> |
that measure summed over everything that folded into the building |
sum: is the form complexity wants, because complexity is measured per
invocable and a building is a type. Since M10b the Java extractor emits sloc
and cyclomatic, so --height sum:cyclomatic --footprint loc is a working
city; for an extractor that emits neither it reports "unmeasured", which is the
truth. This package will not invent a measure by re-parsing source it
cannot see — only the extractor measures. (A top-level numeric key of the same
name is still read, since an entity is a loose object, but it is
uncontractual: the map wins.)
sum: returns undefined — not 0 — when no member carries the key:
"nothing measured this" and "everything measured zero" are different
statements, and only the second is a fact about the code.
An unknown name throws (UnknownMetricError, naming what exists) rather
than falling back to a default. A silent fallback would put a documented
metric's name on a different metric's numbers.
Two channels are bound today:
| Channel | Default metric | Default scale | Reading |
|---|---|---|---|
height |
loc |
linear |
a tower twice as tall holds twice the source |
footprint |
members |
sqrt |
the metric maps onto the SIDE, so base AREA is proportional to it |
sqrt on the footprint is the one non-obvious default: mapping a metric
linearly onto the side length would make area grow with its square, and a city
plan invites the reader to compare areas. Both are overridable per channel
(--footprint-scale linear if you want the other reading), and whichever was
used ships in bindings.
Scales. linear, sqrt, log. log is log1p, not log: metrics
legitimately reach 0 (a type nobody depends on has fan-in 0) and log(0) is
-Infinity, which would silently render a measured zero at the range floor.
Negative inputs are clamped to 0 — no built-in produces one, and an
extractor-supplied key that does is outside this transform's competence.
A degenerate domain maps to the MIDDLE of the range. When every building measures the same, identical inputs must render identically; flooring them all would read as "everything is minimal" when the truth is "everything is equal".
Dimensions are rounded to 3 decimals, so two runs serialize byte-identically without anyone downstream reasoning about floating-point associativity.
The order is not negotiable — a dimension is relative to a domain, so the domain must exist first:
- Place. Fold to type level, resolve each building's district.
- Measure. Ask every source for every building. No dimension exists yet.
- Scale. Compute each channel's domain over the measured values, then map.
Doing 2 and 3 in one pass would need the domain before it is known, which is how "the first building is always the tallest" bugs happen.
These are the CLAUDE.md visualization rules made mechanical:
- Unmeasured is a third state, not zero. A building whose metric is
undefinedis floored at the channel minimum, recorded asnullinmetrics, and counted in bothbindings[].unmeasuredanddiagnostics.unmeasured. The CLI additionally warns on stderr. Without this, "shortest" and "unknown" are the same picture. - A type with no module is left out, and listed in
diagnostics.unplacedBuildings. Inventing a(none)district would stand buildings on ground the model never described. - A district with no building does not exist. Districts are collected from the placed buildings, so a module the view excluded leaves no empty plot claiming a module is there.
- Type-level self-dependencies are not arrows. A method calling a sibling
of its own class is real cohesion but not a dependency between two roofs; it
is dropped and counted in
diagnostics.selfArrows. - Inferred stays marked.
inferred: truefor any arrow whose base edges are not alldeclared— the renderer must keep those visually distinct. - The view travels with the city. Every artefact repeats the
ViewDescriptorit was built under.
Everything is linear in the model, with one deliberate index:
| Step | Work | Note |
|---|---|---|
| fold to type level | O(V + E) | analyzer, memoized container walk |
| coupling table | O(V + E) | analyzer; indexed by id into a Map |
groupMembers |
O(V) | one pass over all ids, bucketing by containingType |
| measure | O(V × sources) | sources are O(1) or O(members) |
| domains | O(V) per channel | |
| dimensions | O(V) | |
| arrows | O(E) |
groupMembers is the choice that matters: members is needed per building
(for methods, fields, sum:<key>), and walking descendants per building
would be quadratic on a real corpus. One pass over graph.ids(), bucketed by
the memoized containingType, gives every building its members in linear time.
Measured on apache/fineract (fineract-provider/src/main/java: 2 466 files,
77 644 entities, 227 154 edges, a 38 MB model.jsonl): codegraph city --internal-only produces 579 districts, 3 537 buildings, 6 012 arrows in
3.2 s wall clock end to end, including reading and validating the model.
The transform itself is a small fraction of that; loading dominates.
Placement. No building has a position; no district has bounds. This slice answers "how big is each thing", not "where does it stand". The handoff to the layout pass is one number per district:
District.footprintDemand— the total base area its buildings occupy, in city units². A district must be at least this big before packing and padding.
A layout pass therefore adds position to buildings and bounds to districts,
consuming footprintDemand and footprint, and has nothing here to undo.
Publishing a model with no coordinates is what keeps that pass replaceable —
including trying several packers and comparing them on the same city.
(That pass now exists: layoutCity in layout.ts, CLI --layout — recursive
shelf packing, readability over density, algorithm and parameters declared in
the artefact's layout block. buildCity itself is unchanged: placement stays
opt-in and separate. With nesting, a child district packs INSIDE its parent as
one more rectangle among the parent's own buildings — sizing bottom-up over
the district tree, placement top-down; a missing or cyclic parent demotes
the district to a root rather than failing.)
Also absent, and why:
- Colour. A palette is a rendering decision, and the raw
metricson each building are what a colour channel will bind to. Adding acolorfield now would freeze a choice the renderer has not yet had to make. - District nesting. Landed, exactly the way this note said it would.
Originally districts were flat because Java packages carried no parent in
the model, and nesting
com.acme.order.legacyundercom.acme.orderwould have split a name — the inference invariant 7 forbids. The Java extractor now emits package containment walked STRUCTURALLY on Spoon's package tree (TChildOf, optional onpackagein the profile): a package's parent is the nearest ancestor package that itself holds corpus types, so a pure namespace prefix (com,org.apache) is never invented, and stub packages stay flat.District.parentis that chain projected onto the city — the nearest ancestor that is also a district — and the layout packs children inside their parent (see CM-10.1). - Geometry. No meshes, no vertices, no units in metres.
units: "city"says the numbers are ratios within a range and nothing else.
Library — @codegraph/city:
buildCity(graph: CodeGraph, options?: CityOptions): CityModel
cityToJsonString(city: CityModel, options?): string
// plus the registry, for a caller with its own measurement
resolveMetric(name | MetricSource): MetricSource
metricNames(): readonly string[]
METRIC_SOURCES, METRIC_PREFIXES, SCALESbuildCity is pure: it reads the graph and allocates a result. CityOptions
carries the view, the two channel bindings (metric, scale, min, max),
carry (extra metrics measured onto every building but bound to no channel),
and edgeKinds.
CLI — codegraph city <model.jsonl...>:
--height METRIC --height-scale linear|sqrt|log
--footprint METRIC --footprint-scale linear|sqrt|log
--carry M1,M2 --internal-only --declared-only --out FILE
The command resolves flags, loads models and moves bytes; the transform stays
in the package (decision 7). It follows the CLI's existing contracts: stdout
carries the artefact and nothing else, warnings and the --out confirmation go
to stderr, exit 3 when the load was not clean, and an unknown metric or scale
becomes a usage error (exit 2) naming what exists — not an internal error,
because the user typed it.
The metric help text is generated from the registry, so a source added in the
package cannot go unmentioned in --help, and a name in --help cannot outlive
its source.
Downstream — packages/viz (shipped; see
city-render.md) reads a laid-out city artifact and renders
it with Three.js. It does not re-derive graph facts, and respects
conventions and bindings rather than inventing its own mapping.
- Layout / 2D bin packing. Landed:
layoutCitypacks buildings within each district, then districts within the ground plane — recursive shelf packing, area-descending order for stability, asqrt(total area)strip target for near-square aspect, declared gaps for streets/sidewalks/avenues. Still open: whethercrossDistrictarrow weight should pull coupled districts together (a force step after packing), and padding as a function of district size rather than a constant. - Colour binding. Most likely
kind(categorical) with provenance or staleness as a second channel. Needs the same "state the metric" disciplinebindingsalready gives dimensions. Complexity from the extractor.Done (M10b): the Java extractor emitscyclomaticper invocable andslocper type and invocable in theTMetricsmap, andsum:cyclomaticbecame a first-class height with a one-function change here — reading the map instead of only a loose key.- Scale beyond fineract. 3 537 buildings render comfortably; a corpus ten times bigger will want the renderer to instance meshes (already a CLAUDE.md rule) and may want the city model itself to support a level-of-detail view — e.g. districts only, buildings folded away.
- Diffing two cities. The artefact is deterministic and id-keyed, so a diff is possible today; what is not decided is how a renderer should show "this building grew".