Files
TheLadder/tools/story-to-pack/probe/wharton/claude_page.py
T
JesseMarkowitzandClaude Opus 5 e5617b86ba Replace clustering with a catalogue, and hand-write the references to judge it against
The pipeline's embed-and-cluster step is dead, and this commit holds both the
evidence for that and the step proposed to replace it.

Predicaments. Scenes are re-described as "what the person is up against", with
no names, jobs or places, then embedded and clustered (redescribe.py,
topic_words.py, topic_share.py). The pilot chose qwen3:14b over 3b by reading
both side by side. Two defects the pilot exposed are fixed: split.py missed
titles in quotes and a contents subtitle after a dash, so three stories had been
merged into their neighbours, and strip_names.py read New York place names as
people. The corrected corpus is probe/v2 (97 stories, 839 scenes);
carry_summaries.py reuses the 829 unchanged v1 summaries. Topic share fell from
20% to 13% at k=60, short of the pre-registered 10%.

Hand references. Three corpora were read scene by scene and written up by hand,
under the same prompt rules the local models get, as a baseline to judge them
against: O. Henry (probe/v2/claude, 839 scenes, 20 situations), Wharton's
Descent of Man (probe/wharton, 262 scenes, 16 groups) and Jacobs's The Lady of
the Barge (probe/jacobs, 157 scenes, 19 groups). Each has its own README and a
readable page. No inference was used for any of them.

Catalogue. probe/catalogue maps every hand group in the three references onto 36
situation entries, with an answer key per corpus and one recurrence rule applied
to all three. classify.py assigns a scene one entry or none, leave-one-corpus-
out; score.py checks it against the key, with a self-test on random labels.

Why clustering is out: hand-written predicaments, embedded and clustered exactly
as the model's were, agree with the hand grouping at ARI 0.05 — no better than
the 14B text's 0.07. Better rewriting cannot rescue it. Embeddings cannot even
shortlist: the hand label is the nearest entry 13% of the time and in the top 8
half the time.

The classification runs are not here. The dev and test runs are pre-registered
in probe/catalogue/README.md with the bar set beforehand, and are blocked on the
inference host, whose GPU has fallen off the PCIe bus three times. The 30-scene
partial output in out/ is not a result.

Review page. The situation review is now a browser page rather than JSON edited
by hand (review_page.py, review_page_logic.cjs with Node tests, format schema
v2). It has never been rendered in a real browser.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014BygvsUXV9eU6oHkTCkKZ1
2026-09-20 17:19:42 -04:00

107 lines
7.1 KiB
Python

