frontend-patterns/scripts/classification_coverage.py
tegwick 39fea9046e Audit open work and define a bounded language 0.1 preview
Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a0cffe-206f-7323-89d3-b20ca4cf4bf9
2026-09-27 02:19:31 +02:00

104 lines
7.8 KiB
Python

#!/usr/bin/env python3
"""Render a provisional coverage inventory; refuse stale or invalid mappings."""
import argparse
from collections import Counter
import hashlib
import json
from pathlib import Path
import re
import subprocess
ROOT = Path(__file__).resolve().parents[1]
MAPPING = ROOT / 'research/catalog/ClassificationCoverage.json'
REPORT = ROOT / 'research/catalog/ClassificationCoverage.md'
INITIAL = '2a5dabd'
RESPONSIBILITIES = 'address authority projection affordance mutation synchronization reconciliation composition execution'.split()
def section_values(text, key):
section = re.search(r'^' + re.escape(key) + r':\n((?:- [^\n]+\n)+)', text, re.M)
if section is None:
raise ValueError('Missing vocabulary section: ' + key)
return re.findall(r'^- ([\w-]+)$', section[1], re.M)
def render():
data = json.loads(MAPPING.read_text())
vocab = (ROOT / 'canon/vocabulary.yaml').read_text()
families = section_values(vocab, 'families')
scales = section_values(vocab, 'scales')
forces = section_values(vocab, 'forces')
records = data['candidates']
paths = [r['path'] for r in records]
actual = {str(p.relative_to(ROOT)) for p in (ROOT / 'research/candidates').glob('*.md')}
if len(set(paths)) != len(paths) or set(paths) != actual:
raise ValueError('Classification must cover every candidate exactly once')
counts = {k: Counter() for k in ['families', 'scales', 'responsibilities', 'improves', 'worsens']}
for r in records:
raw = (ROOT / r['path']).read_bytes()
if hashlib.sha256(raw).hexdigest() != r['sha256']:
raise ValueError('Review classification after candidate changed: ' + r['path'])
front = raw.decode().split('---')[1]
if re.search(r'^title: (.*)$', front, re.M)[1] != r['title'] or not r['rationale'].strip():
raise ValueError('Invalid title/rationale: ' + r['path'])
for key, allowed in [('families', families), ('scales', scales)]:
labels = [s.strip() for s in re.search(r'^' + key + r': \[(.*)\]$', front, re.M)[1].split(',')]
if len(labels) != len(set(labels)) or not set(labels) <= set(allowed):
raise ValueError('Invalid ' + key)
counts[key].update(labels)
for key, labels, allowed in [('responsibilities', r['responsibilities'], RESPONSIBILITIES),
('improves', r['forces']['improves'], forces),
('worsens', r['forces']['worsens'], forces)]:
if not labels or len(labels) != len(set(labels)) or not set(labels) <= set(allowed):
raise ValueError('Invalid classification ' + key + ': ' + r['path'])
counts[key].update(labels)
initial = subprocess.check_output(['git', 'show', INITIAL + ':research/catalog/PatternCandidateCatalog.md'], cwd=ROOT, text=True)
seed_rows = [[c.strip() for c in line.strip('|').split('|')] for line in initial.splitlines() if '| seed |' in line]
original_family = Counter(r[1] for r in seed_rows)
original_scale = Counter(s for r in seed_rows for s in r[2].split('/'))
developed = {r['title'].casefold() for r in records}
aliases = {'URL as Application State': 'URL-addressable State'}
covered = [r[0] for r in seed_rows if aliases.get(r[0], r[0]).casefold() in developed]
uncovered = [r[0] for r in seed_rows if r[0] not in covered]
lines = ['# Classification coverage', '', 'Generated by `python3 scripts/classification_coverage.py`; assessment: ' + data['assessed_at'] + '.', '',
f"{len(records)} developed drafts; {len(seed_rows)} original seeds at `{INITIAL}`. {len(covered)} original seeds developed, {len(uncovered)} without dedicated drafts, {len(records)-len(covered)} later additions. All current candidates remain hypotheses; classification confers no admission or new evidence.", '',
'The original comparison above is the compact 16-entry table. The broader initial [patterns README](../../patterns/README.md) lists 59 seed names; it is a separate discovery inventory, not 59 developed records or a release quota.', '',
'Families/scales come from current candidate metadata. Responsibilities and forces are provisional author-model interpretations in [ClassificationCoverage.json](ClassificationCoverage.json), with candidate hashes and per-record rationale. They are separate from canonical metadata.', '',
data['counting_rule'], '', 'A zero means no assigned direct coverage, not absence of discussion. Force direction describes an intended favorable effect or accepted cost, not a measured result. A classification refresh requires reading changed candidates; do not merely replace hashes.', '',
'## Families', '', '| Family | Original seeds | Developed drafts |', '| --- | ---: | ---: |']
lines += [f'| {k} | {original_family[k]} | {counts["families"][k]} |' for k in families]
lines += ['', '## Scales', '', '| Scale | Original seeds | Developed drafts |', '| --- | ---: | ---: |']
lines += [f'| {k} | {original_scale[k]} | {counts["scales"][k]} |' for k in scales + ['cross-scale']]
lines += ['', '## Responsibilities', '', 'These are direct-mechanism assignments, not family aliases. In particular, reconciliation is a responsibility and quality is a family.', '', '| Responsibility | Drafts |', '| --- | ---: |']
lines += [f'| {k} | {counts["responsibilities"][k]} |' for k in RESPONSIBILITIES]
lines += ['', '## Forces', '', 'Selected trade-offs only. Counts are unique candidates per force, even if a force appears on both sides. Unassigned forces remain research questions; the mapping does not certify a benefit.', '', '| Force | Intended benefit | Cost / tension | Distinct drafts |', '| --- | ---: | ---: | ---: |']
for k in forces:
n = sum(k in r['forces']['improves'] or k in r['forces']['worsens'] for r in records)
lines.append(f'| {k} | {counts["improves"][k]} | {counts["worsens"][k]} | {n} |')
lines += ['', '## Per-candidate responsibility and force rationale', '', '| Candidate | Responsibilities | Intended benefit | Cost / tension | Rationale |', '| --- | --- | --- | --- | --- |']
for r in records:
lines.append('| [' + r['title'] + '](' + '../candidates/' + Path(r['path']).name + ') | ' + ', '.join(r['responsibilities']) + ' | ' + ', '.join(r['forces']['improves']) + ' | ' + ', '.join(r['forces']['worsens']) + ' | ' + r['rationale'] + ' |')
lines += ['', '## Original seeds without dedicated drafts', '']
lines += ['- ' + s for s in uncovered]
lines += ['', '## Research implication', '',
'The developed corpus concentrates on interaction/recovery; authority placement and basic projection choices remain underdeveloped. Family membership alone hides this gap. The [completed authority/projection comparison](../reviews/AuthorityProjectionComparison.md) separates scoped acceptance, projection placement and delivery; it retains all six seeds without adding drafts. Keep the navigation fixture paused; pending Zulip applicability questions remain in WP-0003 T01.', '',
'This inventory is a coverage aid, not a maturity score, empirical learning cycle, graph export or proof that the initial ontology is sufficient.', '']
return '\n'.join(lines)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--check', action='store_true')
args = parser.parse_args()
content = render()
if args.check:
if not REPORT.exists() or REPORT.read_text() != content:
raise SystemExit('Classification report is stale; review mappings and regenerate')
print('Candidate coverage, hashes, vocabulary and generated report are consistent.')
else:
REPORT.write_text(content)
print('Wrote ' + str(REPORT.relative_to(ROOT)))
if __name__ == '__main__':
main()