Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions SPEC-ISSUES.md
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,19 @@ makes the window far wider than its fitting margin, so those fragments are hones
tallied (`skipped_geometry` in the manifest) rather than measured — a sampler bug to fix before
90° coverage is complete, not a spec gap.

**Update (Phase 7, exp-002): gap 2 is RESOLVED.** Root cause: the rotated sampler warped the whole
surface to a full W×H canvas and then cropped an axis-aligned w×h box at `(cx − w/2, cy − h/2)`; for a
window wider than it is tall the fitting margin near 90° is `≈ h/2 < w/2`, so the crop's left edge
`x0 = round(cx − w/2)` went **negative** and Python's negative-index slicing read it as `W + x0`,
producing an empty slice (measured: 93/1000 frac-0.5 fragments at 85–95°). Fixed in
`generator/fragments.py` by framing the `warpAffine` directly onto the window (translate so the window
centre lands at `(w/2, h/2)` of a `(w, h)` output), so the returned fragment is always exactly `(h, w)`
and a negative slice is impossible at any rotation 0–360°. Pixel-equivalent to the old crop for windows
that already fit, so all prior tests pass unchanged (none had encoded the bug); regression tests at
85–95° and under extreme aspect jitter are in `tests/test_cross.py`. exp-002 confirms full 90° coverage.
Gap 1 (the audit-s3 single-band "weak" row unsampled by the genuine frac sweep) remains open for the
field battery.

## SI-018 · §3 · decided-here — The audit names a "density"/composition dialect but publishes no composition model, so the grid generator supplies one

Audit 002 (§3, §4 `variation.per_variant: [ink subset, composition, density]`) lists *composition*
Expand Down Expand Up @@ -387,3 +400,34 @@ consistency is a **weight-0 verification** signal defined only for in-neighbourh
between two distinct inks (not a detector of arbitrary blend corruption), and should say whether the
overlap region may be assumed pre-identified (as the audit had it) or must be recovered from pixels —
because the two impose very different robustness requirements on a conforming recogniser.

## SI-022 · §5 · open — The published false-positive rate needs a control corpus, and grid-vs-grid distance collapses onto the ink set

Spec §5 makes the false-positive rate against a **control corpus of non-enrolled pattern work** a
*published property* of a grammar, and requires an enrolment to verify a **minimum distance** from
previously enrolled grammars, "computed over signature-locus features only (peaks contribute zero)".
The cross-grammar battery (`experiments/exp-002-cross-grammar`) publishes a false-positive margin — no
001 fragment reaches even *candidate* against 002 or vice versa (0 of 885; max cross-aggregate 0.268),
and no impostor reaches *identified* against either sheet — but it does so against a **trivial corpus**:
two enrolled grammars plus two impostors, and the two grammars sit in **different measurer families**
(band vs grid, dispatched on `structure.type` in `claim.py`). A 001 fragment scored against 002 has its
grid/ink features come back unobserved, so 002's *coverage* collapses and the aggregate is floored near
zero **by construction** — the discrimination is a between-family structural fact, not a distance
measured in a shared feature space. **Two consequences the spec should price in:**

1. **The control corpus does not exist yet.** §5's "published property" needs the grammar tested against
real non-enrolled pattern work (or at least many same-family look-alikes), not one other grammar. The
number here is honest but narrow; a corpus and a corpus-construction rule are unspecified.

2. **Grid-vs-grid minimum distance is presently undefined.** iso-002's only identification-weighted
locus feature is the ink set (weight 0.75); every structural feature is weight-0 verification (audit
§6 peak-discounting) and `primitive_frequency_mix` (weight 0.25) is reserved-`unmeasured` (SI-008). So
§5's "distance over signature-locus features only" between two grid grammars reduces to a distance
between their ink sets alone — two grid *dialects* that share an ink set are **indistinguishable** to
this recogniser, and the nine-ink grid impostor confirms only the easy half (different inks → score
~0), never the dangerous half (same inks, different composition). **Spec consequence:** §5 should (a)
define the control corpus and how the false-positive rate is computed and published; and (b) require
that a family carrying most of its structure on canonical peaks either measure a second identification
feature (here: commit `primitive_frequency_mix`, SI-008) or state explicitly that its enrolment
distance is single-dimensional and therefore that same-family collision is unguarded — because a
one-feature signature has no minimum distance to defend once that feature is shared.
48 changes: 48 additions & 0 deletions battery/chart.py
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,54 @@ def line_chart(path, series, x_values, *, xlabel, ylabel, title,
return path


def bar_chart(path, groups, series, *, xlabel, ylabel, title, ymax=None):
"""Grouped bar chart (added for the cross-grammar confusion summary).

Reuses the same canvas/frame/legend machinery as ``line_chart``.

Parameters
groups list of x-category labels (one cluster of bars each).
series dict label -> list of values (length == len(groups)); one coloured
bar per series within each group.
ymax optional y-axis top; default 1.15 x the largest value.
"""
img = _canvas()
n_groups = max(1, len(groups))
n_series = max(1, len(series))
all_vals = [float(v) for vals in series.values() for v in vals]
top = ymax if ymax is not None else (max(all_vals) if all_vals else 1.0) * 1.15
top = max(top, 1.0)
ylim = (0.0, top)
xlim = (0.0, float(n_groups))
yticks = _nice_ticks(0.0, top)
_draw_frame(img, xlim, ylim, xlabel, ylabel, title, [], yticks)
to_px = _mapper(xlim, ylim)
x0, _y0, x1, y1 = _plot_box()

group_w = (x1 - x0) / n_groups
bar_w = group_w * 0.8 / n_series
legend_entries = []
for si, (label, vals) in enumerate(series.items()):
colour = PALETTE[si % len(PALETTE)]
legend_entries.append((label, colour))
for gi, v in enumerate(vals):
gx = x0 + gi * group_w + group_w * 0.1 + si * bar_w
ytop = to_px(0.0, float(v))[1]
cv2.rectangle(img, (int(round(gx)), int(ytop)),
(int(round(gx + bar_w - 2)), int(y1)), colour, -1)
if v: # count label above the bar
cv2.putText(img, f"{int(v)}", (int(round(gx)), int(ytop) - 4),
_FONT, 0.4, (0, 0, 0), 1, cv2.LINE_AA)
for gi, g in enumerate(groups):
cx = x0 + gi * group_w + group_w / 2.0
(tw, _), _ = cv2.getTextSize(g, _FONT, 0.42, 1)
cv2.putText(img, g, (int(round(cx - tw / 2)), int(y1) + 20),
_FONT, 0.42, (0, 0, 0), 1, cv2.LINE_AA)
_legend(img, legend_entries)
cv2.imwrite(str(path), img)
return path


def hist_chart(path, data, *, xlabel, title, bins=20, xlim=(0.0, 1.0), vlines=None):
"""Overlaid normalised step histograms, one per label in ``data``.

Expand Down
Loading
Loading