"""A reading page for the hand-written Wharton reference: groups, their draft events, and every scene.
cd probe/wharton && python3 claude_page.py > claude/REFERENCE.html
Self-contained HTML, no scripts beyond a filter box. Reads chunks.json, stories.json and claude/*.json.
No inference.
"""
import collections, glob, html, json, pathlib
L = lambda p: json.loads(pathlib.Path(p).read_text(encoding='utf-8'))
chunks, stories = L('chunks.json'), L('stories.json')
rows = {r['chunk']: r for f in sorted(glob.glob('claude/batches/s*.json')) for r in L(f)}
groups, events, setting = L('claude/groups.json')['groups'], L('claude/events.json')['events'], L('claude/setting.json')
ev = {e['group']: e for e in events}
member_of = {m: g['id'] for g in groups for m in g['members']}
e = html.escape
TIGHT = {2: 'one predicament', 1: 'broad'}
def effect(x):
p = x['path'].replace('relationships.', '').replace('skills.', '')
if x.get('op') == 'set': return f'<span class="fx set">{e(p)} ✓</span>'
if x.get('op') == 'push': return f'<span class="fx push" title="{e(x["value"])}">notable +</span>'
return f'<span class="fx {"up" if x["dir"] == "+" else "down"}">{e(p)} {"▲" if x["dir"] == "+" else "▼"}</span>'
def scene_ref(i):
return f'<a href="#s{i}">{i}</a>'
out = []
w = out.append
nst = len(stories); nsc = len(chunks); cov = len(member_of)
w(f'''<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Wharton Reference Pack</title>
<style>
:root{{--bg:#fbfaf7;--fg:#1d1c1a;--mut:#6b675f;--line:#e2ded5;--card:#fff;--acc:#7a3b1d;--up:#1f6b3a;--down:#9b2c2c;--chip:#f1ede4}}
@media (prefers-color-scheme:dark){{:root:not([data-theme="light"]){{--bg:#16150f;--fg:#ebe7dd;--mut:#a39d90;--line:#343128;--card:#1e1d17;--acc:#e0a07a;--up:#7fd19b;--down:#f09a9a;--chip:#2a281f}}}}
:root[data-theme="dark"]{{--bg:#16150f;--fg:#ebe7dd;--mut:#a39d90;--line:#343128;--card:#1e1d17;--acc:#e0a07a;--up:#7fd19b;--down:#f09a9a;--chip:#2a281f}}
body{{background:var(--bg);color:var(--fg);font:16px/1.5 Georgia,serif;margin:0;padding:0 16px}}
main{{max-width:980px;margin:0 auto;padding:24px 0 80px}}
h1{{font-size:1.7rem;margin:.2em 0}} h2{{margin-top:2.2em;border-bottom:1px solid var(--line);padding-bottom:.2em}}
h3{{margin:.2em 0}} .mut{{color:var(--mut)}} small{{color:var(--mut)}}
.card{{background:var(--card);border:1px solid var(--line);border-radius:8px;padding:14px 16px;margin:14px 0}}
.pred{{font-style:italic}} .tag{{display:inline-block;background:var(--chip);border-radius:4px;padding:0 6px;font:12px/1.7 system-ui,sans-serif;margin-right:4px}}
table{{border-collapse:collapse;width:100%;font-size:.93rem}} td,th{{border-top:1px solid var(--line);padding:5px 6px;vertical-align:top;text-align:left}}
.fx{{display:inline-block;font:12px/1.6 system-ui,sans-serif;background:var(--chip);border-radius:4px;padding:0 5px;margin:1px 2px 1px 0;white-space:nowrap}}
.fx.up{{color:var(--up)}} .fx.down{{color:var(--down)}}
details summary{{cursor:pointer;color:var(--acc)}} a{{color:var(--acc)}}
.desc{{border-left:3px solid var(--acc);padding-left:10px;margin:8px 0}}
.wrap{{overflow-x:auto}} input{{font:inherit;padding:4px 8px;width:100%;max-width:360px;box-sizing:border-box}}
</style></head><body><main>
<h1>The Descent of Man and Other Stories: reference pack</h1>
<p class="mut">Edith Wharton, 1904 (Project Gutenberg #4519). Every scene was read and summarised by Claude, and the groups and draft events
were written by hand, as a comparison target for the story-to-pack tool and its use of local inference.
It is a reference to compare against, not an answer key: another careful reader would group some of these differently.</p>
<div class="card">
<b>{nst}</b> stories · <b>{nsc}</b> scenes (same ~320-word cut as the O. Henry and Jacobs probes) · <b>{len(groups)}</b> recurring groups ·
<b>{cov}</b> scenes ({cov/nsc:.0%}) in a group · <b>{len(events)}</b> draft events ·
<b>{sum(len(x["options"]) for x in events)}</b> options<br>
<small>A group needs ≥3 of the 10 stories and no story above 50% of its members (O. Henry: ≥4 of 97, ≤40%). Effects are a path and a direction only: DESIGN.md gives numbers to the search, not the model.</small>
</div>
<h2>Setting</h2>
<div class="card"><p><b>Suggested player role</b>: {e(setting["player_role_suggestion"])}</p>
<table>{"".join(f"<tr><td><code>{e(k)}</code></td><td>{e(v)}</td></tr>" for k, v in setting["stat_labels"].items())}</table>
<p class="mut">{e(setting["why_these_labels"])}</p>
<p><b>Roles</b>: {" · ".join(f'<span class="tag">{e(r["token"])}</span>{e(r["role"])}' for r in setting["roles"])}</p>
<p><b>Stages</b>: {" · ".join(f'<b>{e(s["title"])}</b>: {e(s["standing_rule"])}' for s in setting["stages"])}</p></div>
<h2>Groups and their events</h2>''')
for g in groups:
m = g['members']
st = collections.Counter(chunks[i]['story'] for i in m)
x = ev[g['id']]
w(f'<div class="card" id="g-{g["id"]}"><h3>{e(g["name"])}</h3><p class="pred">{e(g["predicament"])}</p>')
w(f'<p><span class="tag">{TIGHT[g["tightness"]]}</span><span class="tag">{len(m)} scenes</span>'
f'<span class="tag">{len(st)} stories</span><span class="tag">largest story {max(st.values())/len(m):.0%}</span>'
f'<span class="tag">stage: {e(x["stage"])}</span>{"<span class=tag>one-off</span>" if not x["recurs"] else ""}</p>')
w('<details><summary>Member scenes</summary><div class="wrap"><table>')
for i in m:
w(f'<tr><td>{scene_ref(i)}</td><td><small>{e(chunks[i]["title"])}</small></td><td>{e(rows[i]["predicament"])}<br><small>{e(rows[i]["summary"])}</small></td></tr>')
w('</table></div></details><h4>Draft event</h4>')
for d in x['descriptions']:
w(f'<div class="desc">{e(d["text"])} <small>from {", ".join(scene_ref(s) for s in d["from"])}</small></div>')
w('<div class="wrap"><table><tr><th>Option</th><th>Source</th><th>Effects</th></tr>')
for o in x['options']:
src = o['source']
if src.startswith('scene'):
s = int(src.split()[1]); src = f'scene {scene_ref(s)}'
if o.get('source_in_group') is False: src += f' <small>(outside group, {o["offset_from_nearest_member"]:+d} from a member)</small>'
w(f'<tr><td><b>{e(o["label"])}</b><br><small>{e(o["result"])}</small></td><td>{src}</td><td>{"".join(effect(f) for f in o["effects"])}</td></tr>')
w('</table></div></div>')
w('<h2>Every scene</h2><p><input id="q" placeholder="Filter scenes by any word" oninput="f(this.value)"></p>')
for si, s in enumerate(stories):
ids = [i for i, c in enumerate(chunks) if c['story'] == si]
w(f'<h3>{si}. {e(s["title"])} <small>{s["words"]:,} words, {len(ids)} scenes</small></h3><div class="wrap"><table>')
for i in ids:
gid = member_of.get(i)
w(f'<tr class="row" id="s{i}"><td>{i}</td><td>{e(rows[i]["summary"])}<br><span class="pred">{e(rows[i]["predicament"])}</span></td>'
f'<td>{f"""<a href="#g-{gid}">{e(gid)}</a>""" if gid else "<small>-</small>"}</td></tr>')
w('</table></div>')
w('''<script>function f(q){q=q.toLowerCase();document.querySelectorAll('.row').forEach(r=>{r.style.display=r.textContent.toLowerCase().includes(q)?'':'none'})}</script>
</main></body></html>''')
print('\n'.join(out))