#!/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.", '', '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()