Source Model — Build notes: shapes on the anchor, and where the retirement begins (slice 7)
Milestone: source-model Manual: TBD — none of its own; engineering notes behind
segments-and-geometry.md.Status: DRAFT. Engineering detail for the engine worker. The maintainer does not need to read this file; the design is in
segments-and-geometry.md(“Shape”, “Its picture”) and the foundation (source.builds-on-the-anchor). No behaviours of its own. Counts were taken on disk on 2026-09-19 by a code worker (grep, source and tests apart); they are sizes, not exact lists, and the worker re-counts before editing. Ids keep the code’s current nouns.
Slice 7 — shapes beyond the rectangle (#4925)
Pins: source.segment.shape-kinds, source.segment.curved-baseline,
source.segment.picture-by-shape, the pictures part of source.derived.recomputable, and the
first step of source.builds-on-the-anchor.
The size of the retirement, before anyone edits
| Type | Engine source | Engine tests | Swift app | Swift tests | Elsewhere | Fate |
|---|---|---|---|---|---|---|
OCRGeometryBox (its own bbox) |
56 references in 7 files | 79 in 19 files | 33 in 13 files | 21 | 1 schema in the OpenAPI contract | Stays as the old block’s format, read only through the guarded reader (slice 6). It is retired by writers moving to passes, tool by tool, after this slice. Not touched here |
AgentNoteSourceAnchor |
9 in 2 files (models/__init__.py, api/routes/system/agent_memory.py) |
5 (the contract file only) | 0 | 0 | the generated CLI surface; the contract | Retired in this slice (small, contained) |
NodeRegion |
39 in 9 files | 70 | 1 | 0 | Not retired. It says where a node sits in its parent. For awareness only | |
direct use of Artifact.ocr_geometry |
67 in 15 files (heaviest: api/routes/document/artifacts.py 18, workflows/tools/vision_base.py 8, workflows/tools/diary_entries.py 8, workflows/tools/llm_base.py 7) |
23 files | 11 in 5 files (OCRGeometrySelection, Artifact, ArtifactPanel+Regions, ZoomableImagePreviewMac+Regions, ArtifactService) |
Readers go behind read_ocr_geometry in slice 6; app readers go to the segment store in app slice A |
The five busiest files for OCRGeometryBox: media/ocr_geometry.py (21),
media/geometry_merge.py (11), tests/unit/media/test_ocr_geometry_helpers.py (9),
tests/unit/workflows/test_ocr_geometry_addressing.py (8),
tests/unit/workflows/test_diary_entries.py (8). .bbox appears 78 times in engine source and
49 in the app, but that count mixes boxes, node regions, documents and annotations: an upper
bound, not a list.
So this slice retires one small type and widens the anchor; it does not touch the 56 + 33 box references. Saying so plainly is the point of the table.
SourceAnchor gains shapes (additive)
SourceAnchor is extra="allow" and already has rect, polygon (closed, three points or
more), rotation, rendition_id, a character span, granularity and refines. It is in the
OpenAPI contract as two schemas (SourceAnchor-Input, SourceAnchor-Output), so the app has two
generated types and, after app slice A, one hand-written SourceAnchorValue.
New, all optional, so every stored anchor stays valid:
shapes: list[AnchorShape] | None, whereAnchorShapeis{ kind: "rect" | "polygon" | "path" | "point" | "time", points: list[list[float]] | None, t_start: float | None, t_end: float | None }.pathis an open line (two points or more);pointis one[x, y];timeuses seconds and carries no points. Whenshapesis absent the anchor means what it means today (rect, orpolygon). When present,rectis the bound of the area shapes, worked out by the engine; a client that sends both and they disagree is refused.media_ref: str | None: the recording atimeshape belongs to (the field only; recordings are a later slice).baselinestays onSegment(slice 3), as a line of two points or more, withbaseline_kind: "baseline" | "topline" | "centerline"(Kraken’s three).
Validation is for each kind, and the rectangle’s rules do not loosen. validate_rect
(models/anchors.py; called from NodeRegion._check_rect, SourceAnchor._check_rect, and
twice in media/region_math.py) keeps every rule it has: four finite numbers; inside the image
for normalised space; width and height above zero; x + width and y + height not past the
edge. New validators sit beside it: validate_points (finite; inside the image), a path needs
two points, a polygon three, a point exactly one, a time span 0 <= t_start < t_end. A
fixture test asserts that every rectangle validate_rect refused before this slice is still
refused (zero width; off the right edge; not a number).
The derived box. For area shapes, the bound of all of them. For a lone point, a box of zero
width and height at the point: allowed in Segment.bbox_* (engine-written) and never put
into SourceAnchor.rect, which stays unset for a point. For a time shape, no box.
Kinds. ANCHOR_GRANULARITIES (models/anchors.py; nineteen words, among them glyph, word,
line, paragraph, column, block, figure, table, marginalia, footnote, header, entry, page,
spread) is documented but read by nothing. It becomes the seed of the one kinds list
(source.segment.open-kinds): Segment.kind and SourceAnchor.granularity draw from it, and
SegmOnto’s zone and line names are added to it as data. One list, as the design says.
AgentNoteSourceAnchor is NOT retired — corrected 2026-09-26 (#5044)
This section previously said the type is a subset of SourceAnchor and could be retired onto
it. That was factually wrong, and the retirement is cancelled.
The two disagree on one field. SourceAnchor.document_id is required;
AgentNoteSourceAnchor.document_id is nullable, and _validate_anchor deliberately accepts
a note anchored by page_id or expediente alone. So the repoint is blocked not because a
compatibility shim was tempting, but because the new type refuses data the old one accepts —
an agent note about a whole expediente has no document to name.
Ruled by the design lead, 2026-09-26: keep AgentNoteSourceAnchor, and keep
SourceAnchor.document_id required. A foundation type is not loosened to accommodate one
caller, and a required field made optional is very hard to tighten again. The ability to anchor a
note above document level is deliberate, not an accident to be tidied away.
Nothing is owed here. If notes ever genuinely move to documents, this becomes a real contract change and needs its own slice.
A segment’s picture, cut to its shape
media/region_crops.py (materialize_region_crop) crops a rectangle and stores the crop as a
Rendition, and already refuses (RegionCropUnavailable) when a region was measured on a
different image than the one in hand. This slice extends that machinery; it does not add a
second cropper.
segment_picture(db, segment_id, *, size, margin, straighten, mask) -> stored image ref: resolves the segment (throughresolve_segment), takes the image named by its anchor’srendition_id, crops to the derived box plus margin, masks outside the polygon whenmaskis true (transparent), and, for a line with a baseline whenstraightenis true, resamples along the baseline so the line is level (what a recogniser wants).- Route:
GET /api/segments/{segment_id}/picture?size=&margin=&straighten=&mask=, answering with a storage reference the app fetches through the existing storage routes. Never a local path (the engine may be remote). - A picture is a worked-out thing: cached, keyed by segment id, segment version, rendition id
and the four options; made again when the version changes; never the record. Absent, not
faked, when the image is missing or the segment’s image is not the one available
(
RegionCropUnavailable, reused). - Making pictures is throttled background work when done in bulk (a training export), and bounded when done on request.
economy_htr.crop_line_strips(padded rectangles, per Apple Vision line box) is the existing line cropper. It is replaced by this function where it is called — but not in this slice. Corrected 2026-09-26 (#5045):crop_line_stripsruns INSIDE the HTR tool on Apple Vision line boxes, before any segment row exists, whilesegment_pictureneeds aSegment, adband a library. The replacement presumes the tool already reads segments, which these same notes defer to “tool by tool, after this slice”. The ordering contradicted itself. The replacement happens when the HTR tool reads segments, not before.
Refusals (typed, each tested): shapes and rect that disagree; a path of one point; a
polygon of two; a point outside the image; a time span with no media_ref; a picture asked of a
provisional (legacy:) id is allowed (it is a read) but a picture asked across images with no
alignment is refused with RegionCropUnavailable.
Tests, by behaviour.
- source.segment.shape-kinds: a point, a path, a polygon, two polygons, and a time span each
store and read back; the old-rectangle fixture still refuses what it refused; an anchor saved
before this slice (no shapes) still reads and means the same.
- source.segment.curved-baseline: a five-point baseline round-trips; a Kraken line converted
in slice 6 keeps its baseline and its polygon.
- source.segment.picture-by-shape: a triangle’s picture is transparent outside the triangle;
a tilted line’s picture is level when straightened (the baseline’s end points share a row,
within a pixel); the same options twice return the same stored picture; a version bump makes
a new one.
- source.builds-on-the-anchor (first step): an agent note sent in the old shape is accepted
and returned unchanged; AgentNoteSourceAnchor no longer exists in the code or the contract.
- Old library: opens twice, no error, nothing rewritten.
Nothing here needs the maintainer. Two things for the manager: the agent-memory route is
called by agents today, so the old request shape must keep working (above); and
economy_htr’s cropper being replaced touches a shipped workflow tool, so its own tests move
with it.