Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a0cffe-206f-7323-89d3-b20ca4cf4bf9
104 lines
7.8 KiB
Python
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()
|