Compare commits
2
Commits
main
..
e5617b86ba
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e5617b86ba | ||
|
|
fa3769d0fe |
@@ -25,7 +25,7 @@ real browser.
|
||||
| Event selection, option gating | done |
|
||||
| Turn loop, per-turn upkeep | done |
|
||||
| Content validator + reachability tests | done |
|
||||
| Corporate Ladder pack — 32 events, two tiers | done |
|
||||
| Corporate Ladder pack — 31 events, two tiers | done |
|
||||
| Turn screen, retrospective, feedback widget | done |
|
||||
| Local-storage save, file export/import | done |
|
||||
| Single-file build | done |
|
||||
@@ -34,10 +34,10 @@ real browser.
|
||||
| Repetition analytics (`npm run analyze`) | done |
|
||||
| Version and build shown on screen | done |
|
||||
| Keyboard play (Enter / Tab) | done |
|
||||
| Second content pack — The Frontier, 37 events, two tiers | done |
|
||||
| Second content pack — The Frontier, 29 events, two tiers | done |
|
||||
| Setting picker, one save slot per pack | done |
|
||||
| Pack conformance suite (every pack, every check) | done |
|
||||
| Played in a real browser | Corporate Ladder since 2026-09-09; The Frontier 2026-09-15 (112 turns, 0.3.3) |
|
||||
| Played in a real browser | Corporate Ladder yes, first playtest 2026-09-09; **The Frontier not yet** |
|
||||
|
||||
The first playtest confirmed the interface, saves and the exported log all work
|
||||
in a real browser. Its findings — an unexplained daily drain, no sense of the
|
||||
@@ -58,7 +58,7 @@ npm run serve # then open http://localhost:8080
|
||||
The game plays from the keyboard: Enter takes the focused option, Enter again
|
||||
moves to the next day, and Tab / Shift+Tab pick a different option. The top of
|
||||
every screen names the game, the pack, your current title, and the build —
|
||||
`0.3.4 · dev` when running from source, and the commit it came from when built.
|
||||
`0.3.0 · dev` when running from source, and the commit it came from when built.
|
||||
|
||||
A server is needed during development because browsers refuse ES module imports
|
||||
over `file://`. If port 8080 is taken, run `python3 -m http.server <port>`
|
||||
@@ -74,7 +74,7 @@ That is one self-contained file with the stylesheet and every module inlined.
|
||||
It plays by double-clicking it — no server, no network, nothing installed.
|
||||
|
||||
```sh
|
||||
npm test # 162 tests: engine, both packs, UI wiring, and the build
|
||||
npm test # 149 tests: engine, both packs, UI wiring, and the build
|
||||
```
|
||||
|
||||
Tests are in three layers. `test/conformance.test.js` runs over **every pack in
|
||||
@@ -101,32 +101,34 @@ used only to run the tests, the single-file build, and the log analyser.
|
||||
|
||||
## Where things stand
|
||||
|
||||
The MVP is complete and has survived four playtests, and a second setting exists
|
||||
to prove the engine/content split holds. Adding it required **no engine change**
|
||||
— and three interface changes, two of which were latent defects a second pack
|
||||
made visible (a single global save slot, and the one place the UI still named a
|
||||
setting). See DECISIONS.md #34.
|
||||
|
||||
The Frontier had its first human session on 2026-09-15: 112 turns, promoted on
|
||||
turn 44, both lifestyle purchases taken and the standing charge firing as
|
||||
designed. What it found — an overcharging debt repayment in both packs, wages no
|
||||
one could recognise, a sale barn offering what the player already owned, and an
|
||||
office stage too thin — is what 0.3.4 fixes. The repayment fix needed one small
|
||||
engine addition: an effect's amount may be read from state. See DECISIONS #38–40.
|
||||
The MVP is complete and has survived three playtests, and a second setting now
|
||||
exists to prove the engine/content split holds. Adding it required **no engine
|
||||
change** — and three interface changes, two of which were latent defects a
|
||||
second pack made visible (a single global save slot, and the one place the UI
|
||||
still named a setting). See DECISIONS.md #34.
|
||||
|
||||
Open questions, in the order they are likely to matter:
|
||||
|
||||
- **0.3.4 has not been played.** Everything in it is asserted by tests and
|
||||
archetype runs, which is the evidence that was not enough before. In simulation
|
||||
promoted Frontier runs now end with about twice the money they did ($164 against
|
||||
$88) and less standing, and why is not yet isolated. The next Frontier session
|
||||
should watch whether money stops mattering at the top. See DECISIONS #40.
|
||||
- **Dispatch is twelve events against the mailroom's eighteen.** It passes the
|
||||
variety guard, but it has had far less play than the mailroom. The Frontier's
|
||||
office had the same thinness in a real session; Dispatch may too.
|
||||
- **The Frontier has never been played by a human.** Its economy is asserted by
|
||||
the conformance suite and exercised by six archetypes, and that is exactly the
|
||||
evidence that was not enough the last two times. It needs a session before its
|
||||
numbers are believed.
|
||||
|
||||
- **Standing costs money to keep, and the Frontier's version is unplayed.**
|
||||
Each setting has a `lifestyle` tier bought on the second rung and charged for
|
||||
every cycle after, a standing penalty for lagging behind what the job expects,
|
||||
and a charge levied by standing itself that no purchase ever ends. Corporate
|
||||
Ladder's has had one 96-turn session: both purchases were found and taken, and
|
||||
the standing pressure worked, but the run arrived at the top with nothing left
|
||||
to spend on — which is where the never-stopping charge came from. The Frontier
|
||||
carries the same system at its own scale and has never been in front of anyone.
|
||||
See DECISIONS #36.
|
||||
- **Dispatch is eleven events against the mailroom's nineteen.** It passes the
|
||||
variety guard, but it has had far less play than the mailroom. Waits on more
|
||||
playtesting.
|
||||
- **A third tier in either pack.** Two rungs is what both settings have. The
|
||||
split is now proven across settings; it is not proven across a long ladder. The
|
||||
Frontier has now been played once, so this is the designer's call to open.
|
||||
split is now proven across settings; it is not proven across a long ladder.
|
||||
Waits until the Frontier has been played.
|
||||
|
||||
Settled, and not open: Corporate Ladder's negotiation gate stays as it is, and
|
||||
the feedback widget stays as it is (DECISIONS #35). Money mattering late is
|
||||
|
||||
@@ -98,17 +98,14 @@ export const hardTimes = [
|
||||
'A man from the lender calls the mailroom phone. Marlene answers it. She ' +
|
||||
'hands it to you without expression and then, notably, without leaving.',
|
||||
options: [
|
||||
// Both take what is owed up to their size, never more — the same defect
|
||||
// a real Frontier session found at the store, present here unplayed.
|
||||
// Money first, because both effects read `debt` before it moves. See #38.
|
||||
{
|
||||
id: 'pay_chunk',
|
||||
label: 'Pay a real chunk of it down, up to $350.',
|
||||
label: 'Pay a real chunk of it down.',
|
||||
requires: [{ path: 'money', op: '>=', value: 350 }],
|
||||
result: 'He is audibly disappointed.',
|
||||
effects: [
|
||||
{ path: 'money', op: 'add', value: { from: 'debt', times: -1, cap: 350 } },
|
||||
{ path: 'debt', op: 'add', value: { from: 'debt', times: -1, cap: 350 } },
|
||||
{ path: 'money', op: 'add', value: -350 },
|
||||
{ path: 'debt', op: 'add', value: -350 },
|
||||
{ path: 'reputation', op: 'add', value: 1 },
|
||||
],
|
||||
},
|
||||
@@ -116,11 +113,10 @@ export const hardTimes = [
|
||||
id: 'pay_minimum',
|
||||
label: 'Pay what you can.',
|
||||
requires: [{ path: 'money', op: '>=', value: 60 }],
|
||||
result: 'Up to fifty against the loan, and ten for the privilege of the call.',
|
||||
result: 'Sixty dollars, fifty of which is the loan.',
|
||||
effects: [
|
||||
{ path: 'money', op: 'add', value: -10 },
|
||||
{ path: 'money', op: 'add', value: { from: 'debt', times: -1, cap: 50 } },
|
||||
{ path: 'debt', op: 'add', value: { from: 'debt', times: -1, cap: 50 } },
|
||||
{ path: 'money', op: 'add', value: -60 },
|
||||
{ path: 'debt', op: 'add', value: -50 },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -15,7 +15,7 @@ export const corporateLadder = {
|
||||
blurb:
|
||||
'A modern office and a mailroom, two people who have been here longer ' +
|
||||
'than you, and a laminated org chart with your name not on it.',
|
||||
version: '0.1.1',
|
||||
version: '0.1.0',
|
||||
turnUnit: 'day',
|
||||
startingStage: 'mailroom',
|
||||
// What the interface calls the panel of people. The UI used to hardcode
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
// Harrow & Pike's counting house, as it might be staffed.
|
||||
//
|
||||
// Nobody here is fixed. Each role has several people who could fill it, and a
|
||||
// run draws one partner, one chief clerk and two to four clerks from them
|
||||
// (DECISIONS #41), so a second career in the same pack meets a different
|
||||
// office. Every candidate carries the same four attributes the player does,
|
||||
// and a description that should agree with them: a partner written as sharp
|
||||
// has the wits to see through a bluff, and one written as easily flattered
|
||||
// does not.
|
||||
//
|
||||
// Descriptions may name another role in braces, filled from whoever was drawn
|
||||
// into it, so a nephew can be the nephew of whichever partner this run has.
|
||||
|
||||
export const characters = [
|
||||
{
|
||||
id: 'partner',
|
||||
role_type: 'boss',
|
||||
title: 'Senior Partner',
|
||||
candidates: [
|
||||
{
|
||||
name: 'Josiah Crane',
|
||||
description:
|
||||
'Built the house from one room and a borrowed desk. Remembers every figure ' +
|
||||
'he has ever been shown, and forgives very few of them.',
|
||||
attributes: { nerve: 7, wits: 8, charm: 3, resolve: 8 },
|
||||
startingRelationship: { trust: 40 },
|
||||
},
|
||||
{
|
||||
name: 'Harriet Voss',
|
||||
description:
|
||||
'Took over the firm when her husband died and has doubled it since. ' +
|
||||
'Listens to everything and says about a fifth of it.',
|
||||
attributes: { nerve: 6, wits: 9, charm: 5, resolve: 7 },
|
||||
},
|
||||
{
|
||||
name: 'Lionel Ashby',
|
||||
description:
|
||||
'Inherited the chair and would rather be at his club. Easily flattered, ' +
|
||||
'easily bored, and sharper than he lets anyone see.',
|
||||
attributes: { nerve: 4, wits: 6, charm: 7, resolve: 3 },
|
||||
startingRelationship: { regard: 55 },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'chief',
|
||||
role_type: 'supervisor',
|
||||
title: 'Chief Clerk',
|
||||
candidates: [
|
||||
{
|
||||
name: 'Silas Pruett',
|
||||
description:
|
||||
'Thirty years at the same high desk. Counts the pen nibs on Fridays and ' +
|
||||
'knows to the penny what the stationery cupboard should hold.',
|
||||
attributes: { nerve: 5, wits: 6, charm: 2, resolve: 9 },
|
||||
},
|
||||
{
|
||||
name: 'Ada Kemble',
|
||||
description:
|
||||
'Runs the floor with a pencil behind her ear and a list, kept in the ' +
|
||||
'same pencil, of everybody\'s lapses.',
|
||||
attributes: { nerve: 6, wits: 7, charm: 4, resolve: 7 },
|
||||
startingRelationship: { trust: 40 },
|
||||
},
|
||||
{
|
||||
name: 'Horace Lumb',
|
||||
description:
|
||||
'Wanted your desk for somebody else\'s nephew and has not forgotten it. ' +
|
||||
'Polite to your face, in a way that takes some effort.',
|
||||
attributes: { nerve: 4, wits: 5, charm: 5, resolve: 6 },
|
||||
startingRelationship: { regard: 35 },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'clerk',
|
||||
role_type: 'peer',
|
||||
title: 'Clerk',
|
||||
count: [2, 4],
|
||||
candidates: [
|
||||
{
|
||||
name: 'Tom Bassett',
|
||||
description: 'Cheerful, generous with other people\'s time, and a borrower of small sums he means to repay.',
|
||||
attributes: { nerve: 5, wits: 4, charm: 7, resolve: 3 },
|
||||
},
|
||||
{
|
||||
name: 'Nell Farrow',
|
||||
description: 'The quickest pen on the floor and does not mind who knows it. Wants the next desk up, and soon.',
|
||||
attributes: { nerve: 5, wits: 8, charm: 5, resolve: 6 },
|
||||
startingRelationship: { regard: 40 },
|
||||
},
|
||||
{
|
||||
name: 'Arthur Quill',
|
||||
description: 'Timid, exact, and never wrong about a column of figures. Apologises to doors.',
|
||||
attributes: { nerve: 2, wits: 7, charm: 3, resolve: 7 },
|
||||
},
|
||||
{
|
||||
name: 'Joe Madden',
|
||||
description: 'The loudest laugh in the building and most of the jokes, not all of them kind.',
|
||||
attributes: { nerve: 7, wits: 4, charm: 6, resolve: 3 },
|
||||
},
|
||||
{
|
||||
name: 'Lottie Swain',
|
||||
description: 'Types the partner\'s letters and so knows everything a week early. Tells almost none of it.',
|
||||
attributes: { nerve: 4, wits: 7, charm: 7, resolve: 5 },
|
||||
},
|
||||
{
|
||||
name: 'Percy Dyer',
|
||||
description: 'A nephew of {partner}\'s, sent to learn the business. Has so far learned where the kettle is.',
|
||||
attributes: { nerve: 3, wits: 3, charm: 6, resolve: 2 },
|
||||
startingRelationship: { trust: 40 },
|
||||
},
|
||||
{
|
||||
name: 'Walt Rudge',
|
||||
description: 'Twenty years a clerk and content with it. The one everyone asks, and the last to be thanked.',
|
||||
attributes: { nerve: 6, wits: 6, charm: 4, resolve: 8 },
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default characters;
|
||||
@@ -1,882 +0,0 @@
|
||||
// A clerk's days at Harrow & Pike.
|
||||
//
|
||||
// Every situation here comes from the story-to-pack catalogue (tools/
|
||||
// story-to-pack/probe/catalogue/v3), where it recurred across public-domain
|
||||
// office fiction: Torchy's door, Keefe's pay talk, Pooter's cheeky juniors,
|
||||
// Lorimer's hard boss. The id names the catalogue entry where one fits.
|
||||
//
|
||||
// This pack exists to try checks and a drawn cast (DECISIONS #41). Most options
|
||||
// test one of the four attributes, against a named person or against a set
|
||||
// difficulty, and say what happens either way; the player sees only the odds
|
||||
// in words. Names are written as `{partner}`, `{chief}` and `{clerk}` and filled
|
||||
// from whoever this run drew. An event about "a clerk" lists `clerk` in
|
||||
// `involves`, and the engine picks one present clerk for the turn.
|
||||
|
||||
const moment = (text) => ({ path: 'notable', op: 'push', value: text });
|
||||
|
||||
export const events = [
|
||||
{
|
||||
id: 'first_day',
|
||||
stage: 'desk',
|
||||
weight: 1000,
|
||||
once: true,
|
||||
involves: ['clerk'],
|
||||
description:
|
||||
'Your first morning at Harrow & Pike. {chief} shows you a stool, a pen, a ' +
|
||||
'blotter and the clock, in that order, and leaves you to draw your own ' +
|
||||
'conclusions about which matters most.',
|
||||
options: [
|
||||
{
|
||||
id: 'before_the_clock',
|
||||
label: 'Be on the stool before the clock is.',
|
||||
result: '{chief} notices, and writes something down. It may even be good.',
|
||||
effects: [
|
||||
{ path: 'relationships.chief.regard', op: 'add', value: 4 },
|
||||
moment('Began at Harrow & Pike, early.'),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'meet_the_floor',
|
||||
label: 'Go round and introduce yourself to {clerk}.',
|
||||
result: '{clerk} shakes your hand and tells you which of the pens leak.',
|
||||
effects: [
|
||||
{ path: 'relationships.{clerk}.regard', op: 'add', value: 5 },
|
||||
moment('Began at Harrow & Pike, and met {clerk} first.'),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// The floor: the one event nothing can exclude.
|
||||
{
|
||||
id: 'the_ledgers',
|
||||
stage: 'desk',
|
||||
weight: 40,
|
||||
description:
|
||||
'The ledgers are the same as last week\'s, only longer. {chief} has left a ' +
|
||||
'note on your blotter about the width of your margins.',
|
||||
options: [
|
||||
{
|
||||
id: 'head_down',
|
||||
label: 'Keep your head down and work.',
|
||||
result: 'The day goes into the columns, as days here do.',
|
||||
effects: [
|
||||
{ path: 'standing', op: 'add', value: 1 },
|
||||
{ path: 'relationships.chief.trust', op: 'add', value: 1 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'stay_late',
|
||||
label: 'Stay on after six to finish the day book.',
|
||||
result: 'Overtime is paid, at the rate the firm thinks overtime is worth.',
|
||||
effects: [
|
||||
{ path: 'money', op: 'add', value: 2 },
|
||||
{ path: 'relationships.chief.regard', op: 'add', value: 1 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'ease_off',
|
||||
label: 'Make the day last.',
|
||||
result: 'Nobody notices, today.',
|
||||
effects: [{ path: 'standing', op: 'add', value: -1 }],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'ask_for_more',
|
||||
stage: 'desk',
|
||||
weight: 25,
|
||||
cooldown: 12,
|
||||
requires: [
|
||||
{ path: 'turn', op: '>=', value: 10 },
|
||||
{ path: 'rises', op: '<', value: 3 },
|
||||
],
|
||||
description:
|
||||
'You have been at the desk long enough to know what the work is worth, and ' +
|
||||
'your lodgings have not got any cheaper. {partner} is in, and alone.',
|
||||
options: [
|
||||
{
|
||||
id: 'ask',
|
||||
label: 'Knock, and ask {partner} for a rise.',
|
||||
check: { attribute: 'charm', against: 'partner', opposedBy: 'wits', relationship: 'trust' },
|
||||
success: {
|
||||
result: '{partner} looks at you for a long moment and says the firm can manage something.',
|
||||
effects: [
|
||||
{ path: 'rises', op: 'add', value: 1 },
|
||||
{ path: 'standing', op: 'add', value: 3 },
|
||||
moment('Asked {partner} for a rise, and got it.'),
|
||||
],
|
||||
},
|
||||
failure: {
|
||||
result: '{partner} asks whether you have any idea what a clerk is worth. You find you do not.',
|
||||
effects: [
|
||||
{ path: 'relationships.partner.trust', op: 'add', value: -5 },
|
||||
{ path: 'standing', op: 'add', value: -2 },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'hint',
|
||||
label: 'Say nothing directly; mention the price of lodgings.',
|
||||
check: { attribute: 'wits', difficulty: 5 },
|
||||
success: {
|
||||
result: '{partner} takes the point and, better, thinks it was a delicate one.',
|
||||
effects: [{ path: 'relationships.partner.regard', op: 'add', value: 4 }],
|
||||
},
|
||||
failure: {
|
||||
result: '{partner} agrees that lodgings are very dear and goes back to the post.',
|
||||
effects: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'not_yet',
|
||||
label: 'Wait for a better moment.',
|
||||
result: 'There will be one. There usually is.',
|
||||
effects: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'called_in',
|
||||
stage: 'desk',
|
||||
weight: 30,
|
||||
cooldown: 8,
|
||||
involves: ['clerk'],
|
||||
description:
|
||||
'{chief} has found a column that does not add up, and it is in your hand. You ' +
|
||||
'are to explain it, standing, in front of the high desk.',
|
||||
options: [
|
||||
{
|
||||
id: 'own_up',
|
||||
label: 'Say plainly that it was your mistake.',
|
||||
check: { attribute: 'resolve', difficulty: 4 },
|
||||
success: {
|
||||
result: 'It is noted, and then, which is rarer, it is let go.',
|
||||
effects: [
|
||||
{ path: 'relationships.partner.trust', op: 'add', value: 4 },
|
||||
{ path: 'attributes.resolve', op: 'add', value: 1 },
|
||||
],
|
||||
},
|
||||
failure: {
|
||||
result: 'You start well and finish apologising for the weather.',
|
||||
effects: [{ path: 'standing', op: 'add', value: -2 }],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'argue',
|
||||
label: 'Argue that the figures were right.',
|
||||
check: { attribute: 'wits', against: 'chief' },
|
||||
success: {
|
||||
result: 'They were. {chief} checks twice and hands the page back without a word.',
|
||||
effects: [
|
||||
{ path: 'standing', op: 'add', value: 3 },
|
||||
{ path: 'relationships.chief.regard', op: 'add', value: -4 },
|
||||
],
|
||||
},
|
||||
failure: {
|
||||
result: 'They were not, and now everybody knows you thought they were.',
|
||||
effects: [
|
||||
{ path: 'standing', op: 'add', value: -4 },
|
||||
{ path: 'relationships.partner.trust', op: 'add', value: -3 },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'blame',
|
||||
label: 'Say the figures came from {clerk}\'s desk.',
|
||||
check: { attribute: 'wits', against: 'chief' },
|
||||
success: {
|
||||
result: '{chief} goes to find {clerk}. You do not watch.',
|
||||
effects: [
|
||||
{ path: 'standing', op: 'add', value: 1 },
|
||||
{ path: 'relationships.{clerk}.trust', op: 'add', value: -8 },
|
||||
],
|
||||
},
|
||||
failure: {
|
||||
result: '{chief} has the day book open at your page before you finish the sentence.',
|
||||
effects: [
|
||||
{ path: 'standing', op: 'add', value: -5 },
|
||||
{ path: 'relationships.{clerk}.trust', op: 'add', value: -10 },
|
||||
{ path: 'relationships.partner.trust', op: 'add', value: -5 },
|
||||
moment('Tried to put a mistake on {clerk}, and was found out.'),
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'the_door',
|
||||
stage: 'desk',
|
||||
weight: 30,
|
||||
cooldown: 6,
|
||||
description:
|
||||
'A caller in a good coat wants {partner} and will not give his name. You ' +
|
||||
'were told the partner was not to be disturbed before three.',
|
||||
options: [
|
||||
{
|
||||
id: 'turn_away',
|
||||
label: 'Tell him the partner is out.',
|
||||
check: { attribute: 'nerve', difficulty: 5 },
|
||||
success: {
|
||||
result: 'He goes. {partner} hears of it later and approves of the lie.',
|
||||
effects: [{ path: 'relationships.partner.trust', op: 'add', value: 3 }],
|
||||
},
|
||||
failure: {
|
||||
result: 'He sits down to wait, and {partner} comes out at a quarter past and finds him there.',
|
||||
effects: [
|
||||
{ path: 'relationships.partner.regard', op: 'add', value: -3 },
|
||||
{ path: 'standing', op: 'add', value: -1 },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'size_him_up',
|
||||
label: 'Size him up and decide for yourself.',
|
||||
check: { attribute: 'wits', difficulty: 6 },
|
||||
success: {
|
||||
result: 'You read him right. He mattered, and he got in, and {partner} knows who let him.',
|
||||
effects: [
|
||||
{ path: 'relationships.partner.trust', op: 'add', value: 5 },
|
||||
{ path: 'standing', op: 'add', value: 2 },
|
||||
moment('Judged a caller at the door, and judged him right.'),
|
||||
],
|
||||
},
|
||||
failure: {
|
||||
result: 'He was selling encyclopaedias, and {partner} bought one to get rid of him.',
|
||||
effects: [{ path: 'relationships.partner.trust', op: 'add', value: -4 }],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'pass_it_on',
|
||||
label: 'Fetch {chief} and let it be somebody else\'s decision.',
|
||||
result: '{chief} deals with it, and with you, briefly.',
|
||||
effects: [{ path: 'standing', op: 'add', value: -1 }],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'needled',
|
||||
stage: 'desk',
|
||||
weight: 40,
|
||||
cooldown: 4,
|
||||
involves: ['clerk'],
|
||||
description:
|
||||
'{clerk} reads your last letter aloud in a voice meant to be yours, and the ' +
|
||||
'floor laughs, and keeps laughing longer than it deserves.',
|
||||
options: [
|
||||
{
|
||||
id: 'let_it_go',
|
||||
label: 'Let it pass.',
|
||||
result: 'It passes. They will remember that you let it.',
|
||||
effects: [
|
||||
{ path: 'relationships.{clerk}.regard', op: 'add', value: 1 },
|
||||
{ path: 'standing', op: 'add', value: -1 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'retort',
|
||||
label: 'Answer {clerk} back, with the floor listening.',
|
||||
check: { attribute: 'wits', against: '{clerk}', relationship: 'regard' },
|
||||
success: {
|
||||
result: 'It gets a bigger laugh than the letter did, and {clerk} laughs too.',
|
||||
effects: [
|
||||
{ path: 'standing', op: 'add', value: 2 },
|
||||
{ path: 'relationships.{clerk}.regard', op: 'add', value: 3 },
|
||||
],
|
||||
},
|
||||
failure: {
|
||||
result: 'It comes out too sharp, and the laugh turns on you.',
|
||||
effects: [
|
||||
{ path: 'standing', op: 'add', value: -2 },
|
||||
{ path: 'relationships.{clerk}.regard', op: 'add', value: -3 },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'report',
|
||||
label: 'Mention it to {chief}.',
|
||||
result: '{chief} has a word. {clerk} knows where it came from.',
|
||||
effects: [
|
||||
{ path: 'relationships.chief.trust', op: 'add', value: 2 },
|
||||
{ path: 'relationships.{clerk}.trust', op: 'add', value: -6 },
|
||||
{ path: 'standing', op: 'add', value: -1 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'red_ink',
|
||||
stage: 'desk',
|
||||
weight: 30,
|
||||
cooldown: 6,
|
||||
description:
|
||||
'{chief} has gone over your morning\'s work in red ink, and left the page ' +
|
||||
'open on the high desk where the whole floor can see it.',
|
||||
options: [
|
||||
{
|
||||
id: 'swallow_it',
|
||||
label: 'Take it, and do the page again.',
|
||||
result: 'You do it again. {chief} initials it without looking up.',
|
||||
effects: [
|
||||
{ path: 'relationships.chief.regard', op: 'add', value: 2 },
|
||||
{ path: 'standing', op: 'add', value: -1 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'answer_back',
|
||||
label: 'Point out that the red ink is wrong.',
|
||||
check: { attribute: 'wits', against: 'chief' },
|
||||
success: {
|
||||
result: 'It is, in two places. {chief} says nothing, which from {chief.first} is a concession.',
|
||||
effects: [
|
||||
{ path: 'standing', op: 'add', value: 2 },
|
||||
{ path: 'relationships.chief.regard', op: 'add', value: 3 },
|
||||
],
|
||||
},
|
||||
failure: {
|
||||
result: 'The red ink is right. It usually is.',
|
||||
effects: [
|
||||
{ path: 'standing', op: 'add', value: -2 },
|
||||
{ path: 'relationships.chief.regard', op: 'add', value: -5 },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'over_the_head',
|
||||
label: 'Take the page to {partner}.',
|
||||
check: { attribute: 'charm', against: 'partner', opposedBy: 'resolve', relationship: 'trust' },
|
||||
success: {
|
||||
result: '{partner} reads it, and has a word with {chief}, and {chief} has one with you.',
|
||||
effects: [
|
||||
{ path: 'relationships.partner.trust', op: 'add', value: 3 },
|
||||
{ path: 'relationships.chief.regard', op: 'add', value: -8 },
|
||||
{ path: 'standing', op: 'add', value: 2 },
|
||||
moment('Went over {chief}\'s head, and it worked.'),
|
||||
],
|
||||
},
|
||||
failure: {
|
||||
result: '{partner} says the chief clerk runs the floor, and asks whether there was anything else.',
|
||||
effects: [
|
||||
{ path: 'relationships.partner.trust', op: 'add', value: -4 },
|
||||
{ path: 'relationships.chief.regard', op: 'add', value: -8 },
|
||||
{ path: 'standing', op: 'add', value: -3 },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'sure_thing',
|
||||
stage: 'desk',
|
||||
weight: 15,
|
||||
cooldown: 20,
|
||||
description:
|
||||
'A pleasant man at the chop-house has a share in a tramway in Brazil going ' +
|
||||
'begging. Twelve dollars today, he says, will be forty by Christmas.',
|
||||
options: [
|
||||
{
|
||||
id: 'buy_in',
|
||||
label: 'Put in twelve dollars.',
|
||||
effects: [{ path: 'money', op: 'add', value: -12 }],
|
||||
check: { attribute: 'wits', difficulty: 7 },
|
||||
success: {
|
||||
result: 'Against every expectation, including his, it pays.',
|
||||
effects: [{ path: 'money', op: 'add', value: 30 }, moment('Bought a share in a tramway, and it paid.')],
|
||||
},
|
||||
failure: {
|
||||
result: 'There is no tramway. There is, briefly, a man.',
|
||||
effects: [moment('Bought a share in a tramway that did not exist.')],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'see_through',
|
||||
label: 'Ask him the one question he cannot answer.',
|
||||
check: { attribute: 'wits', difficulty: 6 },
|
||||
success: {
|
||||
result: 'He finds he has an appointment. You find you are sharper than you were.',
|
||||
effects: [{ path: 'attributes.wits', op: 'add', value: 1 }, moment('Saw through a tramway in Brazil.')],
|
||||
},
|
||||
failure: {
|
||||
result: 'He answers it smoothly, and you are left not knowing.',
|
||||
effects: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'decline',
|
||||
label: 'Keep your money in your pocket.',
|
||||
result: 'He is sorry for you, he says, and goes to find someone less careful.',
|
||||
effects: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'the_round',
|
||||
stage: 'desk',
|
||||
weight: 30,
|
||||
cooldown: 5,
|
||||
involves: ['clerk'],
|
||||
description:
|
||||
'{clerk} and the others are going to the chop-house after work, and they ' +
|
||||
'have kept you a place. The round there is not cheap.',
|
||||
options: [
|
||||
{
|
||||
id: 'go',
|
||||
label: 'Go, and stand your round.',
|
||||
result: 'A good evening, and a dear one.',
|
||||
effects: [
|
||||
{ path: 'money', op: 'add', value: -5 },
|
||||
{ path: 'relationships.{clerk}.regard', op: 'add', value: 4 },
|
||||
{ path: 'standing', op: 'add', value: 1 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'talk_your_way',
|
||||
label: 'Go, and talk your way out of the round.',
|
||||
check: { attribute: 'charm', against: '{clerk}', opposedBy: 'wits' },
|
||||
success: {
|
||||
result: 'Nobody notices you never reached for your purse, and {clerk} laughs at all your jokes.',
|
||||
effects: [{ path: 'relationships.{clerk}.regard', op: 'add', value: 3 }],
|
||||
},
|
||||
failure: {
|
||||
result: '{clerk} notices, and says so, and you pay for the round and the remark.',
|
||||
effects: [
|
||||
{ path: 'money', op: 'add', value: -5 },
|
||||
{ path: 'relationships.{clerk}.regard', op: 'add', value: -2 },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'excuse',
|
||||
label: 'Make an excuse and go home.',
|
||||
result: 'They go without you. They will ask again, perhaps.',
|
||||
effects: [{ path: 'relationships.{clerk}.regard', op: 'add', value: -2 }],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'night_school',
|
||||
stage: 'desk',
|
||||
weight: 25,
|
||||
cooldown: 5,
|
||||
requires: [{ path: 'money', op: '>=', value: 3 }],
|
||||
description:
|
||||
'A card in the tram window: evening classes in bookkeeping, in the art of ' +
|
||||
'address, and at the boxing club behind the Institute. Three dollars a course.',
|
||||
options: [
|
||||
{
|
||||
id: 'bookkeeping',
|
||||
label: 'Take the bookkeeping course.',
|
||||
result: 'Double entry, twice a week. Your columns begin to add up before you do.',
|
||||
effects: [{ path: 'money', op: 'add', value: -3 }, { path: 'attributes.wits', op: 'add', value: 1 }],
|
||||
},
|
||||
{
|
||||
id: 'address',
|
||||
label: 'Take the art of address.',
|
||||
result: 'You learn when to say nothing, which is most of it.',
|
||||
effects: [{ path: 'money', op: 'add', value: -3 }, { path: 'attributes.charm', op: 'add', value: 1 }],
|
||||
},
|
||||
{
|
||||
id: 'boxing',
|
||||
label: 'Join the boxing club.',
|
||||
result: 'You are hit in the face by a draper. It does you a world of good.',
|
||||
effects: [{ path: 'money', op: 'add', value: -3 }, { path: 'attributes.nerve', op: 'add', value: 1 }],
|
||||
},
|
||||
{
|
||||
id: 'not_this_term',
|
||||
label: 'Not this term.',
|
||||
result: 'The card is still there next week.',
|
||||
effects: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'stand_in',
|
||||
stage: 'desk',
|
||||
weight: 15,
|
||||
cooldown: 10,
|
||||
requires: [{ path: 'standing', op: '>=', value: 45 }],
|
||||
description:
|
||||
'{partner} is laid up and sends you, of all people, to sit in on the meeting ' +
|
||||
'with the shipping agents. You are to listen, and to say as little as possible.',
|
||||
options: [
|
||||
{
|
||||
id: 'listen',
|
||||
label: 'Listen, and take good notes.',
|
||||
result: 'Your notes are better than the minutes. {partner} keeps them.',
|
||||
effects: [
|
||||
{ path: 'relationships.partner.trust', op: 'add', value: 2 },
|
||||
{ path: 'standing', op: 'add', value: 1 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'speak_up',
|
||||
label: 'Say the thing you noticed in their figures.',
|
||||
check: { attribute: 'wits', difficulty: 7 },
|
||||
success: {
|
||||
result: 'You were right, the agents know it, and so, by the afternoon, does {partner}.',
|
||||
effects: [
|
||||
{ path: 'relationships.partner.trust', op: 'add', value: 6 },
|
||||
{ path: 'standing', op: 'add', value: 5 },
|
||||
moment('Spoke up at the agents\' meeting in {partner}\'s place, and was right.'),
|
||||
],
|
||||
},
|
||||
failure: {
|
||||
result: 'You were wrong in front of people who were paid to be right.',
|
||||
effects: [
|
||||
{ path: 'relationships.partner.trust', op: 'add', value: -4 },
|
||||
{ path: 'standing', op: 'add', value: -3 },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'charm_them',
|
||||
label: 'Charm the agents over lunch.',
|
||||
check: { attribute: 'charm', difficulty: 6 },
|
||||
success: {
|
||||
result: 'They like you. They say so to {partner}, which is the point.',
|
||||
effects: [
|
||||
{ path: 'relationships.partner.regard', op: 'add', value: 5 },
|
||||
{ path: 'standing', op: 'add', value: 3 },
|
||||
],
|
||||
},
|
||||
failure: {
|
||||
result: 'The lunch is long, and you pay for it.',
|
||||
effects: [
|
||||
{ path: 'money', op: 'add', value: -4 },
|
||||
{ path: 'standing', op: 'add', value: -1 },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'lost_bill',
|
||||
stage: 'desk',
|
||||
weight: 20,
|
||||
cooldown: 12,
|
||||
involves: ['clerk'],
|
||||
description:
|
||||
'{clerk} has lost a bill of lading, and {chief} is talking about a letter to ' +
|
||||
'the partner. {clerk} has not looked up from the desk all morning.',
|
||||
options: [
|
||||
{
|
||||
id: 'speak_for',
|
||||
label: 'Put {clerk}\'s case to {partner} yourself.',
|
||||
check: { attribute: 'charm', against: 'partner', opposedBy: 'resolve', relationship: 'trust' },
|
||||
success: {
|
||||
result: '{partner} hears you out and lets it go. {clerk} does not forget who spoke.',
|
||||
effects: [
|
||||
{ path: 'relationships.{clerk}.trust', op: 'add', value: 10 },
|
||||
{ path: 'relationships.partner.regard', op: 'add', value: 2 },
|
||||
moment('Spoke up for {clerk}, and it held.'),
|
||||
],
|
||||
},
|
||||
failure: {
|
||||
result: '{partner} asks whether you would like to share the letter.',
|
||||
effects: [
|
||||
{ path: 'relationships.partner.trust', op: 'add', value: -3 },
|
||||
{ path: 'relationships.{clerk}.trust', op: 'add', value: 4 },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'help_look',
|
||||
label: 'Stay late and help look for it.',
|
||||
check: { attribute: 'resolve', difficulty: 5 },
|
||||
success: {
|
||||
result: 'It is in the wrong drawer, under the right one. You find it at nine.',
|
||||
effects: [
|
||||
{ path: 'relationships.{clerk}.trust', op: 'add', value: 8 },
|
||||
{ path: 'relationships.{clerk}.regard', op: 'add', value: 4 },
|
||||
],
|
||||
},
|
||||
failure: {
|
||||
result: 'It is not found, but {clerk} saw you look.',
|
||||
effects: [{ path: 'relationships.{clerk}.regard', op: 'add', value: 2 }],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'keep_out',
|
||||
label: 'Keep out of it.',
|
||||
result: 'The letter goes. So, very nearly, does {clerk}.',
|
||||
effects: [{ path: 'relationships.{clerk}.trust', op: 'add', value: -3 }],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'the_butt',
|
||||
stage: 'desk',
|
||||
weight: 20,
|
||||
cooldown: 8,
|
||||
involves: ['clerk'],
|
||||
description:
|
||||
'Somebody has pinned a card to your back, and you have walked the length of ' +
|
||||
'the floor to {chief}\'s desk wearing it. {clerk} cannot keep a straight face.',
|
||||
options: [
|
||||
{
|
||||
id: 'laugh_along',
|
||||
label: 'Laugh louder than anybody.',
|
||||
check: { attribute: 'charm', difficulty: 4 },
|
||||
success: {
|
||||
result: 'It becomes your joke instead of theirs.',
|
||||
effects: [
|
||||
{ path: 'standing', op: 'add', value: 1 },
|
||||
{ path: 'relationships.{clerk}.regard', op: 'add', value: 3 },
|
||||
],
|
||||
},
|
||||
failure: {
|
||||
result: 'The laugh comes out wrong, and everyone hears that it did.',
|
||||
effects: [{ path: 'standing', op: 'add', value: -2 }],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'dignity',
|
||||
label: 'Take it off and say nothing at all.',
|
||||
check: { attribute: 'resolve', difficulty: 4 },
|
||||
success: {
|
||||
result: 'It is not funny for long, when nobody is laughing at the front.',
|
||||
effects: [{ path: 'standing', op: 'add', value: 1 }],
|
||||
},
|
||||
failure: {
|
||||
result: 'Your ears give you away.',
|
||||
effects: [
|
||||
{ path: 'standing', op: 'add', value: -1 },
|
||||
{ path: 'relationships.{clerk}.regard', op: 'add', value: -2 },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'get_even',
|
||||
label: 'Resolve to do something about {clerk} tomorrow.',
|
||||
result: 'You do. It is not as funny, and {clerk} knows exactly who it was.',
|
||||
effects: [
|
||||
{ path: 'relationships.{clerk}.regard', op: 'add', value: -2 },
|
||||
{ path: 'relationships.{clerk}.trust', op: 'add', value: -3 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'saturday',
|
||||
stage: 'desk',
|
||||
weight: 60,
|
||||
cooldown: 5,
|
||||
requires: [{ path: 'calendar.name', op: '==', value: 'Saturday' }],
|
||||
description:
|
||||
'Saturday, and the office shuts at one. The pay envelope is in your inside ' +
|
||||
'pocket, and the whole of the afternoon is in front of you.',
|
||||
options: [
|
||||
{
|
||||
id: 'new_collar',
|
||||
label: 'Buy the collar the senior clerks wear.',
|
||||
result: 'It pinches, and it is noticed.',
|
||||
effects: [
|
||||
{ path: 'money', op: 'add', value: -5 },
|
||||
{ path: 'standing', op: 'add', value: 2 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'good_dinner',
|
||||
label: 'Dine somewhere with a tablecloth.',
|
||||
result: 'A dinner you will describe, later, to people who were not there.',
|
||||
effects: [{ path: 'money', op: 'add', value: -4 }],
|
||||
},
|
||||
{
|
||||
id: 'pay_back',
|
||||
label: 'Pay back what you owe the floor.',
|
||||
requires: [{ path: 'owed', op: '>', value: 0 }],
|
||||
whenLocked: 'hide',
|
||||
result: 'Squared, or nearer it. It is a better feeling than the dinner would have been.',
|
||||
// The money is read from the balance before either moves, so a small
|
||||
// debt costs what it is and no more (DECISIONS #38).
|
||||
effects: [
|
||||
{ path: 'money', op: 'add', value: { from: 'owed', times: -1, cap: 10 } },
|
||||
{ path: 'owed', op: 'add', value: { from: 'owed', times: -1, cap: 10 } },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'straight_home',
|
||||
label: 'Straight home, envelope sealed.',
|
||||
result: 'The envelope is lighter than it was in your imagination, but it is all there.',
|
||||
effects: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'family_errand',
|
||||
stage: 'desk',
|
||||
weight: 20,
|
||||
cooldown: 8,
|
||||
involves: ['clerk'],
|
||||
description:
|
||||
'A parcel for {partner}\'s sister has to cross the city before four, in the ' +
|
||||
'rain, and {partner} has asked for you by name. It is not in the job.',
|
||||
options: [
|
||||
{
|
||||
id: 'take_it',
|
||||
label: 'Take it yourself, and pay your own tram.',
|
||||
result: 'The sister gives you tea, and a message for {partner} that is more about you than the parcel.',
|
||||
effects: [
|
||||
{ path: 'money', op: 'add', value: -1 },
|
||||
{ path: 'relationships.partner.regard', op: 'add', value: 4 },
|
||||
{ path: 'standing', op: 'add', value: -1 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'make_an_impression',
|
||||
label: 'Take it, and make yourself agreeable when you get there.',
|
||||
check: { attribute: 'charm', difficulty: 5 },
|
||||
success: {
|
||||
result: 'The sister writes to {partner} that evening. You are mentioned warmly.',
|
||||
effects: [
|
||||
{ path: 'relationships.partner.regard', op: 'add', value: 6 },
|
||||
{ path: 'relationships.partner.trust', op: 'add', value: 2 },
|
||||
moment('Carried a parcel for {partner}\'s sister, and made a friend of her.'),
|
||||
],
|
||||
},
|
||||
failure: {
|
||||
result: 'You drip on the carpet and say something about the weather twice.',
|
||||
effects: [{ path: 'relationships.partner.regard', op: 'add', value: 1 }],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'hand_it_on',
|
||||
label: 'Hand it on to {clerk}, who is going that way.',
|
||||
result: '{clerk} goes, and gets the tea, and does not say whose errand it was.',
|
||||
effects: [
|
||||
{ path: 'relationships.{clerk}.regard', op: 'add', value: -3 },
|
||||
{ path: 'relationships.partner.regard', op: 'add', value: -2 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'window_desk',
|
||||
stage: 'desk',
|
||||
weight: 20,
|
||||
cooldown: 10,
|
||||
involves: ['clerk'],
|
||||
description:
|
||||
'The desk by the window has come free. {chief} says it will go to whoever ' +
|
||||
'deserves it, and {clerk} has been in before the clock all week.',
|
||||
options: [
|
||||
{
|
||||
id: 'outwork',
|
||||
label: 'Come in earlier than {clerk} does.',
|
||||
check: { attribute: 'resolve', against: '{clerk}' },
|
||||
success: {
|
||||
result: 'You are there before the cleaners. The desk is yours, and so is the draught.',
|
||||
effects: [
|
||||
{ path: 'standing', op: 'add', value: 4 },
|
||||
{ path: 'relationships.{clerk}.regard', op: 'add', value: -3 },
|
||||
moment('Won the desk by the window from {clerk}.'),
|
||||
],
|
||||
},
|
||||
failure: {
|
||||
result: '{clerk} was there before you, every morning, and has the desk.',
|
||||
effects: [{ path: 'standing', op: 'add', value: -1 }],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'word_with_chief',
|
||||
label: 'Have a quiet word with {chief}.',
|
||||
check: { attribute: 'charm', against: 'chief', opposedBy: 'wits', relationship: 'regard' },
|
||||
success: {
|
||||
result: '{chief} decides the matter in your favour, and makes sure {clerk} knows why.',
|
||||
effects: [
|
||||
{ path: 'standing', op: 'add', value: 3 },
|
||||
{ path: 'relationships.{clerk}.trust', op: 'add', value: -6 },
|
||||
],
|
||||
},
|
||||
failure: {
|
||||
result: '{chief} writes your name down, next to the word "canvassing".',
|
||||
effects: [
|
||||
{ path: 'relationships.chief.trust', op: 'add', value: -4 },
|
||||
{ path: 'standing', op: 'add', value: -2 },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'let_them_have_it',
|
||||
label: 'Let {clerk} have it.',
|
||||
result: '{clerk} takes the desk and, to your surprise, remembers who did not fight for it.',
|
||||
effects: [
|
||||
{ path: 'relationships.{clerk}.regard', op: 'add', value: 5 },
|
||||
{ path: 'relationships.{clerk}.trust', op: 'add', value: 3 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'hard_times',
|
||||
stage: 'desk',
|
||||
weight: 400,
|
||||
cooldown: 2,
|
||||
requires: [{ path: 'money', op: '<', value: 0 }],
|
||||
involves: ['clerk'],
|
||||
description:
|
||||
'The landlady has knocked twice about the week, and the purse under your ' +
|
||||
'pillow is empty. It is a long walk to Saturday.',
|
||||
options: [
|
||||
{
|
||||
id: 'copying',
|
||||
label: 'Take in copying work to do at night.',
|
||||
result: 'Law papers, by lamp, at a halfpenny a folio. It keeps the landlady quiet.',
|
||||
effects: [
|
||||
{ path: 'money', op: 'add', value: 8 },
|
||||
{ path: 'attributes.resolve', op: 'add', value: 1 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'borrow',
|
||||
label: 'Ask {clerk} for the loan of ten dollars.',
|
||||
check: { attribute: 'charm', against: '{clerk}', opposedBy: 'resolve', relationship: 'trust' },
|
||||
success: {
|
||||
result: '{clerk} lends it, and tells you not to mention it, and you will not forget.',
|
||||
effects: [
|
||||
{ path: 'money', op: 'add', value: 10 },
|
||||
{ path: 'owed', op: 'add', value: 10 },
|
||||
{ path: 'relationships.{clerk}.trust', op: 'add', value: -2 },
|
||||
],
|
||||
},
|
||||
failure: {
|
||||
result: '{clerk} is short too, but finds you three, and makes a face about it.',
|
||||
effects: [
|
||||
{ path: 'money', op: 'add', value: 3 },
|
||||
{ path: 'owed', op: 'add', value: 3 },
|
||||
{ path: 'relationships.{clerk}.regard', op: 'add', value: -3 },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'pawn_the_watch',
|
||||
label: 'Pawn your father\'s watch.',
|
||||
requires: [{ path: 'flags.pawned_watch', op: '==', value: false }],
|
||||
whenLocked: 'hide',
|
||||
result: 'Twelve dollars and a ticket. You will get it back. Everybody says that.',
|
||||
effects: [
|
||||
{ path: 'money', op: 'add', value: 12 },
|
||||
{ path: 'flags.pawned_watch', op: 'set', value: true },
|
||||
moment('Pawned your father\'s watch to pay the landlady.'),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default events;
|
||||
@@ -1,136 +0,0 @@
|
||||
// The Counting House — a prototype pack for checks and a drawn cast.
|
||||
//
|
||||
// Harrow & Pike, a trading firm's counting house, somewhere about 1910. You
|
||||
// are the newest clerk. The situations are the ones public-domain office
|
||||
// fiction kept returning to (tools/story-to-pack, catalogue v3); the people are
|
||||
// drawn fresh each run from several candidates per role, two to four clerks
|
||||
// among them; and most choices are a check, shown to the player only as odds
|
||||
// in words. See DECISIONS #41.
|
||||
//
|
||||
// One stage, deliberately: this pack exists to find out whether chance and a
|
||||
// varying cast make the same situations worth replaying, before a whole career
|
||||
// is built on them.
|
||||
|
||||
import { characters } from './characters.js';
|
||||
import { events } from './events.js';
|
||||
|
||||
export const countingHouse = {
|
||||
id: 'countinghouse',
|
||||
name: 'The Counting House',
|
||||
blurb:
|
||||
'Harrow & Pike, about 1910: a stool, a pen, a chief clerk with red ink, and a ' +
|
||||
'different office every time you start.',
|
||||
version: '0.1.0',
|
||||
turnUnit: 'day',
|
||||
startingStage: 'desk',
|
||||
peopleHeading: 'Harrow & Pike',
|
||||
|
||||
stages: {
|
||||
desk: { label: 'The Desk', title: 'Junior Clerk' },
|
||||
},
|
||||
|
||||
// A six-day week: the office shuts at one on Saturday, and that is pay day.
|
||||
calendar: {
|
||||
cycleLength: 6,
|
||||
cycleName: 'Week',
|
||||
unitNames: ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'],
|
||||
},
|
||||
|
||||
// The four attributes, the same for the player and for everyone in the cast.
|
||||
// A pack may call them what it likes; these are the prototype's names.
|
||||
attributes: {
|
||||
nerve: { label: 'Nerve' },
|
||||
wits: { label: 'Wits' },
|
||||
charm: { label: 'Charm' },
|
||||
resolve: { label: 'Resolve' },
|
||||
},
|
||||
|
||||
// How attributes turn into chances. The player sees only the words.
|
||||
// chance = 50 + 8 x (yours - theirs) + 0.4 x (their regard - 50), from 5 to 95
|
||||
// A new clerk (3) against a sharp partner (8) is 10%, a long shot; against an
|
||||
// ordinary task (4) it is 42%. Each point of training is worth eight.
|
||||
checks: {
|
||||
base: 50,
|
||||
perPoint: 8,
|
||||
unopposed: 4,
|
||||
relationshipPerPoint: 0.4,
|
||||
relationshipCentre: 50,
|
||||
floor: 5,
|
||||
ceiling: 95,
|
||||
odds: [
|
||||
{ upTo: 20, words: 'a long shot' },
|
||||
{ upTo: 40, words: 'unlikely' },
|
||||
{ upTo: 60, words: 'an even chance' },
|
||||
{ upTo: 80, words: 'likely' },
|
||||
{ upTo: 95, words: 'a safe bet' },
|
||||
],
|
||||
said: { success: 'and it came off.', failure: 'and it did not come off.' },
|
||||
},
|
||||
|
||||
state: {
|
||||
'money': { initial: 20, min: -200, max: 100000 },
|
||||
'standing': { initial: 40, min: 0, max: 100 },
|
||||
'attributes.nerve': { initial: 3, min: 0, max: 10 },
|
||||
'attributes.wits': { initial: 3, min: 0, max: 10 },
|
||||
'attributes.charm': { initial: 3, min: 0, max: 10 },
|
||||
'attributes.resolve': { initial: 3, min: 0, max: 10 },
|
||||
// Borrowed from the other clerks when times are hard, repaid on a Saturday.
|
||||
'owed': { initial: 0, min: 0, max: 100000 },
|
||||
// Rises won from the partner: each is a permanent addition to the wage.
|
||||
'rises': { initial: 0, min: 0, max: 3 },
|
||||
'flags.pawned_watch': { initial: false },
|
||||
'notable': { initial: [] },
|
||||
},
|
||||
|
||||
// Two axes, not one: whether they believe you, and what they think of you.
|
||||
relationshipStats: {
|
||||
trust: { label: 'Trusts you', initial: 50, min: 0, max: 100 },
|
||||
regard: { label: 'Thinks well of you', initial: 50, min: 0, max: 100 },
|
||||
},
|
||||
|
||||
characters,
|
||||
events,
|
||||
|
||||
// Per six-day week: -12 board and lodging +16 wages = +4.
|
||||
// Each rise adds four; a rise is earned, so it is outside the baseline.
|
||||
upkeep: [
|
||||
{ label: 'Board and lodging', effects: [{ path: 'money', op: 'add', value: -2 }] },
|
||||
{
|
||||
label: 'Wages from Harrow & Pike',
|
||||
every: 6,
|
||||
offset: 5,
|
||||
effects: [{ path: 'money', op: 'add', value: 16 }],
|
||||
},
|
||||
...[1, 2, 3].map((n) => ({
|
||||
label: n === 1 ? 'Your rise' : `Your rise (${n})`,
|
||||
every: 6,
|
||||
offset: 5,
|
||||
requires: [{ path: 'rises', op: '>=', value: n }],
|
||||
effects: [{ path: 'money', op: 'add', value: 4 }],
|
||||
})),
|
||||
],
|
||||
|
||||
tuning: {
|
||||
baselinePerCycle: { desk: [0, 10] },
|
||||
baselineExcludes: ['rises'],
|
||||
},
|
||||
|
||||
display: {
|
||||
'money': { label: 'Money', format: 'money', order: 1 },
|
||||
'owed': { label: 'Owed to the floor', format: 'money', order: 2, hideWhenZero: true },
|
||||
'standing': { label: 'Standing', format: 'meter', order: 2 },
|
||||
'attributes.nerve': { label: 'Nerve', format: 'meter', order: 3 },
|
||||
'attributes.wits': { label: 'Wits', format: 'meter', order: 4 },
|
||||
'attributes.charm': { label: 'Charm', format: 'meter', order: 5 },
|
||||
'attributes.resolve': { label: 'Resolve', format: 'meter', order: 6 },
|
||||
'rises': { label: 'Rises', order: 7, hideWhenZero: true },
|
||||
'notable': { hidden: true, inLedger: false },
|
||||
'flags.pawned_watch': {
|
||||
label: 'Your father\'s watch', hidden: true,
|
||||
whenTrue: 'Your father\'s watch is at the pawnbroker\'s.',
|
||||
whenFalse: 'You have your father\'s watch back.',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default countingHouse;
|
||||
@@ -160,7 +160,7 @@ export const deputy = [
|
||||
options: [
|
||||
{
|
||||
id: 'wade_in',
|
||||
label: 'Go in and take hold of the nearest man who is fighting.',
|
||||
label: 'Go in and take hold of the nearest one.',
|
||||
result:
|
||||
'It stops. Your sleeve does not survive it and neither does a ' +
|
||||
'table, and the whole street knows by morning which it was.',
|
||||
@@ -580,281 +580,15 @@ export const deputy = [
|
||||
],
|
||||
},
|
||||
|
||||
// Five more days in the office, added after the first real play of this
|
||||
// stage: 69 turns saw 13 distinct situations, and `office_day` and
|
||||
// `the_cells` were a sixth of the stage each. See DECISIONS #40.
|
||||
|
||||
{
|
||||
id: 'stage_came_in_short',
|
||||
stage: 'deputy',
|
||||
weight: 50,
|
||||
cooldown: 7,
|
||||
description:
|
||||
'The stage came in with the strongbox eleven pounds lighter than the ' +
|
||||
'manifest, and the driver swears on his mother that he never once ' +
|
||||
'stopped between the Wells and here.',
|
||||
options: [
|
||||
{
|
||||
id: 'wire_the_county',
|
||||
label: 'Wire the county and let them send a man.',
|
||||
result:
|
||||
'The county sends a man in nine days, by which time the driver has ' +
|
||||
'told the story often enough to believe every word of it.',
|
||||
effects: [
|
||||
{ path: 'skills.trade', op: 'add', value: 2 },
|
||||
{ path: 'relationships.ward.trusts_you', op: 'add', value: 2 },
|
||||
{ path: 'standing', op: 'add', value: -1 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'ride_the_route',
|
||||
label: 'Ride the route back yourself, looking at the ground.',
|
||||
result:
|
||||
'Wheel ruts off the road at the second creek, where the driver says ' +
|
||||
'he never stopped. You do not find the eleven pounds. You find where ' +
|
||||
'they got off.',
|
||||
effects: [
|
||||
{ path: 'money', op: 'add', value: -3 },
|
||||
{ path: 'skills.nerve', op: 'add', value: 3 },
|
||||
{ path: 'standing', op: 'add', value: 3 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'lean_on_the_driver',
|
||||
label: 'Lean on the driver until his story changes.',
|
||||
requires: [{ path: 'skills.nerve', op: '>=', value: 30 }],
|
||||
result:
|
||||
'It changes on the fourth telling, at the second creek, and then ' +
|
||||
'again at the part where somebody was expecting him.',
|
||||
effects: [
|
||||
{ path: 'skills.nerve', op: 'add', value: 3 },
|
||||
{ path: 'standing', op: 'add', value: 4 },
|
||||
{ path: 'relationships.ward.wary_of_you', op: 'add', value: 5 },
|
||||
{ path: 'notable', op: 'push', value: 'Leaned on a stage driver until the story changed.' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'the_county_fees',
|
||||
stage: 'deputy',
|
||||
weight: 45,
|
||||
cooldown: 8,
|
||||
description:
|
||||
'The county wants its licence fees collected from every business on the ' +
|
||||
'street, and the list it sent includes the woman who sells pies out of ' +
|
||||
'her kitchen window on Sundays.',
|
||||
options: [
|
||||
{
|
||||
id: 'collect_all',
|
||||
label: 'Collect from everybody on the list.',
|
||||
result:
|
||||
'All of it, pies included. The county is pleased. The street is ' +
|
||||
'polite to you in a way it was not last week.',
|
||||
effects: [
|
||||
{ path: 'standing', op: 'add', value: -4 },
|
||||
{ path: 'skills.trade', op: 'add', value: 1 },
|
||||
{ path: 'relationships.ward.trusts_you', op: 'add', value: 4 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'pay_hers',
|
||||
label: 'Collect from the rest, and pay hers out of your own pocket.',
|
||||
result:
|
||||
'A dollar fifty. Nobody is told, and by Sunday everybody knows, which ' +
|
||||
'is how the street keeps its accounts.',
|
||||
effects: [
|
||||
{ path: 'money', op: 'add', value: -5 },
|
||||
{ path: 'standing', op: 'add', value: 5 },
|
||||
{ path: 'notable', op: 'push', value: 'Paid the pie licence out of your own pocket.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'strike_her_off',
|
||||
label: 'Strike her off the list and let the county notice if it can.',
|
||||
requires: [{ path: 'skills.trade', op: '>=', value: 28 }],
|
||||
result:
|
||||
'You find the statute that exempts a kitchen window, and cite it in ' +
|
||||
'the return. The county never writes back, which is the county ' +
|
||||
'agreeing.',
|
||||
effects: [
|
||||
{ path: 'skills.trade', op: 'add', value: 3 },
|
||||
{ path: 'standing', op: 'add', value: 3 },
|
||||
{ path: 'relationships.ward.wary_of_you', op: 'add', value: 4 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'the_bounty_man',
|
||||
stage: 'deputy',
|
||||
weight: 40,
|
||||
cooldown: 9,
|
||||
description:
|
||||
'A man with a paper from Bitter Wells and a rifle he keeps too clean ' +
|
||||
'wants to know whether a teamster called Pike is in Cold Fork. There is ' +
|
||||
'a teamster called Pike in Cold Fork, and he has been decent to you.',
|
||||
options: [
|
||||
{
|
||||
id: 'tell_him',
|
||||
label: 'Tell him where Pike is.',
|
||||
result:
|
||||
'He thanks you and leaves money on the desk, which you did not ask ' +
|
||||
'for and do not give back. The teamsters hear about the money first.',
|
||||
effects: [
|
||||
{ path: 'money', op: 'add', value: 6 },
|
||||
{ path: 'standing', op: 'add', value: -3 },
|
||||
{ path: 'relationships.ward.trusts_you', op: 'add', value: 3 },
|
||||
{ path: 'relationships.dutch.wary_of_you', op: 'add', value: 6 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'send_him_on',
|
||||
label: 'Tell him Pike went west a month ago.',
|
||||
result:
|
||||
'He looks at you for long enough that you understand he knows. Then ' +
|
||||
'he rides west, because the paper does not pay him to argue.',
|
||||
effects: [
|
||||
{ path: 'skills.nerve', op: 'add', value: 2 },
|
||||
{ path: 'relationships.dutch.trusts_you', op: 'add', value: 5 },
|
||||
{ path: 'relationships.ward.wary_of_you', op: 'add', value: 5 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'check_the_paper',
|
||||
label: 'Read his paper properly before you say anything at all.',
|
||||
requires: [{ path: 'skills.trade', op: '>=', value: 30 }],
|
||||
result:
|
||||
'It names a Pike, a year and a county, and two of the three are ' +
|
||||
'wrong. You tell him so, with the statute, and he goes home.',
|
||||
effects: [
|
||||
{ path: 'skills.trade', op: 'add', value: 3 },
|
||||
{ path: 'standing', op: 'add', value: 4 },
|
||||
{ path: 'relationships.ward.trusts_you', op: 'add', value: 4 },
|
||||
{ path: 'notable', op: 'push', value: 'Read a bounty man’s paper closely enough to send him home.' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'fire_at_the_store',
|
||||
stage: 'deputy',
|
||||
weight: 35,
|
||||
cooldown: 12,
|
||||
description:
|
||||
'A lamp goes over in the back room of the store an hour after midnight, ' +
|
||||
'and by the time you are in your boots the whole street is standing in ' +
|
||||
'it in its nightclothes.',
|
||||
options: [
|
||||
{
|
||||
id: 'bucket_line',
|
||||
label: 'Get a bucket line going from the trough.',
|
||||
result:
|
||||
'It is out by two. The back room is gone and the front is black to ' +
|
||||
'the ceiling, and the storekeeper shakes every hand on the line.',
|
||||
effects: [
|
||||
{ path: 'money', op: 'add', value: -2 },
|
||||
{ path: 'skills.nerve', op: 'add', value: 2 },
|
||||
{ path: 'standing', op: 'add', value: 5 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'go_in_for_the_book',
|
||||
label: 'Go in for the book on the counter.',
|
||||
requires: [{ path: 'skills.nerve', op: '>=', value: 34 }],
|
||||
result:
|
||||
'You come out with it under your coat and no eyebrows. Half the town ' +
|
||||
'is in that book, and every one of them knows who carried it out.',
|
||||
effects: [
|
||||
{ path: 'skills.nerve', op: 'add', value: 4 },
|
||||
{ path: 'standing', op: 'add', value: 6 },
|
||||
{ path: 'notable', op: 'push', value: 'Went into the burning store for the book.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'hold_the_crowd',
|
||||
label: 'Keep the crowd back and let it burn itself out.',
|
||||
result:
|
||||
'Nobody else is hurt, which Ward says is the only part of a fire that ' +
|
||||
'is the office’s business. The storekeeper does not agree, loudly.',
|
||||
effects: [
|
||||
{ path: 'skills.trade', op: 'add', value: 2 },
|
||||
{ path: 'standing', op: 'add', value: -2 },
|
||||
{ path: 'relationships.ward.trusts_you', op: 'add', value: 3 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'dutch_wants_the_ridge',
|
||||
stage: 'deputy',
|
||||
weight: 40,
|
||||
cooldown: 10,
|
||||
description:
|
||||
'Dutch comes to the office sober and early, which has not happened ' +
|
||||
'before, and asks whether a deputy has any say in who drives the Ridge ' +
|
||||
'road now that Vane is short a man.',
|
||||
options: [
|
||||
{
|
||||
id: 'put_him_forward',
|
||||
label: 'Say you will put his name in with Vane.',
|
||||
result:
|
||||
'He nods once and goes, and Vane hears about it from Dutch before he ' +
|
||||
'hears about it from you, which is not how you meant it to go.',
|
||||
effects: [
|
||||
{ path: 'relationships.dutch.trusts_you', op: 'add', value: 8 },
|
||||
{ path: 'relationships.dutch.owes_you', op: 'add', value: 10 },
|
||||
{ path: 'relationships.hollis.wary_of_you', op: 'add', value: 5 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'office_stays_out',
|
||||
label: 'Say the office does not hire teamsters.',
|
||||
result:
|
||||
'He says that is true, and it is, and he takes his hat off the desk ' +
|
||||
'and goes back down the street to wait for somebody else to decide.',
|
||||
effects: [
|
||||
{ path: 'standing', op: 'add', value: 2 },
|
||||
{ path: 'relationships.dutch.trusts_you', op: 'add', value: -5 },
|
||||
{ path: 'relationships.ward.trusts_you', op: 'add', value: 2 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'ask_vane_for_him',
|
||||
label: 'Walk across the street and ask Vane yourself.',
|
||||
requires: [{ path: 'relationships.hollis.trusts_you', op: '>=', value: 40 }],
|
||||
result:
|
||||
'Vane hears you out in the doorway and says he will think about it, ' +
|
||||
'and Dutch is on the Ridge road on Monday.',
|
||||
effects: [
|
||||
{ path: 'standing', op: 'add', value: 3 },
|
||||
{ path: 'relationships.dutch.trusts_you', op: 'add', value: 10 },
|
||||
{ path: 'relationships.hollis.trusts_you', op: 'add', value: -2 },
|
||||
{ path: 'notable', op: 'push', value: 'Asked Vane to put Dutch on the Ridge road.' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
// Corporate Ladder's car lot, in Cold Fork's terms and at its scale. Same
|
||||
// structure deliberately: an offer, never an obligation, with the weekly
|
||||
// charge for falling short of it sitting in upkeep instead.
|
||||
//
|
||||
// The horse and the room used to be one event, whose description was
|
||||
// always about the horse. A real session was offered the room under that
|
||||
// text, and shown "Buy the bay" locked after buying it — a door that could
|
||||
// never open. Now each is its own event, gated on the tier it offers, so
|
||||
// neither can appear once it is bought. See DECISIONS #40.
|
||||
id: 'the_sale_barn',
|
||||
stage: 'deputy',
|
||||
weight: 45,
|
||||
cooldown: 6,
|
||||
requires: [{ path: 'lifestyle', op: '==', value: 0 }],
|
||||
requires: [{ path: 'lifestyle', op: '<', value: 2 }],
|
||||
description:
|
||||
'The sale barn has a bay with four good years in him and a price on ' +
|
||||
'him. Ward says nothing about it, except that the county pays mileage ' +
|
||||
@@ -863,7 +597,10 @@ export const deputy = [
|
||||
{
|
||||
id: 'buy_the_bay',
|
||||
label: 'Buy the bay.',
|
||||
requires: [{ path: 'money', op: '>=', value: 70 }],
|
||||
requires: [
|
||||
{ path: 'lifestyle', op: '==', value: 0 },
|
||||
{ path: 'money', op: '>=', value: 70 },
|
||||
],
|
||||
result:
|
||||
'He is a better animal than the livery has ever put under you, and ' +
|
||||
'the whole street watches you ride him down it once.',
|
||||
@@ -874,32 +611,13 @@ export const deputy = [
|
||||
{ path: 'notable', op: 'push', value: 'Bought the bay out of the sale barn.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'ride_the_livery',
|
||||
label: 'Ride what the livery gives you.',
|
||||
result:
|
||||
'A different animal every week, and you learn something about each ' +
|
||||
'one of them in the first mile.',
|
||||
effects: [{ path: 'skills.trade', op: 'add', value: 1 }],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'the_ellis_room',
|
||||
stage: 'deputy',
|
||||
weight: 45,
|
||||
cooldown: 6,
|
||||
requires: [{ path: 'lifestyle', op: '==', value: 1 }],
|
||||
description:
|
||||
'The room over the Ellis place is empty again: a stove, a door that ' +
|
||||
'locks, and a window over the street. The Cordwood charges a deputy the ' +
|
||||
'same as a teamster, and says so to both of them.',
|
||||
options: [
|
||||
{
|
||||
id: 'take_the_room',
|
||||
label: 'Take the room over the Ellis place, with the stove.',
|
||||
requires: [{ path: 'money', op: '>=', value: 150 }],
|
||||
requires: [
|
||||
{ path: 'lifestyle', op: '==', value: 1 },
|
||||
{ path: 'money', op: '>=', value: 150 },
|
||||
],
|
||||
result:
|
||||
'A door that locks, a stove, and a window over the street. The ' +
|
||||
'Cordwood does not hold it against you; the Cordwood holds nothing ' +
|
||||
@@ -912,15 +630,12 @@ export const deputy = [
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'stay_at_the_cordwood',
|
||||
label: 'Stay at the Cordwood.',
|
||||
id: 'ride_the_livery',
|
||||
label: 'Ride what the livery gives you.',
|
||||
result:
|
||||
'The walls are thin and the talk comes through them, and some of what ' +
|
||||
'comes through is worth a deputy hearing.',
|
||||
effects: [
|
||||
{ path: 'skills.trade', op: 'add', value: 1 },
|
||||
{ path: 'relationships.dutch.trusts_you', op: 'add', value: 2 },
|
||||
],
|
||||
'A different animal every week, and you learn something about each ' +
|
||||
'one of them in the first mile.',
|
||||
effects: [{ path: 'skills.trade', op: 'add', value: 1 }],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -99,16 +99,14 @@ export const hardTimes = [
|
||||
'book and yours is one of them. He mentions this to you in the street, ' +
|
||||
'in daylight, in front of two other people.',
|
||||
options: [
|
||||
// Both payments take what is owed up to their size, never more — money
|
||||
// first, because both effects read `owed` before it moves. See #38.
|
||||
{
|
||||
id: 'pay_chunk',
|
||||
label: 'Pay a real piece of it down, up to forty-five dollars.',
|
||||
label: 'Pay a real piece of it down.',
|
||||
requires: [{ path: 'money', op: '>=', value: 45 }],
|
||||
result: 'He rules a line through most of it and is disappointed about it.',
|
||||
effects: [
|
||||
{ path: 'money', op: 'add', value: { from: 'owed', times: -1, cap: 45 } },
|
||||
{ path: 'owed', op: 'add', value: { from: 'owed', times: -1, cap: 45 } },
|
||||
{ path: 'money', op: 'add', value: -45 },
|
||||
{ path: 'owed', op: 'add', value: -45 },
|
||||
{ path: 'standing', op: 'add', value: 2 },
|
||||
],
|
||||
},
|
||||
@@ -116,11 +114,10 @@ export const hardTimes = [
|
||||
id: 'pay_minimum',
|
||||
label: 'Pay what you have on you.',
|
||||
requires: [{ path: 'money', op: '>=', value: 12 }],
|
||||
result: 'Up to ten dollars against the line, and two dollars of coffee while you are in there.',
|
||||
result: 'Twelve dollars, ten of which is the line and two of which is coffee.',
|
||||
effects: [
|
||||
{ path: 'money', op: 'add', value: -2 },
|
||||
{ path: 'money', op: 'add', value: { from: 'owed', times: -1, cap: 10 } },
|
||||
{ path: 'owed', op: 'add', value: { from: 'owed', times: -1, cap: 10 } },
|
||||
{ path: 'money', op: 'add', value: -12 },
|
||||
{ path: 'owed', op: 'add', value: -10 },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -10,58 +10,6 @@
|
||||
// than a mistake.
|
||||
|
||||
export const promotion = [
|
||||
{
|
||||
// The first real player asked, thirty turns in, how they would know they
|
||||
// were getting close. Nothing said. This does, in the town's own terms: it
|
||||
// names what the offer is waiting for without printing a threshold. It
|
||||
// comes once, somewhat before the offer can, and only to a hand already
|
||||
// heading that way. See DECISIONS #40.
|
||||
id: 'ward_takes_notice',
|
||||
stage: 'hand',
|
||||
weight: 300,
|
||||
once: true,
|
||||
requires: [
|
||||
{ path: 'turn', op: '>=', value: 24 },
|
||||
{ path: 'standing', op: '>=', value: 55 },
|
||||
],
|
||||
description:
|
||||
'Ward stops at the yard gate on his way past and says, to nobody in ' +
|
||||
'particular, that the county will want a second man by winter. The man ' +
|
||||
'will need a name the street says for the right reasons, and he will ' +
|
||||
'need to be one Ward can take at his word — or one who has already ' +
|
||||
'walked into trouble on the Ridge road and walked back out of it.',
|
||||
options: [
|
||||
{
|
||||
id: 'ask_what_it_takes',
|
||||
label: 'Ask him how long a man would have to wait.',
|
||||
result:
|
||||
'He says not before the fortieth day of anybody’s employment, and ' +
|
||||
'that he is not talking about anybody in particular, and goes on up ' +
|
||||
'the street.',
|
||||
effects: [
|
||||
{ path: 'relationships.ward.trusts_you', op: 'add', value: 3 },
|
||||
{ path: 'notable', op: 'push', value: 'Heard from Ward that the county would want a second man.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'happy_in_the_yard',
|
||||
label: 'Say you are happy in the yard.',
|
||||
result: 'Vane, in the office doorway, hears you say it.',
|
||||
effects: [
|
||||
{ path: 'relationships.hollis.trusts_you', op: 'add', value: 3 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'remember_it',
|
||||
label: 'Say nothing, and remember it.',
|
||||
result: 'He did not expect an answer, and seems to like not getting one.',
|
||||
effects: [
|
||||
{ path: 'skills.nerve', op: 'add', value: 1 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'deputy_offer',
|
||||
stage: 'hand',
|
||||
|
||||
@@ -243,7 +243,7 @@ export const routine = [
|
||||
},
|
||||
{
|
||||
id: 'stay_out',
|
||||
label: 'Stay out of it, and take neither side about the axle.',
|
||||
label: 'Have no view on the axle.',
|
||||
result: 'Both of them decide, separately, that you are no use to them.',
|
||||
effects: [
|
||||
{ path: 'relationships.dutch.trusts_you', op: 'add', value: -4 },
|
||||
@@ -298,7 +298,7 @@ export const routine = [
|
||||
},
|
||||
{
|
||||
id: 'pay_down',
|
||||
label: 'Pay the book down, up to twenty dollars.',
|
||||
label: 'Put money against the book.',
|
||||
// Hidden rather than greyed out when there is nothing owed. A locked
|
||||
// door is worth showing when it is a door the player could open later
|
||||
// by playing differently; "pay down what you owe" shown to a man who
|
||||
@@ -310,13 +310,9 @@ export const routine = [
|
||||
{ path: 'money', op: '>=', value: 20 },
|
||||
],
|
||||
result: 'He crosses a line through it and turns the page back over.',
|
||||
// What is owed, up to twenty, and never more. A flat -20 against a
|
||||
// four-dollar balance took sixteen dollars for nothing in a real
|
||||
// session. Money comes first: both effects read `owed` before it moves.
|
||||
// See DECISIONS #38.
|
||||
effects: [
|
||||
{ path: 'money', op: 'add', value: { from: 'owed', times: -1, cap: 20 } },
|
||||
{ path: 'owed', op: 'add', value: { from: 'owed', times: -1, cap: 20 } },
|
||||
{ path: 'money', op: 'add', value: -20 },
|
||||
{ path: 'owed', op: 'add', value: -20 },
|
||||
{ path: 'standing', op: 'add', value: 2 },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -20,7 +20,7 @@ export const frontier = {
|
||||
blurb:
|
||||
'Cold Fork, at the end of a stage line: a freight yard, a road that is ' +
|
||||
'not safe, and a marshal who has been six years retiring.',
|
||||
version: '0.1.1',
|
||||
version: '0.1.0',
|
||||
turnUnit: 'day',
|
||||
startingStage: 'hand',
|
||||
peopleHeading: 'Cold Fork',
|
||||
@@ -100,18 +100,15 @@ export const frontier = {
|
||||
offset: 3,
|
||||
effects: [{ path: 'money', op: 'add', value: -8 }],
|
||||
},
|
||||
// Labelled as wages, because they are. "Settlement" read as jargon to the
|
||||
// first real player, who could not tell it was the twenty-four a run the
|
||||
// first morning quotes; the ledger is written for a player (DECISIONS #27).
|
||||
{
|
||||
label: 'Wages from Vane',
|
||||
label: 'Settlement',
|
||||
every: 4,
|
||||
offset: 4,
|
||||
requires: [{ path: 'stage', op: '==', value: 'hand' }],
|
||||
effects: [{ path: 'money', op: 'add', value: 24 }],
|
||||
},
|
||||
{
|
||||
label: 'Wages from the county',
|
||||
label: 'Deputy pay',
|
||||
every: 4,
|
||||
offset: 4,
|
||||
// The town pays a real wage, and it is still not much of a living. What
|
||||
|
||||
+1
-2
@@ -10,9 +10,8 @@
|
||||
|
||||
import { corporateLadder } from './corporateladder/index.js';
|
||||
import { frontier } from './frontier/index.js';
|
||||
import { countingHouse } from './countinghouse/index.js';
|
||||
|
||||
export const packs = [corporateLadder, frontier, countingHouse];
|
||||
export const packs = [corporateLadder, frontier];
|
||||
|
||||
/** A pack by id, or undefined. Used to resume into whatever was last played. */
|
||||
export function packById(id) {
|
||||
|
||||
@@ -656,201 +656,3 @@ promoted is not independent of how they spend, so the comparison mostly measured
|
||||
the mix. It inverted the moment there was something to spend money on. It is now
|
||||
derived from upkeep arithmetic, and the lifestyle's effect is tested with two
|
||||
players who differ in exactly one habit.
|
||||
|
||||
## 38. A repayment pays what is owed, never more
|
||||
|
||||
Recorded 2026-09-15, for 0.3.4. Designer's call: a small engine addition rather
|
||||
than a content workaround.
|
||||
|
||||
The first human session of the Frontier on a current build — 112 turns — owed the
|
||||
store four dollars on turn 27 and chose "Put money against the book". It took
|
||||
twenty dollars and cleared four: the option was a flat `money -20, owed -20`, and
|
||||
`owed` clamped at zero while `money` did not. The ledger showed "−$4 at its
|
||||
limit", which the player read, reasonably, as a debt still standing.
|
||||
|
||||
The same shape was in five options across both packs — the Frontier's store and
|
||||
both of its hard-times repayments, and both of Corporate Ladder's loan
|
||||
repayments — and none had been caught, because every archetype that pays debt
|
||||
pays large debts.
|
||||
|
||||
**Content could not fix it.** Effects add or set a number the pack wrote, and a
|
||||
balance built from +14, +32, +4, −45 and −10 can be anything, so no fixed payment
|
||||
can equal it. The alternatives were to gate each instalment on the balance
|
||||
covering it (leaving small balances to cost more than they are) or to retune
|
||||
every credit amount to a common step (changing tuned numbers to suit a missing
|
||||
primitive).
|
||||
|
||||
**So an `add` effect's value may now be read from state:**
|
||||
`{ path: 'money', op: 'add', value: { from: 'owed', times: -1, cap: 20 } }` is
|
||||
"take away whatever is owed, but no more than 20". The amount is resolved at the
|
||||
moment the effect applies, so a repayment lists the money effect before the
|
||||
balance effect — both read the balance before it moves. The change record keeps
|
||||
the number actually applied, with the reference in `source`.
|
||||
|
||||
This is not the engine learning about debt. It reads a number from a path without
|
||||
knowing what the path means, exactly as a condition does, and #1 and #2 still
|
||||
hold: it is data, validated at load. The validator requires the referenced path to
|
||||
be declared and `times` and `cap` to be numbers. The test archetypes score such an
|
||||
effect at its cap, the most a player reading the label could expect.
|
||||
|
||||
A conformance test now holds every pack to it: an option that spends money and
|
||||
reduces a balance — any money-formatted stat other than money — must read the
|
||||
amount from the balance, or be gated on the balance covering the whole instalment.
|
||||
|
||||
## 39. A hidden option is not a locked door in the log either
|
||||
|
||||
`whenLocked: 'hide'` (#37) kept "Put money against the book" off the screen of a
|
||||
player who owed nothing, and it worked. But the turn record listed offered options
|
||||
without saying which were hidden, so `npm run analyze` reported the option as a
|
||||
locked door shown three times to a player who never saw it. The instrument was
|
||||
counting the very thing the fix removed.
|
||||
|
||||
The turn record now carries `hidden` for every offered option, and the analyser
|
||||
skips hidden options when counting locked doors. Logs exported before 0.3.4 do not
|
||||
have the field and still over-count.
|
||||
|
||||
The same session's exports also put a save file next to the feedback log in
|
||||
`logs/`. The analyser read "the newest `.json`", so whichever of the two was
|
||||
exported last would have been read as the log. It now reads only
|
||||
`theladder-feedback-*.json` when no file is named.
|
||||
|
||||
## 40. What the first Frontier playtest changed
|
||||
|
||||
The session (112 turns, 0.3.3, promoted on turn 44) confirmed the systems #36 and
|
||||
#37 were built for: both lifestyle purchases were found and taken, the standing
|
||||
charge fired as designed, money never went below zero, and the run ended flat —
|
||||
the break-even the Frontier intends at the top. Everything it found wrong was
|
||||
legibility, plus the defect in #38. All eight of the player's comments were
|
||||
questions about what a number meant, and both thumbs-down were on events that
|
||||
confused them.
|
||||
|
||||
- **Wages are called wages.** "Settlement" and "Deputy pay" became "Wages from
|
||||
Vane" and "Wages from the county". The player could not connect "Settlement"
|
||||
to the twenty-four a run the first morning quotes, and read a run's own +$9 as
|
||||
the pay going missing (#27: the ledger is written for a player).
|
||||
- **A clamped change says where it stopped** — "stopped at $0", "stopped at 100" —
|
||||
instead of "at its limit", which was read as a balance still outstanding.
|
||||
- **Two option labels were rewritten** that the player could not parse: "Have no
|
||||
view on the axle" and "take hold of the nearest one".
|
||||
- **The horse and the room are separate events.** One event offered both, with a
|
||||
description that was always about the horse, so the room was offered under
|
||||
horse text and "Buy the bay" was shown locked to a player who had bought it — a
|
||||
door that could never open. Each is now gated on the tier it sells, and a test
|
||||
holds that neither is offered once bought.
|
||||
- **The promotion gets a warning.** "How do I find out how close I am to
|
||||
promotion?" had no answer anywhere. `ward_takes_notice` comes once, to a hand
|
||||
past turn 24 with standing 55, and says in the town's terms what the offer waits
|
||||
for — a name the street says for the right reasons, and a man Ward takes at his
|
||||
word or one with the Ridge road behind him — without printing a threshold. Across
|
||||
72 archetype runs it fired in 67, at a median of turn 29, always before the offer.
|
||||
A numeric progress display was not built: it would need the interface to know
|
||||
what a promotion is, which #22 keeps in content.
|
||||
- **The office got five more days.** 69 deputy turns saw 13 distinct situations,
|
||||
and `office_day` and `the_cells` were 17% of the stage each — the same thinness
|
||||
#24 caught in Dispatch. Five events were added (the stage in short, the county's
|
||||
fees, a bounty hunter, a fire at the store, Dutch wanting the Ridge road) and the
|
||||
horse/room split adds a sixth. Measured across 72 archetype runs of 250 turns,
|
||||
the three floor events fell from about 17.5% of deputy turns each to about
|
||||
13.5%. Every new event has an ungated way through, and its skill gates (nerve 30
|
||||
and 34, trade 28 and 30) sit under growth available without them (#32).
|
||||
|
||||
**Measured and not yet understood.** In the same simulation, promoted runs now end
|
||||
with a median of $164 rather than $88, and a median standing of 89 rather than 98.
|
||||
Two changes plausibly account for it — repayments no longer overcharge, and lower
|
||||
standing triggers the $4 standing charge less often — but it has not been
|
||||
isolated. The declared baselines and every tuning test still pass. The Frontier is
|
||||
meant to be roughly level at the top, so the next Frontier session should watch
|
||||
whether money stops mattering again, as it did in Corporate Ladder before #36.
|
||||
|
||||
"Owed at the store" was asked for as "a major field at the top". It already is,
|
||||
second after Money whenever anything is owed. Whether it is prominent enough can
|
||||
only be judged in a browser, which this machine cannot render (#18).
|
||||
|
||||
## 41. Chance, and a cast that changes between runs
|
||||
|
||||
Recorded 2026-09-23. Designer's call: a change to the base engine, generic, which
|
||||
every pack may use and none has to. Corporate Ladder and the Frontier use none of
|
||||
it and replay exactly as before.
|
||||
|
||||
Until now an option did exactly what it said. Skills only decided whether an
|
||||
option was on the menu (#15, #32). The story-to-pack catalogue of recurring
|
||||
situations (asking for a rise, the door to the powerful, a colleague's needling)
|
||||
is made of moments whose outcome depends on who you are and who you are dealing
|
||||
with, so three things were added. `content/countinghouse` is the pack that exercises them.
|
||||
|
||||
**Attributes.** A pack names them in `pack.attributes`. The player's live in
|
||||
ordinary declared state (`attributes.<id>`), so they grow by effects like anything
|
||||
else. The prototype's four, agreed with the designer, are Nerve, Wits, Charm and
|
||||
Resolve. Another pack may rename them, as #14 lets it rename the interface.
|
||||
|
||||
**Checks.** An option may carry `check: { attribute, against?, opposedBy?,
|
||||
relationship?, difficulty?, modifier? }` and then must carry `success` and
|
||||
`failure`, each with its own `result` and `effects`. Its plain `effects` apply
|
||||
either way. The chance is
|
||||
|
||||
base + perPoint × (yours − theirs) + relationshipPerPoint × (their regard − centre) + modifier,
|
||||
clamped to [floor, ceiling]
|
||||
|
||||
where "theirs" is the opposing character's attribute, or the check's `difficulty`,
|
||||
or the pack's `unopposed`. Every number is the pack's, in `pack.checks` (#9).
|
||||
**The player never sees a number.** Options show the odds in words ("a long shot",
|
||||
"likely"), from bands the pack may rename, and the outcome says whether it came
|
||||
off. The roll is one draw from the run's own generator, so a run with checks still
|
||||
replays exactly from its seed and choices (#5, #6). A pack that never checks draws
|
||||
nothing extra.
|
||||
|
||||
**A cast drawn per run.** A character may be a *role* with `candidates`, several
|
||||
authored people who could fill it, and one is drawn when a run starts. A role may
|
||||
be a *group* with `count: [min, max]` (two to four clerks), which draws that many
|
||||
different people into numbered slots (`clerk_1` .. `clerk_4`). Every candidate
|
||||
carries a name, a description and the same attributes the player has, so a
|
||||
replayed pack meets a different office with different strengths. Candidates are
|
||||
authored rather than generated from random parts: a character written as sharp
|
||||
has the wits to see through a bluff, which a random attribute roll pasted onto a
|
||||
random description would not guarantee.
|
||||
|
||||
- **The draw is saved in engine-owned state** (`cast`, now a reserved root, #4)
|
||||
and made on its own stream derived from the seed, so it neither moves the event
|
||||
generator nor depends on it. `hydrate` (#36) draws one for a save that predates
|
||||
it, and draws the same one every time.
|
||||
- **Every slot up to a group's maximum is declared in the schema from the start.**
|
||||
Relationship state for `clerk_3` exists whether or not anyone was drawn into it,
|
||||
so it validates and saves like any other path. An empty slot is recorded as
|
||||
`null` and is simply absent from the run.
|
||||
- **Text names people by role.** `{partner}`, `{chief.first}` and `{clerk.title}`
|
||||
are filled in from whoever was drawn. An event about "a clerk" says
|
||||
`involves: ['clerk']`. When it is drawn, one present clerk is bound to it for
|
||||
the turn, and `{clerk}` in its text, `relationships.{clerk}.trust` in its effects
|
||||
and `against: '{clerk}'` in a check all mean that person. The binding is saved
|
||||
with the run. An event cannot fire in a run with nobody to bind. Text kept in
|
||||
state, such as a notable moment, has its placeholders pinned to the slot
|
||||
(`{clerk_2}`), so the retrospective names the right person later.
|
||||
- **The validator holds all of it:**
|
||||
- attributes declared, and every drawn person carrying all of them;
|
||||
- enough candidates for a group's maximum, and unique names;
|
||||
- checks naming real attributes and people, with both branches;
|
||||
- `{placeholders}` that name real characters, where a group is only named inside an event that binds it;
|
||||
- no placeholder in an event's own `requires`, which is evaluated before anyone is bound.
|
||||
|
||||
**What the test harness had to learn.** The archetypes score an option by its
|
||||
effects (#21). A checked option is now scored at its expected value: plain effects
|
||||
in full, and each branch weighted by its chance, the way a player reading the odds
|
||||
would. The conformance check that no repayment takes more than is owed (#38) now
|
||||
reads both branches, since a check can pay or spend on either.
|
||||
|
||||
**The prototype pack was tuned by measurement, not by eye.** Its first draft
|
||||
failed three conformance tests:
|
||||
- the routine ledgers were a third of every player's turns;
|
||||
- a careless player was broke 40% of a run;
|
||||
- it had no balance a borrower could owe.
|
||||
|
||||
Two more catalogue situations (an errand for the partner's family, and a desk you
|
||||
and a clerk both want), a lighter floor event, a loan that is refused but still
|
||||
helps a little, and an "owed to the floor" balance repaid on Saturdays brought
|
||||
those to 22% at worst, 12% at worst, and a debt that is repaid by reading the
|
||||
balance (#38).
|
||||
|
||||
**Not yet known.** Whether chance makes the same situations worth replaying, and
|
||||
whether the odds in words are legible, can only be judged by someone playing it
|
||||
(#18). The pack has one stage on purpose, so that question can be answered before
|
||||
a career is built on it.
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "theladder",
|
||||
"version": "0.4.0",
|
||||
"version": "0.3.3",
|
||||
"private": true,
|
||||
"description": "A turn-based career simulation game. Engine is theme-agnostic; settings are content packs.",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
// Who is in this run.
|
||||
//
|
||||
// A pack's characters are fixed people by default: a name and a description,
|
||||
// written once. A character may instead be a *role* with `candidates` — several
|
||||
// authored people who could fill it — and one is drawn when a run starts. A role
|
||||
// may also be a *group* with a `count` range, like two to four co-workers, in
|
||||
// which case that many different candidates are drawn into numbered slots.
|
||||
// Replaying a pack therefore meets a different cast, which is the point.
|
||||
//
|
||||
// Slots are what state is keyed by: a single role is its own id (`boss`), a
|
||||
// group's members are `coworker_1` .. `coworker_<max>`. Every slot up to the
|
||||
// group's maximum exists in the schema from the start, so relationship state
|
||||
// is declared, validated and saved like any other; a slot nobody was drawn into
|
||||
// is simply not present in the run. See DECISIONS #41.
|
||||
//
|
||||
// The draw uses its own stream derived from the run's seed, so it neither moves
|
||||
// the event generator nor depends on it: a pack that gains a cast does not
|
||||
// change which events any other pack draws, and a save that predates the cast
|
||||
// can have one drawn for it on load, reproducibly.
|
||||
|
||||
import { createRng } from './rng.js';
|
||||
import { getPath } from './paths.js';
|
||||
|
||||
/** The count range of a group role, or null for a single character. */
|
||||
export function groupRange(character) {
|
||||
if (character.count === undefined) return null;
|
||||
return Array.isArray(character.count) ? character.count : [character.count, character.count];
|
||||
}
|
||||
|
||||
/** Every state slot a character occupies: its own id, or one per possible group member. */
|
||||
export function slotsOf(character) {
|
||||
const range = groupRange(character);
|
||||
if (!range) return [character.id];
|
||||
return Array.from({ length: range[1] }, (_, i) => `${character.id}_${i + 1}`);
|
||||
}
|
||||
|
||||
/** Whether a pack draws any part of its cast per run. */
|
||||
export function hasDrawnCast(pack) {
|
||||
return (pack.characters ?? []).some((c) => c.candidates?.length || groupRange(c));
|
||||
}
|
||||
|
||||
/** The authored person behind a candidate entry, in the shape the state keeps. */
|
||||
function personOf(character, candidate, index) {
|
||||
return {
|
||||
role: character.id,
|
||||
candidate: index,
|
||||
name: candidate.name,
|
||||
title: candidate.title ?? character.title ?? null,
|
||||
description: candidate.description ?? '',
|
||||
attributes: { ...(candidate.attributes ?? {}) },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw the cast for a run. Returns `{ <slot>: person | null }` for every slot of
|
||||
* every drawn role; a group slot left empty is `null`, which is how "not in this
|
||||
* run" is recorded. Fixed characters are not in it: they need no drawing.
|
||||
*/
|
||||
export function drawCast(pack, seed) {
|
||||
const rng = createRng(`cast:${seed}`);
|
||||
const cast = {};
|
||||
for (const character of pack.characters ?? []) {
|
||||
const pool = character.candidates ?? [];
|
||||
const range = groupRange(character);
|
||||
if (!range && !pool.length) continue;
|
||||
|
||||
const slots = slotsOf(character);
|
||||
const wanted = range ? range[0] + rng.int(range[1] - range[0] + 1) : 1;
|
||||
const remaining = pool.map((candidate, index) => ({ candidate, index }));
|
||||
for (let i = 0; i < slots.length; i++) {
|
||||
if (i >= wanted || !remaining.length) {
|
||||
cast[slots[i]] = null;
|
||||
continue;
|
||||
}
|
||||
const [picked] = remaining.splice(rng.int(remaining.length), 1);
|
||||
cast[slots[i]] = personOf(character, picked.candidate, picked.index);
|
||||
}
|
||||
}
|
||||
return cast;
|
||||
}
|
||||
|
||||
/** Starting relationship overrides a drawn person brings with them. */
|
||||
export function drawnRelationships(pack, cast) {
|
||||
const overrides = [];
|
||||
for (const character of pack.characters ?? []) {
|
||||
for (const slot of slotsOf(character)) {
|
||||
const person = cast?.[slot];
|
||||
if (!person) continue;
|
||||
const candidate = character.candidates?.[person.candidate];
|
||||
for (const [stat, value] of Object.entries(candidate?.startingRelationship ?? {})) {
|
||||
overrides.push({ path: `relationships.${slot}.${stat}`, value });
|
||||
}
|
||||
}
|
||||
}
|
||||
return overrides;
|
||||
}
|
||||
|
||||
/** The character definition that owns a slot. */
|
||||
export function characterForSlot(pack, slot) {
|
||||
for (const character of pack.characters ?? []) {
|
||||
if (slotsOf(character).includes(slot)) return character;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The person in a slot as the interface and the checks see them: name, title,
|
||||
* description and attributes, whether drawn for this run or written into the
|
||||
* pack. Undefined if the slot is empty this run or unknown.
|
||||
*/
|
||||
export function personIn(pack, state, slot) {
|
||||
const character = characterForSlot(pack, slot);
|
||||
if (!character) return undefined;
|
||||
const drawn = getPath(state, `cast.${slot}`);
|
||||
if (drawn === null) return undefined;
|
||||
if (drawn) return { id: slot, ...drawn };
|
||||
if (character.candidates?.length || groupRange(character)) return undefined;
|
||||
return {
|
||||
id: slot,
|
||||
role: character.id,
|
||||
name: character.name,
|
||||
title: character.title ?? null,
|
||||
description: character.description ?? '',
|
||||
attributes: { ...(character.attributes ?? {}) },
|
||||
};
|
||||
}
|
||||
|
||||
/** Everyone present in this run, in pack order. */
|
||||
export function presentPeople(pack, state) {
|
||||
return (pack.characters ?? []).flatMap((character) =>
|
||||
slotsOf(character).map((slot) => personIn(pack, state, slot)).filter(Boolean));
|
||||
}
|
||||
|
||||
/** The slots of a role that are filled in this run. */
|
||||
export function presentSlots(pack, state, roleId) {
|
||||
const character = (pack.characters ?? []).find((c) => c.id === roleId);
|
||||
if (!character) return [];
|
||||
return slotsOf(character).filter((slot) => personIn(pack, state, slot));
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
// Options whose outcome is not certain.
|
||||
//
|
||||
// An option may carry a `check`: which of the player's attributes it tests,
|
||||
// optionally *against* a character who opposes it with their own attribute, and
|
||||
// optionally leaning on how that character regards the player. It then has a
|
||||
// `success` and a `failure`, each with its own result text and effects; the
|
||||
// option's plain `effects` apply either way. The engine works out the chance,
|
||||
// rolls on the run's own generator, and records which way it went.
|
||||
//
|
||||
// The engine knows the arithmetic and nothing about what any attribute means:
|
||||
// the numbers are the pack's (`pack.checks`), the attribute names are the
|
||||
// pack's, and so are the words the player sees in place of a percentage. A
|
||||
// player is shown "a long shot" or "likely", never a number. See DECISIONS #41.
|
||||
//
|
||||
// chance = base + perPoint × (yours − theirs)
|
||||
// + relationshipPerPoint × (their regard − relationshipCentre)
|
||||
// + modifier, clamped to [floor, ceiling]
|
||||
//
|
||||
// where "theirs" is the opposing character's attribute when the check is
|
||||
// `against` someone, and the check's `difficulty` (or the pack's `unopposed`)
|
||||
// when it is not.
|
||||
|
||||
import { getPath } from './paths.js';
|
||||
import { personIn } from './cast.js';
|
||||
import { resolveSlot } from './text.js';
|
||||
|
||||
/** Odds words used when a pack does not name its own. Upper bounds, in percent. */
|
||||
export const DEFAULT_ODDS = [
|
||||
{ upTo: 20, words: 'a long shot' },
|
||||
{ upTo: 40, words: 'unlikely' },
|
||||
{ upTo: 60, words: 'an even chance' },
|
||||
{ upTo: 80, words: 'likely' },
|
||||
{ upTo: 100, words: 'a safe bet' },
|
||||
];
|
||||
|
||||
/** The words for a chance, from the pack's bands. */
|
||||
export function oddsWords(pack, chance) {
|
||||
const bands = pack.checks?.odds ?? DEFAULT_ODDS;
|
||||
return (bands.find((band) => chance <= band.upTo) ?? bands[bands.length - 1]).words;
|
||||
}
|
||||
|
||||
/** The slot a check is made against this turn, if any. */
|
||||
function opponentSlot(check, bindings) {
|
||||
if (!check.against) return undefined;
|
||||
const id = check.against.replace(/^\{|\}$/g, '');
|
||||
return resolveSlot(id, bindings);
|
||||
}
|
||||
|
||||
/**
|
||||
* The chance of an option's check succeeding in this state, as a whole
|
||||
* percentage, with the parts it was built from. Undefined for an option with
|
||||
* no check.
|
||||
*/
|
||||
export function chanceOf(pack, state, option, bindings = state?.meta?.bindings ?? {}) {
|
||||
const check = option.check;
|
||||
if (!check) return undefined;
|
||||
const rules = pack.checks ?? {};
|
||||
const yours = getPath(state, `attributes.${check.attribute}`) ?? 0;
|
||||
|
||||
const slot = opponentSlot(check, bindings);
|
||||
const opponent = slot ? personIn(pack, state, slot) : undefined;
|
||||
const theirs = opponent
|
||||
? opponent.attributes?.[check.opposedBy ?? check.attribute] ?? 0
|
||||
: check.difficulty ?? rules.unopposed ?? 0;
|
||||
|
||||
let regard = 0;
|
||||
if (check.relationship && slot) {
|
||||
const value = getPath(state, `relationships.${slot}.${check.relationship}`);
|
||||
if (typeof value === 'number') {
|
||||
regard = (rules.relationshipPerPoint ?? 0) * (value - (rules.relationshipCentre ?? 50));
|
||||
}
|
||||
}
|
||||
|
||||
const raw = (rules.base ?? 50) + (rules.perPoint ?? 0) * (yours - theirs) + regard + (check.modifier ?? 0);
|
||||
const chance = Math.round(Math.min(rules.ceiling ?? 95, Math.max(rules.floor ?? 5, raw)));
|
||||
return { chance, words: oddsWords(pack, chance), yours, theirs, against: slot ?? null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Roll a check. `rng` is the run's generator; exactly one number is drawn, so
|
||||
* a replay from the same seed and choices rolls the same way.
|
||||
*/
|
||||
export function rollCheck(pack, state, option, rng, bindings) {
|
||||
const odds = chanceOf(pack, state, option, bindings);
|
||||
const roll = rng.next() * 100;
|
||||
return { ...odds, succeeded: roll < odds.chance };
|
||||
}
|
||||
Vendored
+5
-32
@@ -25,50 +25,23 @@ function clamp(value, rule) {
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Whether an effect's value is a reference to state rather than a literal. */
|
||||
export function isAmountFrom(value) {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||
&& typeof value.from === 'string';
|
||||
}
|
||||
|
||||
/**
|
||||
* The amount an `add` effect applies. Usually the number the pack wrote. It may
|
||||
* instead be read from state at the moment the effect applies:
|
||||
*
|
||||
* { path: 'money', op: 'add', value: { from: 'owed', times: -1, cap: 20 } }
|
||||
*
|
||||
* is "take away whatever is owed, but no more than 20". A fixed instalment
|
||||
* cannot pay off a balance that could be anything, and charging the whole
|
||||
* instalment against a smaller balance took a playtester's money for a debt
|
||||
* that did not exist (DECISIONS #38). It is still data, and still generic: the
|
||||
* engine reads a number from a path without knowing what the path means.
|
||||
*/
|
||||
export function resolveAmount(state, value) {
|
||||
if (!isAmountFrom(value)) return value;
|
||||
const current = getPath(state, value.from);
|
||||
let amount = typeof current === 'number' ? current : 0;
|
||||
if (typeof value.cap === 'number') amount = Math.min(amount, value.cap);
|
||||
return amount * (typeof value.times === 'number' ? value.times : 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply one effect. `schema` is the resolved state schema, consulted only for
|
||||
* clamping — the engine still has no idea what any given path means.
|
||||
*/
|
||||
export function applyEffect(state, effect, schema = {}) {
|
||||
const { path, op } = effect;
|
||||
const { path, op, value } = effect;
|
||||
const operate = OPS[op];
|
||||
if (!operate) throw new TypeError(`unknown effect operation ${JSON.stringify(op)}`);
|
||||
|
||||
const value = op === 'add' ? resolveAmount(state, effect.value) : effect.value;
|
||||
const from = getPath(state, path);
|
||||
const raw = operate(from, value);
|
||||
const to = clamp(raw, schema[path]);
|
||||
|
||||
const change = { path, op, value, from, to, clamped: raw !== to };
|
||||
// The log keeps the number actually applied, and says where it came from.
|
||||
if (value !== effect.value) change.source = effect.value;
|
||||
return { state: setPath(state, path, to), change };
|
||||
return {
|
||||
state: setPath(state, path, to),
|
||||
change: { path, op, value, from, to, clamped: raw !== to },
|
||||
};
|
||||
}
|
||||
|
||||
/** Apply a list of effects in order. Returns the new state and every change. */
|
||||
|
||||
+4
-40
@@ -9,10 +9,8 @@ import { createRng } from './rng.js';
|
||||
import { applyEffects } from './effects.js';
|
||||
import { check } from './conditions.js';
|
||||
import { resolveSchema, createState } from './state.js';
|
||||
import { selectEvent, resolveOptions, findOption, bindRoles } from './select.js';
|
||||
import { selectEvent, resolveOptions, findOption } from './select.js';
|
||||
import { setPath, getPath } from './paths.js';
|
||||
import { chanceOf, rollCheck } from './checks.js';
|
||||
import { bindEffects, fillText } from './text.js';
|
||||
|
||||
export class ContentError extends Error {}
|
||||
export class MoveError extends Error {}
|
||||
@@ -30,10 +28,6 @@ function advance(pack, state) {
|
||||
);
|
||||
}
|
||||
let next = setPath(dated, 'meta.currentEventId', event.id);
|
||||
// Which co-worker this event is about, drawn after the event itself. A pack
|
||||
// with no `involves` draws nothing here, so its runs are unchanged.
|
||||
const bindings = bindRoles(pack, next, event, rng);
|
||||
if (Object.keys(bindings).length || next.meta.bindings) next = setPath(next, 'meta.bindings', bindings);
|
||||
next = setPath(next, 'meta.rngPosition', rng.getPosition());
|
||||
return next;
|
||||
}
|
||||
@@ -107,10 +101,7 @@ export function currentEvent(pack, state) {
|
||||
/** The current event together with each option's availability. */
|
||||
export function currentTurn(pack, state) {
|
||||
const event = currentEvent(pack, state);
|
||||
const options = resolveOptions(state, event).map((resolved) => (resolved.option.check
|
||||
? { ...resolved, odds: chanceOf(pack, state, resolved.option) }
|
||||
: resolved));
|
||||
return { event, options, bindings: state.meta.bindings ?? {} };
|
||||
return { event, options: resolveOptions(state, event) };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -129,27 +120,7 @@ export function takeTurn(pack, state, optionId, { schema = resolveSchema(pack),
|
||||
throw new MoveError(`option "${optionId}" is locked: ${JSON.stringify(chosen.failed)}`);
|
||||
}
|
||||
|
||||
const bindings = state.meta.bindings ?? {};
|
||||
let effects = bindEffects(option.effects, bindings);
|
||||
let check = null;
|
||||
let rngState = state;
|
||||
if (option.check) {
|
||||
// One number from the run's own generator, so the roll replays with the run.
|
||||
const rng = createRng(state.meta.seed, state.meta.rngPosition ?? undefined);
|
||||
const rolled = rollCheck(pack, state, option, rng, bindings);
|
||||
const outcome = rolled.succeeded ? option.success : option.failure;
|
||||
effects = [...effects, ...bindEffects(outcome?.effects, bindings)];
|
||||
check = {
|
||||
attribute: option.check.attribute,
|
||||
against: rolled.against,
|
||||
chance: rolled.chance,
|
||||
odds: rolled.words,
|
||||
outcome: rolled.succeeded ? 'success' : 'failure',
|
||||
};
|
||||
rngState = setPath(state, 'meta.rngPosition', rng.getPosition());
|
||||
}
|
||||
|
||||
const applied = applyEffects(rngState, effects, schema);
|
||||
const applied = applyEffects(state, option.effects, schema);
|
||||
let next = applied.state;
|
||||
|
||||
const seenCount = getPath(next, `seen.${event.id}`) ?? 0;
|
||||
@@ -164,10 +135,7 @@ export function takeTurn(pack, state, optionId, { schema = resolveSchema(pack),
|
||||
stage: next.stage,
|
||||
eventId: event.id,
|
||||
optionId: option.id,
|
||||
// Names filled in, so the log and the retrospective read as the player saw it.
|
||||
optionLabel: fillText(pack, state, option.label, bindings),
|
||||
...(check ? { check } : {}),
|
||||
...(Object.keys(bindings).length ? { bindings } : {}),
|
||||
optionLabel: option.label,
|
||||
changes: applied.changes,
|
||||
upkeep: upkeep.changes,
|
||||
offered: resolved.map((r) => ({
|
||||
@@ -175,10 +143,6 @@ export function takeTurn(pack, state, optionId, { schema = resolveSchema(pack),
|
||||
label: r.option.label,
|
||||
available: r.available,
|
||||
failed: r.failed,
|
||||
// A hidden option was never on the player's screen. Without this the log
|
||||
// cannot tell it from a locked door they saw, and the analyser counted
|
||||
// one the first real Frontier player never met. See DECISIONS #39.
|
||||
hidden: r.hidden,
|
||||
})),
|
||||
at: now,
|
||||
};
|
||||
|
||||
@@ -7,6 +7,3 @@ export { createState, resolveSchema, indexCharacters, RESERVED_ROOTS } from './s
|
||||
export { eligibleEvents, selectEvent, resolveOptions, findOption } from './select.js';
|
||||
export { startGame, currentEvent, currentTurn, takeTurn, ContentError, MoveError } from './game.js';
|
||||
export { validatePack, assertValidPack } from './validate.js';
|
||||
export { drawCast, presentPeople, presentSlots, personIn, slotsOf, hasDrawnCast } from './cast.js';
|
||||
export { fillText, placeholders, bindEffects, bindConditions } from './text.js';
|
||||
export { chanceOf, rollCheck, oddsWords, DEFAULT_ODDS } from './checks.js';
|
||||
|
||||
+1
-22
@@ -9,21 +9,6 @@
|
||||
|
||||
import { check } from './conditions.js';
|
||||
import { getPath } from './paths.js';
|
||||
import { presentSlots } from './cast.js';
|
||||
import { bindConditions } from './text.js';
|
||||
|
||||
/**
|
||||
* Pick, for each role an event `involves`, one person present in this run. An
|
||||
* event about "a co-worker" is about one particular co-worker for the turn.
|
||||
*/
|
||||
export function bindRoles(pack, state, event, rng) {
|
||||
const bindings = {};
|
||||
for (const role of event.involves ?? []) {
|
||||
const slots = presentSlots(pack, state, role);
|
||||
if (slots.length) bindings[role] = slots.length === 1 ? slots[0] : rng.pick(slots);
|
||||
}
|
||||
return bindings;
|
||||
}
|
||||
|
||||
/** Events that could fire right now, with the reason excluded ones could not. */
|
||||
export function eligibleEvents(pack, state) {
|
||||
@@ -52,11 +37,6 @@ export function eligibleEvents(pack, state) {
|
||||
excluded.push({ event, reason: 'requires', failed: gate.failed });
|
||||
continue;
|
||||
}
|
||||
// An event about a co-worker cannot fire in a run that drew none.
|
||||
if ((event.involves ?? []).some((role) => !presentSlots(pack, state, role).length)) {
|
||||
excluded.push({ event, reason: 'involves' });
|
||||
continue;
|
||||
}
|
||||
eligible.push(event);
|
||||
}
|
||||
|
||||
@@ -80,9 +60,8 @@ export function selectEvent(pack, state, rng) {
|
||||
* strongest signal that the choice set is too narrow.
|
||||
*/
|
||||
export function resolveOptions(state, event) {
|
||||
const bindings = state?.meta?.bindings ?? {};
|
||||
return (event.options ?? []).map((option) => {
|
||||
const gate = check(state, bindConditions(option.requires, bindings));
|
||||
const gate = check(state, option.requires);
|
||||
return {
|
||||
option,
|
||||
available: gate.passed,
|
||||
|
||||
+8
-28
@@ -5,7 +5,6 @@
|
||||
// names no stat of its own beyond its own bookkeeping.
|
||||
|
||||
import { getPath, setPath } from './paths.js';
|
||||
import { slotsOf, drawCast, drawnRelationships, hasDrawnCast } from './cast.js';
|
||||
|
||||
/**
|
||||
* Paths the engine owns. Content may *read* these in conditions — "only after
|
||||
@@ -13,7 +12,7 @@ import { slotsOf, drawCast, drawnRelationships, hasDrawnCast } from './cast.js';
|
||||
* them, which is what keeps engine bookkeeping from being corrupted by data.
|
||||
*/
|
||||
export const RESERVED_ROOTS =
|
||||
new Set(['turn', 'stage', 'seen', 'lastSeen', 'history', 'meta', 'calendar', 'cast']);
|
||||
new Set(['turn', 'stage', 'seen', 'lastSeen', 'history', 'meta', 'calendar']);
|
||||
|
||||
/**
|
||||
* Flatten a pack's state declarations into one path -> rule map, expanding the
|
||||
@@ -28,30 +27,17 @@ export function resolveSchema(pack) {
|
||||
schema[path] = { ...rule };
|
||||
}
|
||||
for (const character of pack.characters ?? []) {
|
||||
// A group role has one slot per possible member (coworker_1 .. coworker_4),
|
||||
// all declared up front so every one is validated and saved. See cast.js.
|
||||
for (const slot of slotsOf(character)) {
|
||||
for (const [stat, rule] of Object.entries(pack.relationshipStats ?? {})) {
|
||||
// A character may start somewhere other than the pack default — a rival
|
||||
// who already resents you, a boss who has not formed an opinion.
|
||||
const override = character.startingRelationship?.[stat];
|
||||
schema[`relationships.${slot}.${stat}`] =
|
||||
override === undefined ? { ...rule } : { ...rule, initial: override };
|
||||
}
|
||||
for (const [stat, rule] of Object.entries(pack.relationshipStats ?? {})) {
|
||||
// A character may start somewhere other than the pack default — a rival
|
||||
// who already resents you, a boss who has not formed an opinion.
|
||||
const override = character.startingRelationship?.[stat];
|
||||
schema[`relationships.${character.id}.${stat}`] =
|
||||
override === undefined ? { ...rule } : { ...rule, initial: override };
|
||||
}
|
||||
}
|
||||
return schema;
|
||||
}
|
||||
|
||||
/** Draw a run's cast into state, with the starting relationships the drawn people bring. */
|
||||
function withCast(pack, state) {
|
||||
if (!hasDrawnCast(pack)) return state;
|
||||
const cast = drawCast(pack, state.meta.seed);
|
||||
let next = setPath(state, 'cast', cast);
|
||||
for (const { path, value } of drawnRelationships(pack, cast)) next = setPath(next, path, value);
|
||||
return next;
|
||||
}
|
||||
|
||||
/** A fresh game state for `pack`, seeded for reproducibility. */
|
||||
export function createState(pack, { seed, schema = resolveSchema(pack) } = {}) {
|
||||
let state = {
|
||||
@@ -72,7 +58,7 @@ export function createState(pack, { seed, schema = resolveSchema(pack) } = {}) {
|
||||
for (const [path, rule] of Object.entries(schema)) {
|
||||
state = setPath(state, path, structuredClone(rule.initial));
|
||||
}
|
||||
return withCast(pack, state);
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -96,12 +82,6 @@ export function hydrate(pack, state, schema = resolveSchema(pack)) {
|
||||
next = setPath(next, path, structuredClone(rule.initial));
|
||||
added.push(path);
|
||||
}
|
||||
// A run saved before its pack drew a cast gets one now, from its own seed, so
|
||||
// the same save always wakes up with the same people.
|
||||
if (hasDrawnCast(pack) && next.cast === undefined) {
|
||||
next = withCast(pack, next);
|
||||
added.push('cast');
|
||||
}
|
||||
return { state: next, added };
|
||||
}
|
||||
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
// Names in text, and which person an event is about.
|
||||
//
|
||||
// Content that meets a different cast each run cannot write "Dutch" into its
|
||||
// prose. It writes `{rival}`, and the name is filled in from whoever was drawn.
|
||||
// `{rival.first}` is the first name, `{rival.title}` the title.
|
||||
//
|
||||
// A group role (two to four co-workers) cannot be named by its role alone —
|
||||
// which one? An event says so with `involves: ['coworker']`, and when the event
|
||||
// is drawn one present member is bound to it for the turn. `{coworker}` in its
|
||||
// text, `relationships.{coworker}.trust` in its effects and `against:
|
||||
// '{coworker}'` in a check all mean that person. The binding is saved with the
|
||||
// run, so a replay binds the same person. See DECISIONS #41.
|
||||
|
||||
import { personIn } from './cast.js';
|
||||
|
||||
const PLACEHOLDER = /\{([A-Za-z0-9_]+)(?:\.(name|first|title))?\}/g;
|
||||
|
||||
/** Every `{id}` / `{id.part}` in a piece of text, as `{ id, part }`. */
|
||||
export function placeholders(text) {
|
||||
if (typeof text !== 'string') return [];
|
||||
return [...text.matchAll(PLACEHOLDER)].map((m) => ({ id: m[1], part: m[2] ?? 'name' }));
|
||||
}
|
||||
|
||||
/** The slot a placeholder id means this turn: a bound group member, or the id itself. */
|
||||
export function resolveSlot(id, bindings = {}) {
|
||||
return bindings[id] ?? id;
|
||||
}
|
||||
|
||||
/** Replace `{id}` in a path or a string with the slot bound to it this turn. */
|
||||
export function bindPath(path, bindings = {}) {
|
||||
if (typeof path !== 'string') return path;
|
||||
return path.replace(/\{([A-Za-z0-9_]+)\}/g, (whole, id) => resolveSlot(id, bindings));
|
||||
}
|
||||
|
||||
/** Fill names into text. Anything it cannot resolve is left as written. */
|
||||
export function fillText(pack, state, text, bindings = state?.meta?.bindings ?? {}) {
|
||||
if (typeof text !== 'string') return text;
|
||||
return text.replace(PLACEHOLDER, (whole, id, part = 'name') => {
|
||||
const person = personIn(pack, state, resolveSlot(id, bindings));
|
||||
if (!person) return whole;
|
||||
if (part === 'first') return person.name.split(' ')[0];
|
||||
if (part === 'title') return person.title ?? '';
|
||||
return person.name;
|
||||
});
|
||||
}
|
||||
|
||||
/** Conditions with any bound placeholders in their paths replaced by the slot. */
|
||||
export function bindConditions(conditions, bindings = {}) {
|
||||
if (!conditions || !Object.keys(bindings).length) return conditions;
|
||||
const walk = (c) => {
|
||||
if (!c || typeof c !== 'object') return c;
|
||||
if (Array.isArray(c)) return c.map(walk);
|
||||
if ('all' in c) return { ...c, all: c.all.map(walk) };
|
||||
if ('any' in c) return { ...c, any: c.any.map(walk) };
|
||||
if ('not' in c) return { ...c, not: walk(c.not) };
|
||||
return { ...c, path: bindPath(c.path, bindings) };
|
||||
};
|
||||
return walk(conditions);
|
||||
}
|
||||
|
||||
/** Effects with any bound placeholders in their paths replaced by the slot. */
|
||||
export function bindEffects(effects, bindings = {}) {
|
||||
return (effects ?? []).map((effect) => {
|
||||
const path = bindPath(effect.path, bindings);
|
||||
let value = effect.value;
|
||||
if (value && typeof value === 'object' && typeof value.from === 'string') {
|
||||
value = { ...value, from: bindPath(value.from, bindings) };
|
||||
} else if (typeof value === 'string') {
|
||||
// Text kept in state (a notable moment) outlives the turn's bindings, so
|
||||
// `{clerk}` is pinned to the slot it meant: `{clerk_2}` still names the
|
||||
// same person when the retrospective reads it back.
|
||||
value = value.replace(PLACEHOLDER, (whole, id, part) =>
|
||||
(bindings[id] ? `{${bindings[id]}${part ? `.${part}` : ''}}` : whole));
|
||||
}
|
||||
return path === effect.path && value === effect.value ? effect : { ...effect, path, value };
|
||||
});
|
||||
}
|
||||
+13
-192
@@ -6,10 +6,8 @@
|
||||
|
||||
import { RESERVED_ROOTS, resolveSchema, indexCharacters } from './state.js';
|
||||
import { OPERATORS, pathsUsed as conditionPaths } from './conditions.js';
|
||||
import { OPERATIONS, isAmountFrom } from './effects.js';
|
||||
import { OPERATIONS } from './effects.js';
|
||||
import { parsePath } from './paths.js';
|
||||
import { groupRange } from './cast.js';
|
||||
import { placeholders } from './text.js';
|
||||
|
||||
const ID = /^[A-Za-z0-9_]+$/;
|
||||
|
||||
@@ -42,108 +40,14 @@ export function validatePack(pack) {
|
||||
}
|
||||
}
|
||||
|
||||
// Attributes are ordinary declared state (`attributes.<id>` for the player);
|
||||
// pack.attributes names them, so checks and characters can be held to the list.
|
||||
const attributeIds = new Set(Object.keys(pack.attributes ?? {}));
|
||||
for (const id of attributeIds) {
|
||||
if (!ID.test(id)) fail(`attribute "${id}"`, `id must match ${ID}`);
|
||||
if (!(`attributes.${id}` in schema)) fail(`attribute "${id}"`, `the player needs "attributes.${id}" in pack.state`);
|
||||
}
|
||||
const checkAttributes = (where, attributes, { complete }) => {
|
||||
for (const key of Object.keys(attributes ?? {})) {
|
||||
if (!attributeIds.has(key)) fail(where, `attribute "${key}" is not in pack.attributes`);
|
||||
else if (typeof attributes[key] !== 'number') fail(where, `attribute "${key}" must be a number`);
|
||||
}
|
||||
if (complete) {
|
||||
for (const id of attributeIds) {
|
||||
if (typeof attributes?.[id] !== 'number') fail(where, `has no "${id}", which every drawn person needs`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const seenCharacters = new Set();
|
||||
const names = new Map();
|
||||
const nameOnce = (where, name) => {
|
||||
if (names.has(name)) fail(where, `name "${name}" is already used by ${names.get(name)}`);
|
||||
else names.set(name, where);
|
||||
};
|
||||
for (const character of pack.characters ?? []) {
|
||||
const where = `character "${character.id}"`;
|
||||
if (!character.id || !ID.test(character.id)) fail(where, `id must match ${ID}`);
|
||||
if (seenCharacters.has(character.id)) fail(where, 'duplicate id');
|
||||
seenCharacters.add(character.id);
|
||||
if (!character.name) fail(where, 'name is required');
|
||||
if (!character.role_type) warn(where, 'no role_type tag; content cannot filter on it');
|
||||
|
||||
const range = groupRange(character);
|
||||
const pool = character.candidates ?? [];
|
||||
if (range) {
|
||||
const [min, max] = range;
|
||||
if (!Number.isInteger(min) || !Number.isInteger(max) || min < 0 || max < 1 || min > max) {
|
||||
fail(where, `count ${JSON.stringify(character.count)} must be [min, max] whole numbers with 0 <= min <= max and max >= 1`);
|
||||
} else if (pool.length < max) {
|
||||
fail(where, `can hold up to ${max} people but has only ${pool.length} candidates`);
|
||||
}
|
||||
}
|
||||
if (range || pool.length) {
|
||||
if (character.name) warn(where, 'has a name as well as candidates; the drawn candidate\'s name is used');
|
||||
pool.forEach((candidate, index) => {
|
||||
const at = `${where} candidate ${index}`;
|
||||
if (!candidate.name) fail(at, 'name is required');
|
||||
else nameOnce(at, candidate.name);
|
||||
if (!candidate.description) warn(at, 'no description');
|
||||
checkAttributes(at, candidate.attributes, { complete: attributeIds.size > 0 });
|
||||
for (const stat of Object.keys(candidate.startingRelationship ?? {})) {
|
||||
if (!(stat in (pack.relationshipStats ?? {}))) fail(at, `startingRelationship names unknown stat "${stat}"`);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
if (!character.name) fail(where, 'name is required');
|
||||
else nameOnce(where, character.name);
|
||||
checkAttributes(where, character.attributes, { complete: false });
|
||||
}
|
||||
}
|
||||
|
||||
const characterById = Object.fromEntries((pack.characters ?? []).map((c) => [c.id, c]));
|
||||
const isGroup = (id) => Boolean(characterById[id] && groupRange(characterById[id]));
|
||||
|
||||
/**
|
||||
* A path or text may name a character as `{id}`. A single character is always
|
||||
* there to name; a group member only when the event binds one with `involves`.
|
||||
* Returns the path with each placeholder replaced by a representative slot, so
|
||||
* it can be checked against the schema like any written path.
|
||||
*/
|
||||
const bindForCheck = (where, text, involves) => {
|
||||
for (const { id } of placeholders(text)) {
|
||||
if (!characterById[id]) fail(where, `{${id}} names no character`);
|
||||
else if (isGroup(id) && !involves.includes(id)) {
|
||||
fail(where, `{${id}} is a group; the event must list "${id}" in involves to say which one`);
|
||||
}
|
||||
}
|
||||
return typeof text === 'string'
|
||||
? text.replace(/\{([A-Za-z0-9_]+)\}/g, (w, id) => (isGroup(id) ? `${id}_1` : id))
|
||||
: text;
|
||||
};
|
||||
|
||||
// Chances are the pack's numbers, like every other tuning value (#9).
|
||||
const usesChecks = (pack.events ?? []).some((e) => (e.options ?? []).some((o) => o.check));
|
||||
if (usesChecks) {
|
||||
const rules = pack.checks;
|
||||
if (!rules) fail('checks', 'options carry checks but the pack declares no checks block');
|
||||
else {
|
||||
for (const key of ['base', 'perPoint', 'floor', 'ceiling']) {
|
||||
if (typeof rules[key] !== 'number') fail('checks', `${key} must be a number`);
|
||||
}
|
||||
if (rules.floor > rules.ceiling) fail('checks', 'floor is above ceiling');
|
||||
if (rules.odds) {
|
||||
let last = -Infinity;
|
||||
for (const band of rules.odds) {
|
||||
if (typeof band.upTo !== 'number' || band.upTo <= last) fail('checks', 'odds bands must rise by upTo');
|
||||
if (!band.words) fail('checks', 'every odds band needs words');
|
||||
last = band.upTo;
|
||||
}
|
||||
if (last < (rules.ceiling ?? 100)) fail('checks', `odds bands stop at ${last}, below the ceiling`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const readable = (path) => {
|
||||
@@ -260,31 +164,6 @@ export function validatePack(pack) {
|
||||
fail(where, `weight ${event.weight} must be greater than zero, or the event can never fire`);
|
||||
}
|
||||
checkConditions(where, event.requires);
|
||||
const involves = event.involves ?? [];
|
||||
for (const role of involves) {
|
||||
if (!characterById[role]) fail(where, `involves "${role}", which is no character`);
|
||||
}
|
||||
// Bindings are drawn after the event is chosen, so the event's own gate
|
||||
// cannot depend on who they are.
|
||||
const gatePaths = event.requires
|
||||
? (Array.isArray(event.requires) ? event.requires : [event.requires]).flatMap((c) => conditionPaths(c))
|
||||
: [];
|
||||
if (gatePaths.some((p) => p.includes('{'))) {
|
||||
fail(where, 'requires names a {character}; only options and effects can, once the event is drawn');
|
||||
}
|
||||
bindForCheck(where, event.description, involves);
|
||||
const bound = (conditions) => {
|
||||
if (!conditions) return conditions;
|
||||
const walk = (c) => {
|
||||
if (!c || typeof c !== 'object') return c;
|
||||
if (Array.isArray(c)) return c.map(walk);
|
||||
if ('all' in c) return { ...c, all: c.all.map(walk) };
|
||||
if ('any' in c) return { ...c, any: c.any.map(walk) };
|
||||
if ('not' in c) return { ...c, not: walk(c.not) };
|
||||
return { ...c, path: bindForCheck(where, c.path, involves) };
|
||||
};
|
||||
return walk(conditions);
|
||||
};
|
||||
// A fallback must be one that *no* exclusion rule can remove: requires,
|
||||
// once and cooldown are exactly the three reasons eligibleEvents drops an
|
||||
// event, so a floor event may carry none of them.
|
||||
@@ -301,78 +180,20 @@ export function validatePack(pack) {
|
||||
if (seenOptions.has(option.id)) fail(optionWhere, 'duplicate id');
|
||||
seenOptions.add(option.id);
|
||||
if (!option.label) fail(optionWhere, 'label is required');
|
||||
bindForCheck(optionWhere, option.label, involves);
|
||||
bindForCheck(optionWhere, option.result, involves);
|
||||
checkConditions(optionWhere, bound(option.requires));
|
||||
checkConditions(optionWhere, option.requires);
|
||||
|
||||
const checkEffects = (at, effects) => {
|
||||
for (const effect of effects ?? []) {
|
||||
if (!OPERATIONS.includes(effect.op)) {
|
||||
fail(at, `unknown effect operation ${JSON.stringify(effect.op)}`);
|
||||
}
|
||||
if (effect.op === 'add' && typeof effect.value !== 'number') {
|
||||
if (isAmountFrom(effect.value)) {
|
||||
// An amount read from state must read something the pack declares,
|
||||
// and scale and cap it by numbers. See DECISIONS #38.
|
||||
const problem = readable(bindForCheck(at, effect.value.from, involves));
|
||||
if (problem) fail(at, `effect "${effect.path}" reads its amount from ${problem}`);
|
||||
for (const key of ['times', 'cap']) {
|
||||
if (effect.value[key] !== undefined && typeof effect.value[key] !== 'number') {
|
||||
fail(at, `effect "${effect.path}" has a non-numeric ${key}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
fail(at, `effect "${effect.path}" uses ${effect.op} with a non-numeric value`);
|
||||
}
|
||||
}
|
||||
for (const effect of option.effects ?? []) {
|
||||
if (!OPERATIONS.includes(effect.op)) {
|
||||
fail(optionWhere, `unknown effect operation ${JSON.stringify(effect.op)}`);
|
||||
}
|
||||
for (const effect of effects ?? []) {
|
||||
if (typeof effect.path !== 'string') continue;
|
||||
const problem = writable(bindForCheck(at, effect.path, involves), effect, at);
|
||||
if (problem) fail(at, `effect writes ${problem}`);
|
||||
if (['add'].includes(effect.op) && typeof effect.value !== 'number') {
|
||||
fail(optionWhere, `effect "${effect.path}" uses ${effect.op} with a non-numeric value`);
|
||||
}
|
||||
};
|
||||
checkEffects(optionWhere, option.effects);
|
||||
|
||||
if (option.check) {
|
||||
const c = option.check;
|
||||
const at = `${optionWhere} check`;
|
||||
if (!attributeIds.has(c.attribute)) fail(at, `attribute "${c.attribute}" is not in pack.attributes`);
|
||||
if (c.opposedBy !== undefined && !attributeIds.has(c.opposedBy)) {
|
||||
fail(at, `opposedBy "${c.opposedBy}" is not in pack.attributes`);
|
||||
}
|
||||
if (c.against !== undefined) {
|
||||
const id = String(c.against).replace(/^\{|\}$/g, '');
|
||||
const character = characterById[id];
|
||||
if (!character) fail(at, `against "${c.against}" is no character`);
|
||||
else {
|
||||
if (isGroup(id) && !involves.includes(id)) {
|
||||
fail(at, `against "${c.against}" is a group; the event must list "${id}" in involves`);
|
||||
}
|
||||
const attribute = c.opposedBy ?? c.attribute;
|
||||
const people = character.candidates?.length ? character.candidates : [character];
|
||||
if (people.some((p) => typeof p.attributes?.[attribute] !== 'number')) {
|
||||
fail(at, `not everyone who can be "${id}" has "${attribute}" to oppose with`);
|
||||
}
|
||||
}
|
||||
} else if (c.relationship !== undefined) {
|
||||
fail(at, 'relationship needs against: whose regard is it?');
|
||||
}
|
||||
if (c.relationship !== undefined && !(c.relationship in (pack.relationshipStats ?? {}))) {
|
||||
fail(at, `relationship "${c.relationship}" is not in relationshipStats`);
|
||||
}
|
||||
for (const key of ['difficulty', 'modifier']) {
|
||||
if (c[key] !== undefined && typeof c[key] !== 'number') fail(at, `${key} must be a number`);
|
||||
}
|
||||
for (const branch of ['success', 'failure']) {
|
||||
const outcome = option[branch];
|
||||
if (!outcome) { fail(at, `has no ${branch}; a check needs both ways it can go`); continue; }
|
||||
if (!outcome.result) warn(`${optionWhere} ${branch}`, 'no result text; the player will not be told');
|
||||
bindForCheck(`${optionWhere} ${branch}`, outcome.result, involves);
|
||||
checkEffects(`${optionWhere} ${branch}`, outcome.effects);
|
||||
}
|
||||
} else if (option.success || option.failure) {
|
||||
fail(optionWhere, 'has success or failure but no check to decide between them');
|
||||
}
|
||||
for (const effect of option.effects ?? []) {
|
||||
if (typeof effect.path !== 'string') continue;
|
||||
const problem = writable(effect.path, effect, optionWhere);
|
||||
if (problem) fail(optionWhere, `effect writes ${problem}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+5
-11
@@ -4,7 +4,6 @@ import { h } from './dom.js';
|
||||
import { formatValue, labelFor, headerStats } from './format.js';
|
||||
import { APP, versionLine } from '../version.js';
|
||||
import { getPath } from '../engine/paths.js';
|
||||
import { fillText } from '../engine/text.js';
|
||||
|
||||
/**
|
||||
* What you are playing, which pack, and who you currently are — plus the build,
|
||||
@@ -58,14 +57,12 @@ export function statPanel(pack, state, schema) {
|
||||
|
||||
/** One character, with how they currently regard the player. */
|
||||
export function characterCard(pack, state, schema, character) {
|
||||
// `character` is a person as presentPeople() returns them: drawn for this run
|
||||
// or written into the pack, keyed by the slot their relationships live under.
|
||||
return h('article', { class: 'person' },
|
||||
h('header', { class: 'person__head' },
|
||||
h('h3', { class: 'person__name' }, character.name),
|
||||
character.title && h('p', { class: 'person__title' }, character.title),
|
||||
),
|
||||
h('p', { class: 'person__description' }, fillText(pack, state, character.description)),
|
||||
h('p', { class: 'person__description' }, character.description),
|
||||
h('div', { class: 'person__meters' },
|
||||
Object.keys(pack.relationshipStats ?? {}).map((stat) => {
|
||||
const path = `relationships.${character.id}.${stat}`;
|
||||
@@ -81,7 +78,7 @@ export function characterCard(pack, state, schema, character) {
|
||||
}
|
||||
|
||||
/** The change list shown after a choice: what moved, and by how much. */
|
||||
export function changeList(pack, changes, { className = 'changes', state } = {}) {
|
||||
export function changeList(pack, changes, { className = 'changes' } = {}) {
|
||||
const meaningful = changes.filter((change) => {
|
||||
if (change.from === change.to) return false;
|
||||
// Some state is bookkeeping the player has no use for — `notable` is the
|
||||
@@ -98,7 +95,7 @@ export function changeList(pack, changes, { className = 'changes', state } = {})
|
||||
if (typeof change.to === 'boolean') {
|
||||
const rule = pack.display?.[change.path];
|
||||
const phrase = (change.to ? rule?.whenTrue : rule?.whenFalse)
|
||||
?? `${labelFor(pack, change.path, state)}: ${change.to ? 'yes' : 'no longer'}`;
|
||||
?? `${labelFor(pack, change.path)}: ${change.to ? 'yes' : 'no longer'}`;
|
||||
return h('li', { class: `change change--note change--${change.to ? 'up' : 'down'}` },
|
||||
h('span', { class: 'change__phrase' }, phrase),
|
||||
);
|
||||
@@ -108,12 +105,9 @@ export function changeList(pack, changes, { className = 'changes', state } = {})
|
||||
return h('li', { class: `change change--${rising ? 'up' : 'down'}` },
|
||||
// Upkeep changes carry the label of the entry that caused them —
|
||||
// "Rent", "Wages" — which is far more use than the path's own name.
|
||||
h('span', { class: 'change__label' }, change.label ?? labelFor(pack, change.path, state)),
|
||||
h('span', { class: 'change__label' }, change.label ?? labelFor(pack, change.path)),
|
||||
h('span', { class: 'change__delta' }, deltaText(pack, change)),
|
||||
// Say where it stopped. "−$4 at its limit" was read by a real player as
|
||||
// a debt of four still standing, when it meant the balance hit zero.
|
||||
change.clamped && h('span', { class: 'change__note' },
|
||||
`stopped at ${formatValue(pack, change.path, change.to)}`),
|
||||
change.clamped && h('span', { class: 'change__note' }, 'at its limit'),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
+12
-18
@@ -8,7 +8,6 @@
|
||||
|
||||
import { getPath } from '../engine/paths.js';
|
||||
import { check } from '../engine/conditions.js';
|
||||
import { personIn } from '../engine/cast.js';
|
||||
|
||||
const titleCase = (text) =>
|
||||
text.replace(/_/g, ' ').replace(/^\w/, (c) => c.toUpperCase());
|
||||
@@ -18,21 +17,16 @@ export function displayFor(pack, path) {
|
||||
return pack.display?.[path] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A human name for a path, derived from the path when the pack is silent.
|
||||
* Given the run's state, a relationship path names whoever is in that slot this
|
||||
* run — a drawn boss, the second of three co-workers — not the role.
|
||||
*/
|
||||
export function labelFor(pack, path, state) {
|
||||
/** A human name for a path, derived from the path when the pack is silent. */
|
||||
export function labelFor(pack, path) {
|
||||
const declared = displayFor(pack, path)?.label;
|
||||
if (declared) return declared;
|
||||
|
||||
const segments = path.split('.');
|
||||
if (segments[0] === 'relationships' && segments.length === 3) {
|
||||
const name = (state && personIn(pack, state, segments[1])?.name)
|
||||
?? pack.characters?.find((c) => c.id === segments[1])?.name;
|
||||
const character = pack.characters?.find((c) => c.id === segments[1]);
|
||||
const stat = pack.relationshipStats?.[segments[2]]?.label ?? titleCase(segments[2]);
|
||||
return `${name ?? titleCase(segments[1])} — ${stat.toLowerCase()}`;
|
||||
return `${character?.name ?? titleCase(segments[1])} — ${stat.toLowerCase()}`;
|
||||
}
|
||||
return titleCase(segments.at(-1));
|
||||
}
|
||||
@@ -67,15 +61,15 @@ export function formatDelta(pack, path, from, to) {
|
||||
* One condition as a requirement the player can read: "Negotiation 15+",
|
||||
* "Trevor — likes you 48+", "not in debt".
|
||||
*/
|
||||
export function describeCondition(pack, condition, state) {
|
||||
export function describeCondition(pack, condition) {
|
||||
if (!condition || typeof condition !== 'object') return '';
|
||||
if (Array.isArray(condition)) return condition.map((c) => describeCondition(pack, c, state)).join(', ');
|
||||
if (condition.all) return condition.all.map((c) => describeCondition(pack, c, state)).join(', ');
|
||||
if (condition.any) return condition.any.map((c) => describeCondition(pack, c, state)).join(' or ');
|
||||
if (condition.not) return `not ${describeCondition(pack, condition.not, state)}`;
|
||||
if (Array.isArray(condition)) return condition.map((c) => describeCondition(pack, c)).join(', ');
|
||||
if (condition.all) return condition.all.map((c) => describeCondition(pack, c)).join(', ');
|
||||
if (condition.any) return condition.any.map((c) => describeCondition(pack, c)).join(' or ');
|
||||
if (condition.not) return `not ${describeCondition(pack, condition.not)}`;
|
||||
|
||||
const { path, op, value } = condition;
|
||||
const label = labelFor(pack, path, state);
|
||||
const label = labelFor(pack, path);
|
||||
|
||||
if (typeof value === 'boolean') return value ? label.toLowerCase() : `no ${label.toLowerCase()}`;
|
||||
|
||||
@@ -92,8 +86,8 @@ export function describeCondition(pack, condition, state) {
|
||||
}
|
||||
|
||||
/** What a locked option is waiting for. */
|
||||
export function requirementText(pack, failedConditions, state) {
|
||||
const parts = failedConditions.map((c) => describeCondition(pack, c, state)).filter(Boolean);
|
||||
export function requirementText(pack, failedConditions) {
|
||||
const parts = failedConditions.map((c) => describeCondition(pack, c)).filter(Boolean);
|
||||
return parts.length ? `Needs ${parts.join(', ')}` : 'Not available yet';
|
||||
}
|
||||
|
||||
|
||||
@@ -6,8 +6,6 @@
|
||||
|
||||
import { h } from '../dom.js';
|
||||
import { statPanel, characterCard, statusBar } from '../components.js';
|
||||
import { presentPeople } from '../../engine/cast.js';
|
||||
import { fillText } from '../../engine/text.js';
|
||||
import { formatMoney, labelFor } from '../format.js';
|
||||
import { summarize } from '../summary.js';
|
||||
|
||||
@@ -60,14 +58,14 @@ export function retrospectiveScreen(pack, state, schema, actions) {
|
||||
h('section', { class: 'retro__section' },
|
||||
h('h2', { class: 'retro__heading' }, 'Where you stand with people'),
|
||||
h('div', { class: 'retro__people' },
|
||||
presentPeople(pack, state).map((person) => characterCard(pack, state, schema, person)),
|
||||
(pack.characters ?? []).map((character) => characterCard(pack, state, schema, character)),
|
||||
),
|
||||
),
|
||||
|
||||
h('section', { class: 'retro__section' },
|
||||
h('h2', { class: 'retro__heading' }, 'Notable moments'),
|
||||
summary.notable.length
|
||||
? h('ol', { class: 'moments' }, summary.notable.map((moment) => h('li', { class: 'moment' }, fillText(pack, state, moment))))
|
||||
? h('ol', { class: 'moments' }, summary.notable.map((moment) => h('li', { class: 'moment' }, moment)))
|
||||
: h('p', { class: 'moments moments--none' },
|
||||
'Nothing that anybody would write down. That happens.'),
|
||||
),
|
||||
|
||||
+13
-28
@@ -12,8 +12,6 @@
|
||||
import { h } from '../dom.js';
|
||||
import { statPanel, characterCard, changeList, statusBar } from '../components.js';
|
||||
import { requirementText, turnHeading, upcomingUpkeep } from '../format.js';
|
||||
import { presentPeople } from '../../engine/cast.js';
|
||||
import { fillText } from '../../engine/text.js';
|
||||
|
||||
export function turnScreen(pack, state, schema, view, actions) {
|
||||
const heading = turnHeading(pack, state);
|
||||
@@ -35,12 +33,12 @@ export function turnScreen(pack, state, schema, view, actions) {
|
||||
h('div', { class: 'layout' },
|
||||
h('main', { class: 'scene' },
|
||||
view.phase === 'outcome'
|
||||
? outcomeView(pack, state, view, actions)
|
||||
: choosingView(pack, state, view, actions),
|
||||
? outcomeView(pack, view, actions)
|
||||
: choosingView(pack, view, actions),
|
||||
),
|
||||
h('aside', { class: 'people' },
|
||||
h('h2', { class: 'people__heading' }, pack.peopleHeading ?? 'The people'),
|
||||
presentPeople(pack, state).map((person) => characterCard(pack, state, schema, person)),
|
||||
(pack.characters ?? []).map((character) => characterCard(pack, state, schema, character)),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -86,55 +84,44 @@ function diary(pack, state) {
|
||||
);
|
||||
}
|
||||
|
||||
function choosingView(pack, state, view, actions) {
|
||||
function choosingView(pack, view, actions) {
|
||||
const { event, options } = view.turn;
|
||||
const text = (t) => fillText(pack, state, t, view.turn.bindings);
|
||||
const visible = options.filter((o) => !o.hidden);
|
||||
// The first option a player can actually take gets the focus, so Enter plays
|
||||
// it and Tab walks to the others.
|
||||
const firstAvailable = visible.find((o) => o.available)?.option.id;
|
||||
|
||||
return h('section', { class: 'scenario' },
|
||||
h('p', { class: 'scenario__text' }, text(event.description)),
|
||||
h('p', { class: 'scenario__text' }, event.description),
|
||||
h('ul', { class: 'options' },
|
||||
visible.map(({ option, available, failed, odds }) => h('li', { class: 'options__item' },
|
||||
visible.map(({ option, available, failed }) => h('li', { class: 'options__item' },
|
||||
h('button', {
|
||||
class: `option${available ? '' : ' option--locked'}`,
|
||||
disabled: !available,
|
||||
dataset: option.id === firstAvailable ? { autofocus: 'true' } : {},
|
||||
onClick: available ? () => actions.onChoose(option.id) : null,
|
||||
},
|
||||
h('span', { class: 'option__label' }, text(option.label)),
|
||||
// Odds are shown in words, never as a number: "a long shot", "likely".
|
||||
available && odds && h('span', { class: 'option__odds' }, odds.words),
|
||||
!available && h('span', { class: 'option__requirement' }, requirementText(pack, failed, state)),
|
||||
h('span', { class: 'option__label' }, option.label),
|
||||
!available && h('span', { class: 'option__requirement' }, requirementText(pack, failed)),
|
||||
),
|
||||
)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function outcomeView(pack, state, view, actions) {
|
||||
function outcomeView(pack, view, actions) {
|
||||
const { record, option } = view;
|
||||
// The state has already moved on to the next event, so the names in this
|
||||
// outcome come from the bindings the turn was played with, not the new ones.
|
||||
const text = (t) => fillText(pack, state, t, record.bindings ?? {});
|
||||
const branch = record.check ? option[record.check.outcome] : null;
|
||||
const result = branch ? branch.result : option.result;
|
||||
const said = pack.checks?.said ?? { success: 'It came off.', failure: 'It did not come off.' };
|
||||
|
||||
return h('section', { class: 'outcome' },
|
||||
h('p', { class: 'outcome__chose' }, record.optionLabel ?? text(option.label)),
|
||||
record.check && h('p', { class: `outcome__check outcome__check--${record.check.outcome}` },
|
||||
`${capitalise(record.check.odds)} — ${said[record.check.outcome]}`),
|
||||
result && h('p', { class: 'outcome__text' }, text(result)),
|
||||
h('p', { class: 'outcome__chose' }, option.label),
|
||||
option.result && h('p', { class: 'outcome__text' }, option.result),
|
||||
|
||||
h('div', { class: 'outcome__ledger' },
|
||||
h('h3', { class: 'outcome__heading' }, 'What that changed'),
|
||||
changeList(pack, record.changes, { state }),
|
||||
changeList(pack, record.changes),
|
||||
record.upkeep.length > 0 && h('div', { class: 'outcome__upkeep' },
|
||||
h('h3', { class: 'outcome__heading' }, 'And then the day happened'),
|
||||
changeList(pack, record.upkeep, { state }),
|
||||
changeList(pack, record.upkeep),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -198,5 +185,3 @@ function feedbackWidget(view, actions) {
|
||||
feedback.saved && h('p', { class: 'feedback__saved' }, 'Noted — thank you.'),
|
||||
);
|
||||
}
|
||||
|
||||
const capitalise = (words) => words.charAt(0).toUpperCase() + words.slice(1);
|
||||
|
||||
@@ -171,13 +171,6 @@ body {
|
||||
.option__requirement {
|
||||
font-size: 12px; letter-spacing: 0.04em; color: var(--ink-faint); font-style: italic;
|
||||
}
|
||||
/* The odds, in words: "a long shot", "likely". Never a number. */
|
||||
.option__odds {
|
||||
font-size: 12px; letter-spacing: 0.04em; color: var(--accent); font-style: italic;
|
||||
}
|
||||
.outcome__check { margin: 0 0 8px; font-size: 14px; font-style: italic; }
|
||||
.outcome__check--success { color: var(--up); }
|
||||
.outcome__check--failure { color: var(--down); }
|
||||
|
||||
/* ---------- outcome ---------- */
|
||||
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@ export const APP = {
|
||||
// Kept in step with package.json by a test, because it silently was not: the
|
||||
// status bar read 0.3.0 through the 0.3.1 and 0.3.2 work, which is exactly
|
||||
// the thing a playtester is told to check to know what they are playing.
|
||||
version: '0.4.0',
|
||||
version: '0.3.3',
|
||||
};
|
||||
|
||||
/** "0.3.0 · a1b2c3d" — or "0.3.0 · dev" when running from source unstamped. */
|
||||
|
||||
@@ -1,185 +0,0 @@
|
||||
// The cast drawn per run, names in text, and options whose outcome is a check.
|
||||
// See DECISIONS #41. Runs against test/fixtures/cast-pack.js, never real content.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { startGame, currentTurn, takeTurn } from '../src/engine/game.js';
|
||||
import { validatePack } from '../src/engine/validate.js';
|
||||
import { hydrate, createState, resolveSchema } from '../src/engine/state.js';
|
||||
import { presentPeople, presentSlots, personIn } from '../src/engine/cast.js';
|
||||
import { fillText } from '../src/engine/text.js';
|
||||
import { chanceOf, oddsWords } from '../src/engine/checks.js';
|
||||
import { getPath, setPath } from '../src/engine/paths.js';
|
||||
import { castPack } from './fixtures/cast-pack.js';
|
||||
import { testPack } from './fixtures/test-pack.js';
|
||||
|
||||
const clone = (value) => structuredClone(value);
|
||||
|
||||
/** Advance until the current event is `eventId`, taking `work` otherwise. */
|
||||
function until(state, eventId, pack = castPack) {
|
||||
let next = state;
|
||||
for (let i = 0; i < 200 && currentTurn(pack, next).event.id !== eventId; i++) {
|
||||
next = takeTurn(pack, next, currentTurn(pack, next).options[0].option.id).state;
|
||||
}
|
||||
assert.equal(currentTurn(pack, next).event.id, eventId);
|
||||
return next;
|
||||
}
|
||||
|
||||
test('the cast pack is valid, with no warnings', () => {
|
||||
const { errors, warnings } = validatePack(castPack);
|
||||
assert.deepEqual(errors, []);
|
||||
assert.deepEqual(warnings, []);
|
||||
});
|
||||
|
||||
test('a run draws one boss and two to four different co-workers', () => {
|
||||
const counts = new Set();
|
||||
for (let seed = 0; seed < 40; seed++) {
|
||||
const state = startGame(castPack, { seed });
|
||||
const people = presentPeople(castPack, state);
|
||||
assert.equal(people.filter((p) => p.role === 'boss').length, 1);
|
||||
const coworkers = presentSlots(castPack, state, 'coworker');
|
||||
assert.ok(coworkers.length >= 2 && coworkers.length <= 4, `drew ${coworkers.length}`);
|
||||
counts.add(coworkers.length);
|
||||
const names = people.map((p) => p.name);
|
||||
assert.equal(new Set(names).size, names.length, 'nobody is drawn twice');
|
||||
assert.ok(names.includes('Old Sam'), 'a fixed character is always present');
|
||||
}
|
||||
assert.deepEqual([...counts].sort(), [2, 3, 4], 'every head count turns up across seeds');
|
||||
});
|
||||
|
||||
test('the cast is the seed\'s: the same seed draws the same people, others differ', () => {
|
||||
const names = (seed) => presentPeople(castPack, startGame(castPack, { seed })).map((p) => p.name).join('|');
|
||||
assert.equal(names('a'), names('a'));
|
||||
const distinct = new Set(Array.from({ length: 20 }, (_, i) => names(`s${i}`)));
|
||||
assert.ok(distinct.size > 10, `only ${distinct.size} different casts in 20 runs`);
|
||||
});
|
||||
|
||||
test('an empty group slot is not in the run, and a drawn person brings their own starting regard', () => {
|
||||
let seed = 0;
|
||||
let state;
|
||||
do state = startGame(castPack, { seed: seed++ });
|
||||
while (presentSlots(castPack, state, 'coworker').length !== 2 || personIn(castPack, state, 'boss').name !== 'Walter Grose');
|
||||
assert.equal(getPath(state, 'cast.coworker_3'), null);
|
||||
assert.equal(personIn(castPack, state, 'coworker_3'), undefined);
|
||||
assert.equal(getPath(state, 'relationships.boss.trust'), 30);
|
||||
});
|
||||
|
||||
test('names are filled into text from whoever was drawn', () => {
|
||||
const state = startGame(castPack, { seed: 'names' });
|
||||
const boss = personIn(castPack, state, 'boss');
|
||||
assert.equal(fillText(castPack, state, 'Ask {boss.first}, the {boss.title}.'),
|
||||
`Ask ${boss.name.split(' ')[0]}, the Manager.`);
|
||||
assert.equal(fillText(castPack, state, '{porter} nods.'), 'Old Sam nods.');
|
||||
assert.equal(fillText(castPack, state, '{nobody} stays as written.'), '{nobody} stays as written.');
|
||||
});
|
||||
|
||||
test('an event about a co-worker binds one who is present, and its effects land on them', () => {
|
||||
const state = until(startGame(castPack, { seed: 'bind' }), 'needled');
|
||||
const turn = currentTurn(castPack, state);
|
||||
const slot = turn.bindings.coworker;
|
||||
assert.ok(presentSlots(castPack, state, 'coworker').includes(slot));
|
||||
assert.match(fillText(castPack, state, turn.event.description), new RegExp(personIn(castPack, state, slot).name));
|
||||
|
||||
const before = getPath(state, `relationships.${slot}.regard`);
|
||||
const { state: after, record } = takeTurn(castPack, state, 'ignore');
|
||||
assert.equal(getPath(after, `relationships.${slot}.regard`), before - 2);
|
||||
assert.deepEqual(record.bindings, { coworker: slot });
|
||||
});
|
||||
|
||||
test('the chance follows the formula, and the odds are words', () => {
|
||||
let state = startGame(castPack, { seed: 'odds' });
|
||||
const option = castPack.events[0].options[2]; // wits against difficulty 5
|
||||
state = setPath(state, 'attributes.wits', 5);
|
||||
assert.equal(chanceOf(castPack, state, option).chance, 50);
|
||||
state = setPath(state, 'attributes.wits', 7);
|
||||
assert.equal(chanceOf(castPack, state, option).chance, 70);
|
||||
state = setPath(state, 'attributes.wits', 10);
|
||||
assert.equal(chanceOf(castPack, state, option).chance, 100, 'clamped at the ceiling');
|
||||
assert.equal(oddsWords(castPack, 15), 'a long shot');
|
||||
assert.equal(oddsWords(castPack, 50), 'an even chance');
|
||||
assert.equal(oddsWords(castPack, 90), 'a safe bet');
|
||||
});
|
||||
|
||||
test('an opposed check reads the other person\'s attribute, and their regard moves it', () => {
|
||||
let state = startGame(castPack, { seed: 'opposed' });
|
||||
const option = castPack.events[0].options[1]; // charm against the boss's wits, leaning on trust
|
||||
const boss = personIn(castPack, state, 'boss');
|
||||
state = setPath(state, 'attributes.charm', 6);
|
||||
state = setPath(state, 'relationships.boss.trust', 50);
|
||||
const even = chanceOf(castPack, state, option);
|
||||
assert.equal(even.theirs, boss.attributes.wits);
|
||||
assert.equal(even.chance, Math.min(100, Math.max(0, 50 + 10 * (6 - boss.attributes.wits))));
|
||||
state = setPath(state, 'relationships.boss.trust', 70);
|
||||
assert.equal(chanceOf(castPack, state, option).chance, Math.min(100, even.chance + 10));
|
||||
});
|
||||
|
||||
test('a check applies the branch it rolled, plus the option\'s own effects', () => {
|
||||
let state = until(startGame(castPack, { seed: 'roll' }), 'routine');
|
||||
const sure = setPath(setPath(state, 'attributes.charm', 10), 'relationships.boss.trust', 100);
|
||||
const won = takeTurn(castPack, sure, 'ask_for_more');
|
||||
assert.equal(won.record.check.outcome, 'success');
|
||||
assert.equal(getPath(won.state, 'money'), getPath(sure, 'money') + 50);
|
||||
assert.equal(getPath(won.state, 'relationships.boss.regard'), getPath(sure, 'relationships.boss.regard') - 1);
|
||||
|
||||
const hopeless = setPath(setPath(state, 'attributes.charm', 0), 'relationships.boss.trust', 0);
|
||||
const lost = takeTurn(castPack, hopeless, 'ask_for_more');
|
||||
assert.equal(lost.record.check.outcome, 'failure');
|
||||
assert.equal(getPath(lost.state, 'money'), getPath(hopeless, 'money'));
|
||||
assert.equal(getPath(lost.state, 'relationships.boss.trust'), 0, 'clamped');
|
||||
assert.match(lost.record.check.odds, /long shot/);
|
||||
});
|
||||
|
||||
test('a run with checks and bindings replays identically from its seed and choices', () => {
|
||||
const play = () => {
|
||||
let state = startGame(castPack, { seed: 'replay' });
|
||||
const records = [];
|
||||
for (let i = 0; i < 60; i++) {
|
||||
const turn = currentTurn(castPack, state);
|
||||
const pick = turn.options[i % turn.options.length].option.id;
|
||||
const result = takeTurn(castPack, state, pick);
|
||||
records.push(result.record);
|
||||
state = i === 30 ? JSON.parse(JSON.stringify(result.state)) : result.state;
|
||||
}
|
||||
return { state, records };
|
||||
};
|
||||
const a = play();
|
||||
const b = play();
|
||||
assert.deepEqual(a.records.map((r) => r.check?.outcome ?? '-'), b.records.map((r) => r.check?.outcome ?? '-'));
|
||||
assert.deepEqual(a.state, b.state);
|
||||
assert.ok(a.records.some((r) => r.check?.outcome === 'success') && a.records.some((r) => r.check?.outcome === 'failure'));
|
||||
});
|
||||
|
||||
test('a save from before the pack drew a cast gets one on load, the same one every time', () => {
|
||||
const schema = resolveSchema(castPack);
|
||||
const fresh = createState(castPack, { seed: 'old', schema });
|
||||
const old = clone(fresh);
|
||||
delete old.cast;
|
||||
const once = hydrate(castPack, clone(old), schema);
|
||||
const twice = hydrate(castPack, clone(old), schema);
|
||||
assert.ok(once.added.includes('cast'));
|
||||
assert.deepEqual(once.state.cast, twice.state.cast);
|
||||
assert.deepEqual(once.state.cast, fresh.cast);
|
||||
});
|
||||
|
||||
test('a pack with no drawn cast gets no cast and no bindings: its runs are unchanged', () => {
|
||||
const state = startGame(testPack, { seed: 'plain' });
|
||||
assert.equal(state.cast, undefined);
|
||||
assert.equal(state.meta.bindings, undefined);
|
||||
assert.equal(presentPeople(testPack, state).map((p) => p.name).join(), 'The Chief,Pat');
|
||||
});
|
||||
|
||||
test('the validator catches cast and check mistakes', () => {
|
||||
const broken = clone(castPack);
|
||||
broken.events[0].options[1].check.attribute = 'strength';
|
||||
broken.events[0].options[2].failure = undefined;
|
||||
broken.events[0].description = 'Blame {coworker}.';
|
||||
broken.events.push({ id: 'ghost', stage: 'desk', description: '{ghost} appears.', options: [{ id: 'x', label: 'x', effects: [] }] });
|
||||
broken.characters[1].count = [2, 6];
|
||||
const { errors } = validatePack(broken);
|
||||
const has = (fragment) => assert.ok(errors.some((e) => e.includes(fragment)), `expected an error mentioning "${fragment}":\n${errors.join('\n')}`);
|
||||
has('attribute "strength" is not in pack.attributes');
|
||||
has('has no failure');
|
||||
has('{coworker} is a group');
|
||||
has('{ghost} names no character');
|
||||
has('has only 5 candidates');
|
||||
});
|
||||
@@ -132,41 +132,6 @@ for (const pack of packs) {
|
||||
assert.deepEqual(trapped, [], 'events whose every option is gated');
|
||||
});
|
||||
|
||||
test(`${where} no repayment can take more than is owed`, () => {
|
||||
// A fixed instalment against a smaller balance takes money for a debt that
|
||||
// does not exist; the Frontier's store did it to a real player. A balance
|
||||
// is any money-formatted stat other than money itself. An option that pays
|
||||
// one down with money must read the amount from the balance, or be gated
|
||||
// on the balance covering the whole instalment. See DECISIONS #38.
|
||||
const balances = Object.entries(pack.display ?? {})
|
||||
.filter(([path, rule]) => rule.format === 'money' && path !== 'money')
|
||||
.map(([path]) => path);
|
||||
const overcharging = [];
|
||||
for (const event of pack.events) {
|
||||
for (const option of event.options ?? []) {
|
||||
// A check's branches pay and spend too (DECISIONS #41): each is judged
|
||||
// together with the effects that apply either way.
|
||||
const branches = option.check
|
||||
? [option.success, option.failure].map((b) => [...(option.effects ?? []), ...(b?.effects ?? [])])
|
||||
: [option.effects ?? []];
|
||||
for (const effects of branches) {
|
||||
const spends = effects.some((e) => e.path === 'money' && e.op === 'add'
|
||||
&& (typeof e.value === 'number' ? e.value < 0 : (e.value?.times ?? 1) < 0));
|
||||
if (!spends) continue;
|
||||
for (const e of effects) {
|
||||
if (!balances.includes(e.path) || e.op !== 'add') continue;
|
||||
if (typeof e.value !== 'number' || e.value >= 0) continue;
|
||||
const covered = (option.requires ?? [])
|
||||
.some((r) => r.path === e.path && r.op === '>=' && r.value >= -e.value);
|
||||
if (!covered) overcharging.push(`${event.id}.${option.id} (${e.path} ${e.value})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.ok(balances.length > 0 || pack.display === undefined, 'a pack with a balance should declare it as money');
|
||||
assert.deepEqual(overcharging, [], 'options that can charge for more than is owed');
|
||||
});
|
||||
|
||||
test(`${where} every turn always offers something to do, however badly it is going`, () => {
|
||||
for (let seed = 100; seed < 130; seed++) randomRun(pack, seed, 150);
|
||||
});
|
||||
|
||||
@@ -67,37 +67,6 @@ test('an unknown operation is a loud failure', () => {
|
||||
assert.throws(() => applyEffect(state, { path: 'money', op: 'multiply', value: 2 }), /unknown effect operation/);
|
||||
});
|
||||
|
||||
test('an amount can be read from state, capped and signed', () => {
|
||||
// The Frontier's store, turn 27 of a real session: four owed, twenty taken.
|
||||
const ledger = { owed: { initial: 0, min: 0, max: 1000 } };
|
||||
const pay = [
|
||||
{ path: 'money', op: 'add', value: { from: 'owed', times: -1, cap: 20 } },
|
||||
{ path: 'owed', op: 'add', value: { from: 'owed', times: -1, cap: 20 } },
|
||||
];
|
||||
|
||||
const small = applyEffects({ money: 110, owed: 4 }, pay, ledger);
|
||||
assert.equal(small.state.money, 106, 'pays what is owed, not the instalment');
|
||||
assert.equal(small.state.owed, 0);
|
||||
assert.equal(small.changes[1].clamped, false, 'nothing is lost to a clamp');
|
||||
assert.equal(small.changes[0].value, -4, 'the change records the amount applied');
|
||||
assert.deepEqual(small.changes[0].source, { from: 'owed', times: -1, cap: 20 });
|
||||
|
||||
const large = applyEffects({ money: 110, owed: 50 }, pay, ledger);
|
||||
assert.equal(large.state.money, 90, 'the cap holds when more is owed');
|
||||
assert.equal(large.state.owed, 30);
|
||||
});
|
||||
|
||||
test('an amount read from a path that is not a number is zero', () => {
|
||||
const result = applyEffect({ money: 10 }, { path: 'money', op: 'add', value: { from: 'owed', times: -1 } }, {});
|
||||
assert.equal(result.state.money, 10);
|
||||
assert.equal(result.change.value === 0 || Object.is(result.change.value, -0), true);
|
||||
});
|
||||
|
||||
test('a literal amount records no source', () => {
|
||||
const result = applyEffect(state, { path: 'money', op: 'add', value: 5 }, schema);
|
||||
assert.equal('source' in result.change, false);
|
||||
});
|
||||
|
||||
test('an empty effect list is a no-op', () => {
|
||||
const result = applyEffects(state, [], schema);
|
||||
assert.deepEqual(result.changes, []);
|
||||
|
||||
Vendored
-114
@@ -1,114 +0,0 @@
|
||||
// A small pack for the cast and checks (DECISIONS #41): a drawn single role, a
|
||||
// group role of two to four, a fixed character with attributes, opposed and
|
||||
// unopposed checks, and an event about "one of the co-workers". Its checks run
|
||||
// from 0 to 100 so tests can force an outcome; a real pack keeps a floor and a
|
||||
// ceiling so nothing is ever certain.
|
||||
|
||||
export const castPack = {
|
||||
id: 'castpack',
|
||||
name: 'Cast Pack',
|
||||
version: '1.0.0',
|
||||
turnUnit: 'day',
|
||||
startingStage: 'desk',
|
||||
|
||||
attributes: {
|
||||
nerve: { label: 'Nerve' },
|
||||
wits: { label: 'Wits' },
|
||||
charm: { label: 'Charm' },
|
||||
resolve: { label: 'Resolve' },
|
||||
},
|
||||
|
||||
checks: {
|
||||
base: 50, perPoint: 10, unopposed: 5,
|
||||
relationshipPerPoint: 0.5, relationshipCentre: 50,
|
||||
floor: 0, ceiling: 100,
|
||||
},
|
||||
|
||||
state: {
|
||||
'money': { initial: 100, min: -500, max: 10000 },
|
||||
'attributes.nerve': { initial: 5, min: 0, max: 10 },
|
||||
'attributes.wits': { initial: 5, min: 0, max: 10 },
|
||||
'attributes.charm': { initial: 5, min: 0, max: 10 },
|
||||
'attributes.resolve': { initial: 5, min: 0, max: 10 },
|
||||
},
|
||||
|
||||
relationshipStats: {
|
||||
trust: { label: 'Trusts you', initial: 50, min: 0, max: 100 },
|
||||
regard: { label: 'Thinks of you', initial: 50, min: 0, max: 100 },
|
||||
},
|
||||
|
||||
characters: [
|
||||
{
|
||||
id: 'boss',
|
||||
role_type: 'boss',
|
||||
title: 'Manager',
|
||||
candidates: [
|
||||
{ name: 'Agnes Pell', description: 'Reads every memo twice.', attributes: { nerve: 6, wits: 8, charm: 3, resolve: 7 } },
|
||||
{ name: 'Walter Grose', description: 'Loud, and quicker than he looks.', attributes: { nerve: 7, wits: 6, charm: 6, resolve: 4 },
|
||||
startingRelationship: { trust: 30 } },
|
||||
{ name: 'Mina Hart', description: 'Has never raised her voice.', attributes: { nerve: 4, wits: 7, charm: 8, resolve: 6 } },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'coworker',
|
||||
role_type: 'peer',
|
||||
title: 'Clerk',
|
||||
count: [2, 4],
|
||||
candidates: [
|
||||
{ name: 'Ben Aske', description: 'Owes {boss} a favour.', attributes: { nerve: 3, wits: 4, charm: 6, resolve: 5 } },
|
||||
{ name: 'Cora Dunn', description: 'First in, last out.', attributes: { nerve: 5, wits: 5, charm: 4, resolve: 8 } },
|
||||
{ name: 'Del Fane', description: 'Knows everyone\'s business.', attributes: { nerve: 4, wits: 7, charm: 5, resolve: 3 } },
|
||||
{ name: 'Edie Gow', description: 'Quiet until she is not.', attributes: { nerve: 7, wits: 5, charm: 3, resolve: 6 } },
|
||||
{ name: 'Fitz Hale', description: 'Charming, unreliable.', attributes: { nerve: 5, wits: 3, charm: 8, resolve: 2 } },
|
||||
],
|
||||
},
|
||||
{ id: 'porter', name: 'Old Sam', role_type: 'staff', description: 'Has seen everyone come and go.',
|
||||
attributes: { nerve: 2, wits: 9, charm: 4, resolve: 9 } },
|
||||
],
|
||||
|
||||
events: [
|
||||
{
|
||||
id: 'routine',
|
||||
stage: 'desk',
|
||||
weight: 100,
|
||||
description: 'A day at the desk under {boss}.',
|
||||
options: [
|
||||
{ id: 'work', label: 'Work.', effects: [{ path: 'money', op: 'add', value: 10 }] },
|
||||
{
|
||||
id: 'ask_for_more',
|
||||
label: 'Ask {boss.first} for more money.',
|
||||
check: { attribute: 'charm', against: 'boss', opposedBy: 'wits', relationship: 'trust' },
|
||||
effects: [{ path: 'relationships.boss.regard', op: 'add', value: -1 }],
|
||||
success: { result: '{boss} agrees.', effects: [{ path: 'money', op: 'add', value: 50 }] },
|
||||
failure: { result: '{boss} laughs.', effects: [{ path: 'relationships.boss.trust', op: 'add', value: -5 }] },
|
||||
},
|
||||
{
|
||||
id: 'hard_sum',
|
||||
label: 'Try the hard sum.',
|
||||
check: { attribute: 'wits', difficulty: 5 },
|
||||
success: { result: 'It adds up.', effects: [{ path: 'attributes.wits', op: 'add', value: 1 }] },
|
||||
failure: { result: 'It does not.', effects: [] },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'needled',
|
||||
stage: 'desk',
|
||||
weight: 100,
|
||||
involves: ['coworker'],
|
||||
description: '{coworker} makes a remark about your tie.',
|
||||
options: [
|
||||
{ id: 'ignore', label: 'Let it go.', effects: [{ path: 'relationships.{coworker}.regard', op: 'add', value: -2 }] },
|
||||
{
|
||||
id: 'retort',
|
||||
label: 'Answer {coworker.first} back.',
|
||||
check: { attribute: 'nerve', against: '{coworker}', relationship: 'regard' },
|
||||
success: { result: '{coworker} laughs, and means it.', effects: [{ path: 'relationships.{coworker}.regard', op: 'add', value: 5 }] },
|
||||
failure: { result: 'It lands badly.', effects: [{ path: 'relationships.{coworker}.trust', op: 'add', value: -5 }] },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export default castPack;
|
||||
Vendored
+7
-35
@@ -13,41 +13,13 @@
|
||||
import { startGame, currentTurn, takeTurn } from '../../src/engine/game.js';
|
||||
import { createRng } from '../../src/engine/rng.js';
|
||||
|
||||
/**
|
||||
* What an effect is worth to a bot. An amount read from state (DECISIONS #38)
|
||||
* is scored at its cap: the most it could be, which is what a player weighing
|
||||
* the option sees on the label.
|
||||
*/
|
||||
const amountOf = (effect) => {
|
||||
if (typeof effect.value === 'number') return effect.value;
|
||||
if (typeof effect.value?.cap === 'number') return effect.value.cap * (effect.value.times ?? 1);
|
||||
return 0;
|
||||
};
|
||||
const moneyGain = (resolved) => (resolved.option.effects ?? [])
|
||||
.filter((e) => e.path === 'money' && e.op === 'add')
|
||||
.reduce((sum, e) => sum + e.value, 0);
|
||||
|
||||
/**
|
||||
* Every effect an option can have, with the weight a player reading the odds
|
||||
* would give it: plain effects count in full, a check's success and failure by
|
||||
* their chance (DECISIONS #41). An option with no check is just its effects.
|
||||
*/
|
||||
export const weightedEffects = (resolved) => {
|
||||
const { option, odds } = resolved;
|
||||
const plain = (option.effects ?? []).map((effect) => ({ effect, weight: 1 }));
|
||||
if (!option.check) return plain;
|
||||
const p = (odds?.chance ?? 50) / 100;
|
||||
return [
|
||||
...plain,
|
||||
...(option.success?.effects ?? []).map((effect) => ({ effect, weight: p })),
|
||||
...(option.failure?.effects ?? []).map((effect) => ({ effect, weight: 1 - p })),
|
||||
];
|
||||
};
|
||||
|
||||
const moneyGain = (resolved) => weightedEffects(resolved)
|
||||
.filter(({ effect }) => effect.path === 'money' && effect.op === 'add')
|
||||
.reduce((sum, { effect, weight }) => sum + amountOf(effect) * weight, 0);
|
||||
|
||||
const relationshipGain = (resolved) => weightedEffects(resolved)
|
||||
.filter(({ effect }) => effect.path.startsWith('relationships.'))
|
||||
.reduce((sum, { effect, weight }) => sum + amountOf(effect) * weight, 0);
|
||||
const relationshipGain = (resolved) => (resolved.option.effects ?? [])
|
||||
.filter((e) => e.path.startsWith('relationships.'))
|
||||
.reduce((sum, e) => sum + e.value, 0);
|
||||
|
||||
const best = (options, score) => [...options].sort((a, b) => score(b) - score(a))[0];
|
||||
|
||||
@@ -76,7 +48,7 @@ export const strategies = {
|
||||
*/
|
||||
lifer: (options) => {
|
||||
const stays = options.filter(
|
||||
(o) => !weightedEffects(o).some(({ effect }) => effect.path === 'stage'),
|
||||
(o) => !(o.option.effects ?? []).some((e) => e.path === 'stage'),
|
||||
);
|
||||
return best(stays.length ? stays : options, relationshipGain);
|
||||
},
|
||||
|
||||
+1
-31
@@ -11,8 +11,6 @@ import assert from 'node:assert/strict';
|
||||
import { frontier as pack } from '../content/frontier/index.js';
|
||||
import { startGame, currentTurn, takeTurn } from '../src/engine/game.js';
|
||||
import { check } from '../src/engine/conditions.js';
|
||||
import { applyEffects } from '../src/engine/effects.js';
|
||||
import { resolveSchema } from '../src/engine/state.js';
|
||||
import { play, sweep, median } from './fixtures/strategies.js';
|
||||
|
||||
/**
|
||||
@@ -60,7 +58,7 @@ test('the settlement and the room land on the days the pack says', () => {
|
||||
const turn = currentTurn(pack, state);
|
||||
const result = takeTurn(pack, state, turn.options.find((o) => o.available).option.id);
|
||||
for (const change of result.record.upkeep) {
|
||||
if (change.label === 'Wages from Vane') settlement.push(state.turn);
|
||||
if (change.label === 'Settlement') settlement.push(state.turn);
|
||||
if (change.label === 'Room at the Cordwood') room.push(state.turn);
|
||||
}
|
||||
state = result.state;
|
||||
@@ -252,34 +250,6 @@ test('a player who wants a skill does not wait long for the first door', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('the horse and the room are each offered only while they can still be bought', () => {
|
||||
// A real session was shown "Buy the bay" locked after buying it, and offered
|
||||
// the room under a description about the horse. See DECISIONS #40.
|
||||
const offered = (lifestyle) => pack.events
|
||||
.filter((e) => ['the_sale_barn', 'the_ellis_room'].includes(e.id))
|
||||
.filter((e) => check({ stage: 'deputy', lifestyle }, e.requires).passed)
|
||||
.map((e) => e.id);
|
||||
assert.deepEqual(offered(0), ['the_sale_barn']);
|
||||
assert.deepEqual(offered(1), ['the_ellis_room']);
|
||||
assert.deepEqual(offered(2), []);
|
||||
});
|
||||
|
||||
test('paying the book down never takes more than is owed', () => {
|
||||
// Turn 27 of that session: four dollars owed, twenty taken. See DECISIONS #38.
|
||||
const schema = resolveSchema(pack);
|
||||
const payDown = pack.events.find((e) => e.id === 'the_store')
|
||||
.options.find((o) => o.id === 'pay_down');
|
||||
const base = startGame(pack, { seed: 'the-book' });
|
||||
|
||||
const small = applyEffects({ ...base, money: 110, owed: 4 }, payDown.effects, schema).state;
|
||||
assert.equal(small.money, 106, 'four owed costs four');
|
||||
assert.equal(small.owed, 0);
|
||||
|
||||
const large = applyEffects({ ...base, money: 110, owed: 50 }, payDown.effects, schema).state;
|
||||
assert.equal(large.money, 90, 'more than twenty owed costs twenty');
|
||||
assert.equal(large.owed, 30);
|
||||
});
|
||||
|
||||
test('a deliberate player can put Dutch in their debt', () => {
|
||||
// The deepest relationship gate in the pack: `send_for_dutch` in the
|
||||
// marshal's office needs a favour banked two rungs earlier.
|
||||
|
||||
@@ -141,7 +141,6 @@ test('the turn record carries the diff and the options the player could not take
|
||||
const locked = record.offered.find((o) => o.id === 'charm');
|
||||
assert.equal(locked.available, false);
|
||||
assert.equal(locked.failed[0].path, 'skills.talking');
|
||||
assert.equal(locked.hidden, false, 'the record says whether a locked option was on screen');
|
||||
});
|
||||
|
||||
test('history accumulates every turn and is what the retrospective reads', () => {
|
||||
|
||||
@@ -527,52 +527,3 @@ test('the header reads the second pack\'s display map, not the first\'s', async
|
||||
dom.restore();
|
||||
}
|
||||
});
|
||||
|
||||
// ---- a drawn cast, and checks (DECISIONS #41) ----------------------------------
|
||||
|
||||
test('a pack with a drawn cast shows whoever this run drew, and never a placeholder', async () => {
|
||||
const dom = await boot({ pack: 'countinghouse' });
|
||||
try {
|
||||
const people = byClass(dom.root, 'person');
|
||||
// One partner, one chief clerk, and two to four clerks.
|
||||
assert.ok(people.length >= 4 && people.length <= 6, `${people.length} people in the panel`);
|
||||
const titles = byClass(dom.root, 'person__title').map((t) => t.textContent);
|
||||
assert.equal(titles.filter((t) => t === 'Senior Partner').length, 1);
|
||||
assert.equal(titles.filter((t) => t === 'Chief Clerk').length, 1);
|
||||
const clerks = titles.filter((t) => t === 'Clerk').length;
|
||||
assert.ok(clerks >= 2 && clerks <= 4, `${clerks} clerks`);
|
||||
for (let turn = 0; turn < 25; turn++) {
|
||||
assert.doesNotMatch(text(dom.root), /\{[a-z_0-9.]+\}/, 'a {placeholder} reached the screen');
|
||||
enabledOptions(dom.root)[0].click();
|
||||
assert.doesNotMatch(text(dom.root), /\{[a-z_0-9.]+\}/, 'a {placeholder} reached the outcome');
|
||||
firstByClass(dom.root, 'button--next').click();
|
||||
}
|
||||
} finally {
|
||||
dom.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('a checked option shows its odds in words, and the outcome says whether it came off', async () => {
|
||||
const dom = await boot({ pack: 'countinghouse' });
|
||||
try {
|
||||
let checked;
|
||||
for (let turn = 0; turn < 40 && !checked; turn++) {
|
||||
checked = optionButtons(dom.root).find((b) => !b.disabled && firstByClass(b, 'option__odds'));
|
||||
if (checked) break;
|
||||
enabledOptions(dom.root)[0].click();
|
||||
firstByClass(dom.root, 'button--next').click();
|
||||
}
|
||||
assert.ok(checked, 'a checked option turned up within forty days');
|
||||
const odds = firstByClass(checked, 'option__odds').textContent;
|
||||
assert.match(odds, /^(a long shot|unlikely|an even chance|likely|a safe bet)$/);
|
||||
assert.doesNotMatch(text(dom.root), /\d+%/, 'no percentage is ever shown');
|
||||
|
||||
checked.click();
|
||||
const said = text(firstByClass(dom.root, 'outcome__check'));
|
||||
assert.match(said, /and it (came|did not come) off\./);
|
||||
assert.ok(said.toLowerCase().startsWith(odds), `the outcome repeats the odds: "${said}"`);
|
||||
assert.ok(firstByClass(dom.root, 'outcome__text'), 'and says what happened');
|
||||
} finally {
|
||||
dom.restore();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -25,23 +25,6 @@ test('an effect writing an undeclared path is caught', () => {
|
||||
assert.match(messages(result), /skills\.wleding.*not declared/s);
|
||||
});
|
||||
|
||||
test('an amount read from state must read a declared path, with numeric scale and cap', () => {
|
||||
const unknown = broken((pack) => {
|
||||
pack.events[0].options[0].effects.push({ path: 'money', op: 'add', value: { from: 'skills.wleding', times: -1 } });
|
||||
});
|
||||
assert.match(messages(unknown), /reads its amount from "skills\.wleding".*not declared/s);
|
||||
|
||||
const badCap = broken((pack) => {
|
||||
pack.events[0].options[0].effects.push({ path: 'money', op: 'add', value: { from: 'money', cap: 'lots' } });
|
||||
});
|
||||
assert.match(messages(badCap), /non-numeric cap/);
|
||||
|
||||
const fine = broken((pack) => {
|
||||
pack.events[0].options[0].effects.push({ path: 'money', op: 'add', value: { from: 'money', times: -1, cap: 5 } });
|
||||
});
|
||||
assert.deepEqual(fine.errors, []);
|
||||
});
|
||||
|
||||
test('a relationship effect naming a character who does not exist is caught', () => {
|
||||
const result = broken((pack) => {
|
||||
pack.events[0].options[0].effects.push({ path: 'relationships.dave.likes_you', op: 'add', value: 1 });
|
||||
|
||||
+3
-13
@@ -17,18 +17,11 @@ import { fileURLToPath } from 'node:url';
|
||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const logsDir = resolve(root, 'logs');
|
||||
|
||||
/**
|
||||
* The most recently modified feedback log in logs/, when none was named.
|
||||
*
|
||||
* Feedback logs only. A save export is a .json too, and a playtester who
|
||||
* exports both — as the first one to do so did — would otherwise have the save
|
||||
* read as a log whenever it was the later of the two.
|
||||
*/
|
||||
/** The most recently modified log in logs/, when none was named. */
|
||||
async function newestLog() {
|
||||
let names;
|
||||
try {
|
||||
names = (await readdir(logsDir))
|
||||
.filter((name) => name.startsWith('theladder-feedback-') && name.endsWith('.json'));
|
||||
names = (await readdir(logsDir)).filter((name) => name.endsWith('.json'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -137,10 +130,7 @@ function reportGates(entries) {
|
||||
const blocked = new Map();
|
||||
for (const entry of entries) {
|
||||
for (const option of entry.offered ?? []) {
|
||||
// A hidden option was never on screen, so it was never a door anybody
|
||||
// met. Logs from before 0.3.4 do not record `hidden`, and still count
|
||||
// such options here. See DECISIONS #39.
|
||||
if (option.available || option.hidden) continue;
|
||||
if (option.available) continue;
|
||||
const reasons = (option.failed ?? [])
|
||||
.map((c) => `${c.path} ${c.op} ${JSON.stringify(c.value)}`)
|
||||
.join(', ') || 'unstated';
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
# story-to-pack lives on its own branch and is never imported by the game.
|
||||
|
||||
# The repo root ignores *.log. Here the run logs are the record of results, so keep them.
|
||||
!*.log
|
||||
|
||||
# Inference-host telemetry: large, and it names the host.
|
||||
probe/gpu-logs/
|
||||
|
||||
# Rebuildable caches — gate2.py, gate3.py and signals.py recreate them on first use.
|
||||
probe/sim.pkl
|
||||
probe/null.pkl
|
||||
probe/null-samples.pkl
|
||||
probe/coassoc.json
|
||||
__pycache__/
|
||||
@@ -0,0 +1,275 @@
|
||||
# story-to-pack — design note
|
||||
|
||||
A tool that builds a playable The Ladder content pack from a corpus of stories.
|
||||
|
||||
**Status (2026-09-14): direction agreed, nothing built.** The decisions below were made in
|
||||
conversation on 2026-09-10 and had until now existed only in that session's transcript. The
|
||||
architecture rests on one assumption that has so far **failed** its first test (see
|
||||
[Assumptions](#assumptions-under-test)); nothing past the probes should be built until it passes.
|
||||
|
||||
This is a design note, not a specification. It records what was decided and why, so that the next
|
||||
step is argued from it rather than re-derived.
|
||||
|
||||
---
|
||||
|
||||
## What it is for
|
||||
|
||||
Feed it a body of fiction set in one world — say 20–30 short stories — plus a handful of structural
|
||||
numbers, and get back a content pack the game can load and that its own conformance checks judge
|
||||
viable. Then play it, hand-edit it, and retune it from play logs.
|
||||
|
||||
The Ladder's founding commitment (Planning/01 §9) is that a new setting is purely data. This tool
|
||||
tests how far that goes: whether "data" can come from a corpus rather than a designer.
|
||||
|
||||
## Decisions
|
||||
|
||||
| Area | Decision |
|
||||
| --- | --- |
|
||||
| **Inputs** | A thematically coherent corpus (one world), 20–30 stories of 2–15 pages. Plus: number of stages, turns per stage, number of promotions, number of characters, and a one-line player role written by the designer. Early development uses much less text. |
|
||||
| **Corpus** | Saved permanently beside the pack. Read once, at fixed depth. **Provenance recorded:** which chunks fed which event, even though nothing reads it yet. |
|
||||
| **Artifact** | **One.** The generated pack is the source of truth from the moment it is written. The designer and the tool both edit that same file in place. Seed text is a seed, not a source: there is no regenerate-and-merge. |
|
||||
| **Pipeline** | Chunk → embed → cluster → select recurring situations → extract prose → assign state paths and directions → search numbers → verify → repair. |
|
||||
| **The model's job** | Selection and classification. **It never chooses a number.** Its generative tasks are entity substitution and, only where needed, filling out options. |
|
||||
| **Inference** | Once, up front, checkpointed and cached. The numeric search and every repair iteration are pure computation and never call a model. This is what makes a small local model viable. |
|
||||
| **Stat vocabulary** | A fixed mechanical spine (currency, debt, standing, two skills, flags, notable, per-character relationship stats). The corpus supplies the *labels*, not the structure. |
|
||||
| **Cast** | Recurring **roles** harvested across the corpus (the authority, the rival, the peer), not people from any one story. Characters are minted for the roles and entity names are substituted in extracted prose. |
|
||||
| **Stages** | Assigned automatically by the protagonist's standing in each scene, binned and balanced across stages. Overridable by hand. |
|
||||
| **Event count** | Derived from turns per stage, with a sufficiency report *before* inference is spent: "this corpus yields N situations, the pack needs M". |
|
||||
| **Options** | 3–5 per event: 1–3 fixed archetypes ("say nothing"), 1–3 drawn from other scenes in the cluster, and 1–3 generated only to fill gaps. Deduplicated **mechanically** — two options with the same effects are one option. |
|
||||
| **Description variants** | An event can carry several descriptions, sharing one general option set. This needs an engine change (see Open questions). |
|
||||
| **Viability** | Fully automatic — a human does not have to play a pack for it to count as viable. A **graded score**, not pass/fail, so extra time improves it. The search **always keeps the best-scoring candidate**, never the most recent. When it cannot reach viable, it reports precisely which checks it could not satisfy. |
|
||||
| **Time and quality** | Start fast and iterate; later, overnight runs for higher quality. Time buys **numeric search depth and prose fidelity** — not corpus reach. |
|
||||
| **Retuning** | Deterministic, driven by the play log `npm run analyze` already reads. Not model-mediated. |
|
||||
| **Extension** | Pulling more corpus into an existing pack is **out of scope** for now. Provenance is recorded so it stays possible. |
|
||||
| **Home** | `theladder/tools/`, never imported by `src/`, `content/` or `test/`. Its own dependencies stay out of the game. |
|
||||
| **Hardware** | Must be able to run on a low-powered local inference host, taking longer if it has to. |
|
||||
|
||||
## Why the hard part is numbers, not prose
|
||||
|
||||
Counted from the two hand-written packs:
|
||||
|
||||
| | Corporate Ladder | The Frontier |
|
||||
| --- | --- | --- |
|
||||
| Prose strings | 219 | 211 |
|
||||
| Numeric decisions | 347 | 427 |
|
||||
| Stable ids | 125 | 120 |
|
||||
|
||||
A pack is roughly 35% prose and 65% numbers, and every defect the project has hit — the debt trap,
|
||||
the rent retune, the negotiation cliff — lived in the numbers. The corpus supplies the prose; the
|
||||
tool has to invent the rest, and has to be able to show it is not a trap.
|
||||
|
||||
Randomising The Frontier's numbers while keeping its prose and structure: random magnitudes pass the
|
||||
conformance checks 3% of the time, random directions 0%, and a crude four-rule repair loop 42% (5 of
|
||||
12 runs converged). The search space is not hopeless, but it is not a broad basin either — hence a
|
||||
real search against a graded score.
|
||||
|
||||
## Why recurrence is the central problem
|
||||
|
||||
Hand-written packs are almost entirely **recurring** situations: 29 of 31 events in Corporate Ladder
|
||||
and 28 of 29 in The Frontier can fire more than once, and a 41-turn playtest saw 12 distinct
|
||||
situations an average of 3.4 times each. Stories are the opposite: every scene happens once.
|
||||
|
||||
So selection is not "pick 30 good scenes". It is **find the situations this corpus keeps returning
|
||||
to**. Twenty stories in one world should contain eight variations on "a superior asks you to cover
|
||||
something up"; clustering those is what yields one recurring event with several descriptions. One
|
||||
long novel is the hard case and is not the target.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- **Planning/01 §10 is not violated by a hosted model here.** §10 says the *game* must never require
|
||||
a hosted inference service. The generator is a build-time tool whose output is plain data with no
|
||||
runtime dependency; a shipped `.html` still plays offline. Record this in theladder's DECISIONS
|
||||
when the tool lands there.
|
||||
- **There are two provider slots, not one.** Embeddings and generation are configured separately.
|
||||
Anthropic offers generation but no embeddings API, so any mix must keep embeddings local.
|
||||
- **The inference host is shared.** Other work sends requests to the same server. Clients run one
|
||||
request at a time with a pause between requests, set a short `keep_alive`, and read the host from
|
||||
the environment (`STP_OLLAMA`) — never from a tracked file. Every run is agreed with its owner
|
||||
before it starts.
|
||||
|
||||
## Assumptions under test
|
||||
|
||||
The architecture rests on three assumptions. All three are cheap to test, and the first could
|
||||
invalidate everything else.
|
||||
|
||||
### 1. A coherent corpus clusters into recurring situations — FAILED in naive form
|
||||
|
||||
**Test (2026-09-10):** O. Henry's *Four Million* cycle — four Gutenberg volumes, 94 stories, 244,690
|
||||
words — cut into 838 scenes of ~300 words, embedded with `nomic-embed-text`, clustered with k-means.
|
||||
|
||||
**Result:** at the best k, 18 clusters were both tight and drawn from four or more stories. But
|
||||
reading them showed most were 60–79% **one** story plus stragglers. Measured by concentration, only
|
||||
**3 of 18** were genuinely recurring, covering **2% of the corpus**. At scene length, embedding
|
||||
similarity tracks *which story a scene is from* — names, setting, narrator voice — not *what kind of
|
||||
situation it is*.
|
||||
|
||||
**Fix under test:** summarise each scene into one generic sentence with no proper names, embed the
|
||||
summary, and cluster those instead. This probe is in `probe/`.
|
||||
|
||||
**Baseline re-measured (2026-09-14)** on the rebuilt corpus — prose embeddings made on a GPU host,
|
||||
same model, same 838 chunks. Seed 11 at k = 100 reproduces the 2026-09-10 figures exactly (18 sharp
|
||||
clusters spanning ≥ 4 stories, 3 genuinely recurring, 2% coverage). Seeds 12 and 13 give 7 and 7,
|
||||
covering 8% and 10%: the old 2% was one unlucky initialisation, and the true baseline is poor rather
|
||||
than hopeless.
|
||||
|
||||
**Pass mark, fixed before any summary existed.** Judged at **k = 60 and k = 100**, medians over
|
||||
three seeds, summary embeddings against prose embeddings as reported by `probe/measure.py`:
|
||||
|
||||
1. Median dominant-story share of clusters of size ≥ 5 falls to **40% or below** at both k.
|
||||
2. **Tight-recurring coverage** — recurring clusters (≥ 4 stories, dominant ≤ 40%) whose coherence is
|
||||
at or above that file's median cluster — reaches **at least 10% of the corpus** at both k. (A
|
||||
relative bar was written first and dropped once the baseline came in at 2% and 0%, where "double"
|
||||
means nothing. 10% of 838 scenes is ~84 — enough raw material for a stage's worth of situations.)
|
||||
3. Reading three sampled tight-recurring clusters, each is recognisably **one kind of situation**,
|
||||
not a prose register ("men walking through the city") or a vocabulary cluster.
|
||||
|
||||
The prose baseline numbers the first two are judged against are in `probe/measure-prose.log`.
|
||||
|
||||
**Result (2026-09-14): criteria 1 and 2 pass decisively; criterion 3 passes only in part.**
|
||||
|
||||
| k | Median dominant share, prose → summaries | Tight-recurring coverage, prose → summaries |
|
||||
| --- | --- | --- |
|
||||
| 60 | 57% → **22%** | 2% → **26%** |
|
||||
| 100 | 67% → **28%** | 0% → **19%** |
|
||||
|
||||
Pipeline as run: `qwen2.5:3b-instruct` summaries with the v2 prompt (no example roles), names removed
|
||||
by `strip_names.py`, embedded with `nomic-embed-text`. Logs: `probe/measure-summary.log`,
|
||||
`probe/sample-k60.log`, `probe/sample-k100.log`.
|
||||
|
||||
Reading the seeded samples (three at each k; one cluster appeared at both, so five distinct):
|
||||
|
||||
- **Clearly one situation (2):** a spouse scheming against or confronting a spouse over infidelity
|
||||
(15 scenes, 10 stories); a woman made the centre of attention while men compete for her (5 scenes,
|
||||
5 stories).
|
||||
- **Broadly one situation (2):** a secret identity or past revealed in a romantic encounter, often over
|
||||
dinner (15 scenes, 13 stories); hosts and guests jockeying for social rank at a dinner (6, 6).
|
||||
- **Not a situation (1):** fortune-tellers, adventurers, a found tyre, a graft inquiry — held together
|
||||
by "seeks", "fortune" and "mysterious" and dominated by three stories. It is the same cluster at
|
||||
both k.
|
||||
|
||||
The rule as written requires every sample to read as one situation, so **strictly this is not a full
|
||||
pass**, and it is recorded that way rather than rounded up.
|
||||
|
||||
**Follow-up: the selection step is designed and tested in [`SELECTION.md`](SELECTION.md).** A
|
||||
two-signal gate (cross-story cohesion, and no single word dominating) accepted 10 clusters across
|
||||
development and test, all of them situations; it rejects the vocabulary clusters this result found.
|
||||
But it keeps only 5 of ~40 candidates — far short of what a pack needs — so recall is the open problem.
|
||||
Three recall-recovery methods were then tested on a fresh partition and **none met its pre-registered
|
||||
bar**: re-clustering found nothing new, consensus clustering found only sharp situations (3 of 3) but
|
||||
too few, and prune-then-grow found 10 distinct situations with too much noise. The finding that
|
||||
matters is that the gate is size-dependent in both directions, so it has to be made size-invariant
|
||||
before recall can be recovered.
|
||||
A second gate, G2, built for that (cohesion against a same-size random baseline, a significance test
|
||||
for shared words), **failed validation on fresh data**: it accepts three to four times as many
|
||||
clusters as the first gate at about the same precision (0.79), but still rejects every cluster under
|
||||
10 members and still lets growth run to the cap. A fixed threshold on a z-score reverses the size bias
|
||||
rather than removing it.
|
||||
A third gate, G3 (a percentile against same-size random sets, plus a mandatory per-member floor),
|
||||
**failed all four validation criteria and did worse than G2**: 17 accepted across fresh partitions,
|
||||
only 8 of them situations. The random-set baseline turned out to be the wrong comparison — any
|
||||
k-means cluster beats random scenes — and the per-member floor is noisiest, and so most lenient,
|
||||
exactly where clusters are smallest.
|
||||
A fourth gate, G4, had the inference model read each cluster and say which summaries share one
|
||||
situation. It **failed with both `qwen2.5:3b-instruct` and `qwen3:14b`** (primary precision 0.48 and
|
||||
0.52 against G2's 0.73): the models answer with abstractions ("power dynamics", "someone wants
|
||||
something from someone") that most summaries fit. Observed but not pre-registered: the 14B model AND
|
||||
G2 accepted 10 clusters, all situations — precise, narrow, and untested on fresh data. The substantive conclusion: summarising
|
||||
before embedding **does** break the story-identity effect that sank the naive design, and most of
|
||||
what clusters is recognisably situational — the recurrence premise is alive, not proven. The design
|
||||
needs a **selection step that rejects vocabulary clusters**, not only a clustering step.
|
||||
|
||||
Known defects in this run, none large enough to change the verdict:
|
||||
|
||||
- `first_sentence` cut 7 of 838 summaries that begin with an initial ("E. Rushmore Coglan…") to
|
||||
"E." — single-letter initials and "Gen." are not handled.
|
||||
- Heavy use of "someone" is itself a shared token across summaries with many characters. Whether it
|
||||
pulls clusters together has not been measured; a replacement by role or a neutral mix would test it.
|
||||
- A few names that are also common words survive stripping ("Silver", "Cherry", "Grace", "the Kid").
|
||||
|
||||
**Why k ≥ 60, and why a tightness filter.** The first version of this pass mark said "median dominant
|
||||
≤ 40% at k ≥ 40" — and the prose baseline, the approach known to fail, passed it at k = 40 (36%).
|
||||
Two things were wrong. Small k forces clusters to merge stories whatever the embeddings encode: on
|
||||
synthetic vectors carrying nothing but story identity, `measure.py` reports 36% at k = 25, 53% at k
|
||||
= 40 and 100% at k ≥ 60, while vectors with planted cross-story situations report 11–17% at every k
|
||||
≥ 40. And without a tightness filter, the loose clusters the 2026-09-10 reading identified as prose
|
||||
register count as recurring. The tightness cutoff is relative to each file's own median because an
|
||||
absolute cosine threshold does not transfer between 300-word scenes and one-sentence summaries.
|
||||
|
||||
If it fails, the recurrence premise needs rethinking before any design goes further.
|
||||
|
||||
### 2. A small model can classify against a closed path vocabulary
|
||||
|
||||
Roughly 100+ calls per pack deciding which state paths an option moves and in which direction.
|
||||
Malformed or wrong output at a meaningful rate changes the design (batching, retries, a bigger
|
||||
model). **Untested.**
|
||||
|
||||
### 3. A small model can substitute entity names without wrecking prose
|
||||
|
||||
The main generative task, run on every description. **Untested.**
|
||||
|
||||
The summarisation probe in assumption 1 incidentally gives the first evidence on 2 and 3: it asks a
|
||||
small model for constrained, name-free output and the results can be read.
|
||||
|
||||
**First evidence (2026-09-14, `qwen2.5:3b-instruct`, two 40-scene pilots):**
|
||||
|
||||
- **It copies examples from the prompt into the output.** A prompt that illustrated roles with "a
|
||||
clerk", "a landlady", "a rich man", "a city park" got those phrases back 18 times — in scenes
|
||||
containing **none** of them, every one invented. For this probe that is worse than noise: shared
|
||||
invented phrases make unrelated scenes cluster together and fake exactly the recurrence being
|
||||
measured. Removing the examples brought it to 1. **Rule: prompts for a small model name no example
|
||||
of the output vocabulary.**
|
||||
- **It does not follow "never use a name."** 25 of 40 summaries kept names with the first prompt,
|
||||
and 27 of 40 with a stricter one plus an automatic retry — the retry fired 35 times, nearly doubled
|
||||
the run, and fixed almost nothing. Names are therefore removed **deterministically** afterwards
|
||||
(`probe/strip_names.py`): a lexicon built from the corpus, of words seen capitalised mid-sentence
|
||||
and almost never in lowercase. It misses names that are also common words ("Bill", "the Kid",
|
||||
"New" in "New York"). That bears directly on assumption 3: **entity substitution should be done by
|
||||
code over a lexicon, with the model's output treated as untrusted.**
|
||||
- **Throughput is not the constraint.** On a GTX 1080 Ti, a scene summary takes ~0.6 s of model time
|
||||
(about 1.1 s including a deliberate pause between requests), and embedding runs at ~16 texts/s.
|
||||
A 838-scene corpus summarises in roughly a quarter of an hour.
|
||||
|
||||
## Open questions
|
||||
|
||||
- **Description variants need an engine change.** Events carry one description today. Scope it
|
||||
before building, and record it in theladder's DECISIONS — it is the first engine change content
|
||||
generation would force.
|
||||
- **The test strategies hardcode the stat spine.** `theladder/test/fixtures/strategies.js` reads the
|
||||
literal path `money` and the prefix `relationships.`. A pack that named its currency `coin` would
|
||||
make four of six archetypes stop discriminating while the sweep still ran and reported. The fixed
|
||||
spine satisfies this by construction; it is also a coupling that should be made explicit.
|
||||
- **Options versus variants.** A cluster-derived option is tied to the scene it came from and may
|
||||
read wrongly under the event's other descriptions. The more variants an event has, the more its
|
||||
options must come from the fixed archetypes.
|
||||
- **More options, harder search.** Moving from ~3.1 to ~4 options per event adds roughly 90 effect
|
||||
magnitudes to a search that already converged only 5 times in 12 with crude repair.
|
||||
- **Prompts may not transfer between models.** Prompts developed against a capable model are
|
||||
typically under-specified for a 3B one. The small-model question stays open until it is tested on
|
||||
a small model, however well a larger one does.
|
||||
- **Story splitting is per-volume fiddly.** Segmentation needs no model, but each source's format
|
||||
needs its own pattern. A production tool needs a report when a volume does not split cleanly.
|
||||
The probe's `split.py` yields 94 stories from 98 contents entries: it silently drops titles
|
||||
containing quotation marks or an em dash ("LITTLE SPECK IN GARNERED FRUIT", "THE THING'S THE PLAY",
|
||||
"WHAT YOU WANT", "THE GUILTY PARTY — AN EAST SIDE TRAGEDY"). The 2026-09-10 run dropped the same
|
||||
ones, so it is left alone to keep the before/after comparison exact.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
story-to-pack/
|
||||
DESIGN.md this note
|
||||
recovered/ the 2026-09-10 scripts, as recovered from the transcript; not re-run
|
||||
probe/ the working probe for assumption 1
|
||||
corpus/ Gutenberg source texts (rebuildable; see recovered/README.md)
|
||||
split.py volumes -> stories.json
|
||||
chunk.py stories -> chunks.json (~300-word scenes)
|
||||
ollama.py the only module that talks to an inference host
|
||||
embed.py texts -> vectors, checkpointed
|
||||
summarise.py scenes -> one generic sentence each, checkpointed
|
||||
strip_names.py summaries -> summaries with names replaced, from a corpus-built lexicon
|
||||
measure.py clustering concentration report for any embedding file
|
||||
sample_clusters.py seeded sample of tight-recurring clusters, for reading (criterion 3)
|
||||
summaries-v1-pilot.json, summaries-v2-pilot-retry.json the two 40-scene pilots, kept as evidence
|
||||
*.log run and measurement output
|
||||
gpu-logs/ the inference host's power/link/kernel logs for the 2026-09-14 run
|
||||
```
|
||||
@@ -0,0 +1,103 @@
|
||||
# story-to-pack
|
||||
|
||||
Research toward a tool that builds a The Ladder content pack from a corpus of stories.
|
||||
|
||||
**Independent of the game.** Nothing in `src/`, `content/` or `test/` imports anything here, and
|
||||
nothing here is part of `npm test` or `npm run build`. It lives on the `story-to-pack` branch so it is
|
||||
versioned alongside the pack format it targets, without touching `main`.
|
||||
|
||||
- **What was decided and why:** [`DESIGN.md`](DESIGN.md)
|
||||
- **How situations are selected, and every test of it:** [`SELECTION.md`](SELECTION.md)
|
||||
- **The 2026-09-10 scripts, recovered from a transcript:** [`recovered/`](recovered/README.md)
|
||||
|
||||
## Status
|
||||
|
||||
Selection was settled as **gate G2 followed by a structured human review** (see below). Every automated
|
||||
alternative tried — three more geometric gates and two model-judged gates — was worse on fresh,
|
||||
blind-labelled data. The first real review then found G2's groups were **topics** (artists, police,
|
||||
hotels), not situations. **Now under test:** re-describe every scene as its main person's
|
||||
**predicament** with qwen3:14b, regroup, and read the groups (SELECTION.md, "Predicaments"). Nothing
|
||||
downstream of selection (paths and directions, numbers, pack output) is built yet.
|
||||
|
||||
## Layout
|
||||
|
||||
`probe/` holds the v1 data every G1–G4 result was measured on. `probe/v2/` is the corrected corpus
|
||||
(97 stories, 839 scenes: v1's splitter merged three quoted-title stories into their neighbours), built by
|
||||
running the same scripts from inside `v2/` (`python3 ../split.py`, and so on).
|
||||
|
||||
```
|
||||
probe/
|
||||
corpus/ O. Henry's Four Million cycle, Project Gutenberg (public domain)
|
||||
split.py, chunk.py volumes -> stories.json -> chunks.json (838 scenes in v1, 839 in v2)
|
||||
carry_summaries.py v1 summaries onto v2 scenes; only new scene text goes to the model
|
||||
redescribe.py scene -> its main person's predicament, via the model -> predicaments.json
|
||||
topic_words.py, topic_share.py how much groups are held together by a job, relationship or place word
|
||||
pilot_page.py side-by-side page for the 3B / 14B predicament pilot -> pilot-compare.html
|
||||
groups_page.py reading page for regrouped predicaments -> v2/groups-*.html
|
||||
ollama.py the only module that talks to an inference host (host from STP_OLLAMA)
|
||||
summarise.py scene -> one sentence, via the model -> summaries.json
|
||||
strip_names.py names removed deterministically -> summaries_clean.json
|
||||
embed.py texts -> vectors -> embeddings.json, summary_embeddings.json
|
||||
measure.py, signals.py, gate2.py, gate3.py, judge.py … the selection experiments
|
||||
labels-*.json, sheet-*.txt, candidates-*.json, *-map.json blind labels and what they labelled
|
||||
review.py G2 on a partition -> REVIEW.html and candidates.json
|
||||
review_page.py renders the page; picks out role words for each group
|
||||
review_page.html the page template
|
||||
review_page_logic.cjs the rules the page applies (also run by the Node tests)
|
||||
review_format.py the saved review's format, checks and conversion
|
||||
apply_review.py saved review -> situations.json
|
||||
test_review.py, test_review_page.mjs tests
|
||||
review/<run>/ REVIEW.html, candidates.json, and situations.json once applied
|
||||
```
|
||||
|
||||
Not committed (see `.gitignore`): the inference host's power and kernel logs, and caches that the
|
||||
scripts rebuild on first use (`sim.pkl`, `null*.pkl`, `coassoc.json`). The embeddings are committed
|
||||
(about 16 MB) because recreating them needs the inference host.
|
||||
|
||||
## Running
|
||||
|
||||
Everything runs from `probe/` with Python 3 and no third-party packages.
|
||||
|
||||
Anything that calls a model reads the host from the environment and never from a file:
|
||||
|
||||
```sh
|
||||
export STP_OLLAMA=http://<inference-host>:11434
|
||||
```
|
||||
|
||||
## Reviewing situations
|
||||
|
||||
The review happens in a web page. Nobody edits JSON.
|
||||
|
||||
```sh
|
||||
cd probe
|
||||
python3 review.py # k = 60, seed 24 -> review/k60-s24/REVIEW.html
|
||||
```
|
||||
|
||||
1. **Open `review/k60-s24/REVIEW.html` in a browser** (double-click it; nothing to install). The first
|
||||
screen explains the job with examples.
|
||||
2. **One group per screen.** For each: keep it, drop it, or mark it the same as a group already kept;
|
||||
untick scenes that don't fit; and fill in "A ___ wants ___ from ___", with the people named in its
|
||||
scenes offered as click-to-fill words. The page shows as you go whether a group still has enough
|
||||
scenes, and colours each group in the strip across the top. Keys: K keep, D drop, ← →.
|
||||
3. **Progress saves in the browser** after every change, so you can close it and come back.
|
||||
4. **"Finish & save"** lists anything unfinished and saves `review-k60-s24.json`, usually to Downloads.
|
||||
5. **Then:**
|
||||
|
||||
```sh
|
||||
python3 apply_review.py review/k60-s24
|
||||
```
|
||||
|
||||
It finds the saved review in Downloads by itself, checks everything again, and names any problem the
|
||||
way the page does ("Group 4: kept, but its name is not finished"). Nothing is written until the
|
||||
review passes; then it writes **`situations.json`**: each situation's sentence and roles, the scenes
|
||||
that show it with their stories, and which groups it came from.
|
||||
|
||||
`candidates.json` in the run directory is the program's record for the checker; leave it alone.
|
||||
|
||||
```sh
|
||||
python3 -m unittest test_review # the saved review's checks, the page renderer, apply_review.py
|
||||
node --test test_review_page.mjs # the rules the page applies as you review
|
||||
```
|
||||
|
||||
The page's drawing code is only checked for syntax and for starting up; how it looks and behaves in a
|
||||
real browser has to be tried by a person.
|
||||
@@ -0,0 +1,891 @@
|
||||
# story-to-pack — selecting situations
|
||||
|
||||
> **Decision (2026-09-15): selection is gate G2 followed by a structured human review.** `probe/review.py`
|
||||
> writes `REVIEW.html`, a page where the reviewer keeps, drops or merges each group, unticks scenes and
|
||||
> names the situation; the page saves a review file, and `probe/apply_review.py` checks it and writes
|
||||
> `situations.json` for the next stage. See the README.
|
||||
>
|
||||
> The first version asked the reviewer to read a long Markdown file and edit JSON by hand. The first
|
||||
> real attempt stopped before a single entry: what to decide was unclear, there was too much to read at
|
||||
> once, hand-edited JSON broke easily, and the wording was hard to write. The page answers each of those.
|
||||
> The record below is how the G2 decision was reached: four gate designs and two model-judged gates,
|
||||
> each tested against blind labels.
|
||||
>
|
||||
> **Superseded in part (2026-09-15), see "Predicaments" at the end:** reading the page, the reviewer
|
||||
> found the groups were topics, not situations.
|
||||
|
||||
The step after clustering: from the clusters of summarised scenes, choose the ones that are genuine
|
||||
**recurring situations** — the raw material of a recurring event — and reject the rest. Companion to
|
||||
`DESIGN.md`; this is the step its assumption-1 result called for.
|
||||
|
||||
**Status (2026-09-14): designed and pre-registered below, before any feature was computed or any
|
||||
label was read against a feature.** Results are appended at the end, and nothing above the Results
|
||||
heading is edited after the test runs, except to fix a typo.
|
||||
|
||||
---
|
||||
|
||||
## The problem, from the assumption-1 result
|
||||
|
||||
Summarise-then-embed broke the story-identity effect: clusters now draw from many stories. But of five
|
||||
sampled tight-recurring clusters, one was a **vocabulary cluster** — fortune-tellers, adventurers, a
|
||||
found tyre, a graft inquiry — held together by "seeks", "fortune", "mysterious", and mostly from three
|
||||
stories. The concentration and tightness filters in `measure.py` let it through. A selector has to
|
||||
reject clusters like that one and keep clusters like "a spouse schemes against or confronts a spouse
|
||||
over infidelity".
|
||||
|
||||
## Constraints
|
||||
|
||||
- **No inference.** Selection uses only what is already on disk: the summary embeddings, the prose
|
||||
embeddings, the summary text, and which story each scene came from. It is pure computation, like the
|
||||
numeric search, so it can be re-run and tuned for free. (It also keeps the inference host free for
|
||||
other work.)
|
||||
- **Deterministic.** Seeded clustering; the same inputs give the same selection.
|
||||
- **Pure Python**, like the rest of the probe.
|
||||
|
||||
## What a selected situation is
|
||||
|
||||
A set of scenes, from **at least 4 different stories**, with no story contributing more than 40%,
|
||||
that a reader can name as **one social transaction**: who wants what from whom. It becomes one
|
||||
recurring event; its member scenes become that event's description variants and option material.
|
||||
|
||||
Rejected: story-dominated clusters (already mechanical), **vocabulary clusters** (shared words, no
|
||||
shared transaction), **register clusters** (shared tone or setting, e.g. "men walking through the
|
||||
city"), and **near-duplicates** of an already-selected situation.
|
||||
|
||||
## Candidate signals
|
||||
|
||||
All computed per cluster, from existing vectors and text. Each carries a pre-registered prediction of
|
||||
which way it moves for a genuine situation.
|
||||
|
||||
| # | Signal | Definition | Prediction for a situation |
|
||||
| --- | --- | --- | --- |
|
||||
| S1 | **Cross-story cohesion** | Mean summary-embedding cosine over member pairs from *different* stories | Higher |
|
||||
| S2 | **Cross/within ratio** | S1 divided by the mean cosine over member pairs from the *same* story (1.0 if there are none) | Closer to 1 — a situation is as similar across stories as within one; a cluster held together by a few stories' scenes is not |
|
||||
| S3 | **Effective stories** | Inverse Simpson index of the member story counts, divided by cluster size | Higher — members spread evenly rather than a few stories plus stragglers |
|
||||
| S4 | **Prose agreement** | Mean *prose*-embedding cosine over cross-story member pairs, minus the corpus-wide mean cross-story prose cosine | Higher — a second, independent view: scenes of the same situation should read a little alike even in the original text |
|
||||
| S5 | **Stability** | Mean pairwise co-association of members across 12 k-means runs (k from 50 to 100, several seeds): the share of runs in which two members land together | Higher |
|
||||
| S6 | **Word dependence** | Share of members containing the cluster's single most over-represented content word (log-odds against the whole summary set, stopwords and "someone" excluded) | Higher for a **vocabulary** cluster — one word doing the work |
|
||||
|
||||
S6 is predicted to indicate the failure, the others to indicate success.
|
||||
|
||||
## Protocol
|
||||
|
||||
1. **Candidates.** Cluster the summary embeddings with k = 60. A candidate is a cluster with ≥ 5
|
||||
members, ≥ 4 distinct stories and dominant share ≤ 40% (`measure.py`'s "recurring"; the relative
|
||||
tightness filter is *not* applied — the selector replaces it).
|
||||
2. **Two candidate sets from two partitions.** Development: seed 11. Test: seed 12.
|
||||
3. **Blind labels.** Each set is dumped to a sheet showing, per cluster, only its member summaries in
|
||||
shuffled order — no story titles, no scores, no sizes beyond the lines themselves. Each cluster is
|
||||
labelled by reading:
|
||||
- **2 — one situation:** one nameable social transaction that most members (roughly 70%+)
|
||||
instantiate. The label records its name.
|
||||
- **1 — broad:** a shared setting or theme, but the transaction varies across members.
|
||||
- **0 — not a situation:** vocabulary, register, or nothing nameable.
|
||||
|
||||
The test sheet is labelled before any signal is computed for the test set.
|
||||
4. **Choose the rule on development only.** Compute S1–S6 for the development set. Candidate rules
|
||||
are single thresholds on one signal, or an AND of two. The chosen rule is the one with the highest
|
||||
**precision** (share of accepted clusters labelled 2), subject to accepting at least half of the
|
||||
development set's label-2 clusters; ties go to the simpler rule. Freeze it here, in writing, before
|
||||
step 5.
|
||||
|
||||
*Detail fixed after the labels were written and before any signal was computed:* thresholds are
|
||||
placed midway between adjacent observed development values, never on one. Ties beyond simplicity
|
||||
break on more label-2 clusters accepted, then on precision for label ≥ 1. Two reference points
|
||||
are reported but cannot be chosen: accepting every candidate, and `measure.py`'s tightness filter
|
||||
(coherence at or above the partition's median sized cluster), called B0.
|
||||
5. **Test once.** Apply the frozen rule to the test set. Report precision (label 2, and label ≥ 1),
|
||||
recall of label-2 clusters, and the number accepted. No re-tuning after seeing test results; any
|
||||
later change is a new rule and needs new data to be tested on.
|
||||
6. **Near-duplicates.** Among accepted clusters, merge any pair whose centroids have cosine ≥ a
|
||||
threshold chosen on development by reading the labels' names. Report how many merges happened on
|
||||
the test set and whether each merged pair's names agree.
|
||||
|
||||
## Known limits of this test
|
||||
|
||||
- **The labels are one reader's judgement** — the model running the probe. They are written to a
|
||||
file so the designer can spot-check or overrule them, and the verdict should be read with that in
|
||||
mind.
|
||||
- **Development and test come from the same corpus.** Two partitions of the same 838 scenes share
|
||||
situations, so the test measures stability across clustering, not generalisation to a new corpus.
|
||||
A genuinely held-out test needs a second corpus, which needs embedding, which needs the inference
|
||||
host.
|
||||
- **k is fixed at 60** for the test. The selector's behaviour at other k is not tested.
|
||||
|
||||
## Frozen rule (written before any test signal was computed)
|
||||
|
||||
Chosen on development (40 candidates, 5 labelled 2; must accept ≥ 3), by `probe/select_rule.py choose`:
|
||||
|
||||
> **Accept a candidate when S1 ≥ 0.6119 AND S6 ≤ 0.1603.**
|
||||
> Cross-story cohesion at least 0.6119, and no single over-represented word in more than 16% of members.
|
||||
|
||||
| On development | Accepted | Precision (2) | Precision (≥ 1) | Recall (2) |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Accept every candidate | 40 | 0.12 | 0.62 | 1.00 |
|
||||
| B0, tightness filter | 17 | 0.24 | 0.76 | 0.80 |
|
||||
| S1 alone (best single) | 9 | 0.44 | 0.89 | 0.80 |
|
||||
| **Frozen rule** | **5** | **0.60** | **1.00** | **0.60** |
|
||||
|
||||
Accepted on development: D04 (2), D09 (1), D13 (2), D16 (2), D33 (1). Rejected label-2: D15 (S1 0.603),
|
||||
D30 (S6 0.20). Full leaderboard: `probe/choose-dev.log`; signals: `probe/signals-dev.json`.
|
||||
|
||||
Observations recorded now, so they cannot be shaped by the test:
|
||||
|
||||
- **This is very likely overfit.** Five positives, fine-grained thresholds, and an AND of two signals
|
||||
is exactly the setting in which a rule memorises its development set. The test is what decides.
|
||||
- **S1 does most of the work.** It is the best single signal by a wide margin, and it alone rejects
|
||||
the vocabulary cluster that motivated this step (D18, S1 0.605).
|
||||
- **S6 did not behave as predicted.** Its most over-represented word is usually a *story's* word
|
||||
("juggler", "furs", "eviction"), not generic vocabulary, and D18's is low (0.11). It catches D39
|
||||
("young", 1.00) — a vocabulary cluster of a different kind. Its contribution to the frozen rule is
|
||||
a single cut, and may not carry over.
|
||||
- **S2, S3 and S4 were weak on development**, and S5 middling. None of the predicted directions was
|
||||
strongly wrong, but only S1 separated well.
|
||||
|
||||
**Near-duplicates (step 6): centroid cosine cannot do it, so no automatic merge is applied.** Among
|
||||
the five clusters the rule accepts on development, no two share a situation, so there was nothing
|
||||
there to calibrate on. Across all 26 development candidates labelled ≥ 1, the one clear duplicate —
|
||||
D13 and D30, both "a run-in with the police" — has centroid cosine **0.9026**, while five pairs of
|
||||
plainly different situations score higher, up to **0.9326** ("money and marriage" against "schemes,
|
||||
cons and theft"). D05/D23 (artists) sit at 0.9005. Any threshold that merges the duplicate merges
|
||||
different situations first. Summary embeddings place situations about money, romance and conflict
|
||||
in one dense region, so closeness between centroids is not identity of situation.
|
||||
|
||||
Decision, fixed before the test: the test reports, **by label name**, whether any two accepted
|
||||
clusters are the same situation, as a measurement of how often duplicates survive selection. A
|
||||
merge method is an open problem; member overlap across partitions (co-association between two
|
||||
clusters' members) is the candidate to try next, and is untested.
|
||||
|
||||
## Results
|
||||
|
||||
**Test run once, 2026-09-14,** on the seed-12 partition (41 candidates, 5 labelled 2), frozen rule
|
||||
unchanged. Logs: `probe/signals-test.json`, `probe/apply-test.log`.
|
||||
|
||||
| On test | Accepted | Precision (2) | Precision (≥ 1) | Recall (2) |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Accept every candidate | 41 | 0.12 | 0.51 | 1.00 |
|
||||
| B0, tightness filter | 18 | 0.17 | 0.44 | 0.60 |
|
||||
| **Frozen rule** | **5** | **0.40** | **1.00** | **0.40** |
|
||||
|
||||
Accepted: T35 (2) coercion over money; T39 (2) spouse against spouse over infidelity; T05 (1) schemes
|
||||
and cons over money; T07 (1) courtship; T25 (1) trying to impress or win someone's favour.
|
||||
Rejected label-2: T16 newcomer seeks acceptance (S1 0.598), T17 proposal or engagement (S1 0.596),
|
||||
T30 run-in with the police (S1 0.604, S6 0.23).
|
||||
|
||||
### Verdict
|
||||
|
||||
**The selector is precise and narrow.** Across both sets it accepted 10 clusters, and **all 10 are
|
||||
situations or broad situational themes; none is a vocabulary, register or grab-bag cluster.**
|
||||
Accepting everything gives about half that. The tightness filter used in the assumption-1 test is
|
||||
*worse* than accepting everything on the test set (0.44 against 0.51) — it keeps vocabulary clusters,
|
||||
which are tight.
|
||||
|
||||
**It does not find most of the situations.** Recall of label-2 clusters fell from 0.60 to 0.40, and
|
||||
precision for label 2 from 0.60 to 0.40 — the expected overfitting. The rejected label-2 clusters sit
|
||||
just under the S1 cut (0.596–0.604), so the threshold is doing real work at a place where situations
|
||||
and non-situations overlap. **Five accepted clusters out of 41 is far short of the ~15 situations
|
||||
per stage a pack needs**, so as it stands selection would make the sufficiency report fail on this
|
||||
corpus.
|
||||
|
||||
**S6 earned its place on the test set, in the way it failed to on development.** Two test clusters are
|
||||
held together by a single word — T10 "caliph" (S6 0.70) and T18 "young" (1.00) — and both have high
|
||||
cross-story cohesion (S1 0.628, 0.626). S1 alone would accept them; S6 rejects them. (Observation made
|
||||
after the test, not a re-tuning: S1 ≥ 0.6119 alone would accept 10 test clusters, 2 labelled 2 and 6 labelled ≥ 1.)
|
||||
Its original motivation, D18, was caught by S1 instead. Both failure modes exist, and each signal
|
||||
catches one.
|
||||
|
||||
**Duplicates among accepted clusters:** no two carry the same label name. T07 (courtship) and T25
|
||||
(trying to impress or win someone's favour) overlap in substance; a reader could reasonably call them
|
||||
one situation. Automatic merging remains unsolved (see step 6).
|
||||
|
||||
### What this means for the design
|
||||
|
||||
1. **Two-signal selection is a sound first gate, not a complete step.** It should run as a *filter
|
||||
that is allowed to reject* — its precision is what matters for pack quality — followed by a
|
||||
second stage that recovers recall.
|
||||
2. **Recall is the next problem, and clustering is the likely cause, not selection.** The rejected
|
||||
situations (police run-ins, proposals, social climbing) are recognisable but diluted by
|
||||
off-situation members at k = 60. Candidates to test, all without inference: re-cluster the members
|
||||
of rejected clusters at finer k and re-apply the gate; select from the union of several partitions,
|
||||
using co-association to keep only members that consistently travel together; or grow an accepted
|
||||
cluster's core by adding nearest cross-story members while S1 stays above the cut.
|
||||
3. **Merging duplicates needs member overlap, not centroid distance.**
|
||||
4. **These labels are one reader's.** Every number above rests on them. They are in
|
||||
`probe/labels-dev.json` and `probe/labels-test.json`, with the sheets they were read from, and are
|
||||
worth a designer's spot-check — especially the 1-versus-0 boundary, which is the least certain.
|
||||
5. **The test is within one corpus.** Generalisation to a different corpus is untested and needs
|
||||
embeddings, which needs the inference host.
|
||||
|
||||
---
|
||||
|
||||
## Recovering recall (pre-registered 2026-09-14, before any recovery method was run)
|
||||
|
||||
The gate is precise and keeps too few. Three ways to find more situations, compared on **fresh data**,
|
||||
because the test set above is spent. Still no inference.
|
||||
|
||||
### Fixed before running
|
||||
|
||||
- **The gate does not change.** `S1 ≥ 0.6119 AND S6 ≤ 0.1603`, plus the candidate conditions (≥ 5
|
||||
members, ≥ 4 stories, dominant ≤ 40%). "Passes the gate" below means all of these.
|
||||
- **A property of the gate, noted in advance:** S6 counts a word only if at least two members share
|
||||
it, so a cluster of fewer than 13 members fails whenever any content word repeats (2/12 = 0.167). In
|
||||
effect the gate requires ~13 members. The methods are built to produce clusters of that size;
|
||||
anything smaller is expected to fail, and that is the gate's behaviour, not a method's.
|
||||
- **Fresh partition:** k = 60, **seed 13** — neither development (11) nor test (12).
|
||||
- **Baseline (B):** the gate applied to seed 13's candidates, as before.
|
||||
- **M1 — re-cluster the rejected.** Every scene not in a B-accepted cluster is pooled and re-clustered
|
||||
with k-means, k = round(pool size / 20), seed 13. Result: B's accepted clusters plus pool clusters
|
||||
that pass the gate.
|
||||
- **M2 — consensus clustering.** Each scene is represented by its co-association row over the 12 cached
|
||||
runs (k 50–100, seeds 21 and 22 — no overlap with 11, 12 or 13): how often it landed with every
|
||||
other scene. Those rows are clustered with k-means, k = 60, seed 13. Result: clusters that pass the gate.
|
||||
- **M3 — prune, then grow.** For each seed-13 candidate: if it fails the gate, repeatedly remove the
|
||||
member with the lowest mean similarity to members from *other* stories, until it passes or falls to
|
||||
13 members (then it is dropped). Then grow every passing cluster: add the non-member scene nearest
|
||||
its centroid while the result still passes; stop at the first addition that would fail, or at 30
|
||||
members. Scenes may end up in more than one M3 cluster; the overlap is reported.
|
||||
|
||||
### Evaluation
|
||||
|
||||
- Every cluster accepted by any method is pooled (identical member sets once), given a shuffled id, and
|
||||
dumped to a blind sheet showing only member summaries. Which method produced a cluster is written
|
||||
to a separate map file that is not read until labelling is finished.
|
||||
- Labels use the same 2 / 1 / 0 scale. Names are reused verbatim from the development and test label
|
||||
files wherever the situation is the same, so duplicate situations can be counted by name.
|
||||
- Per method: clusters accepted; precision for label ≥ 1 and for label 2; **distinct situations**
|
||||
(distinct names among label ≥ 1); scene coverage; overlap between accepted clusters.
|
||||
- **Success, per method:** at least **twice** B's accepted clusters, **and** precision (≥ 1) of at least
|
||||
**0.80**, **and** at least twice B's distinct situations. For scale: a two-stage pack wants ~30.
|
||||
|
||||
### Known limits
|
||||
|
||||
The reader has now read every summary several times; labels are blind to method and signals, not to
|
||||
content. The methods and the gate are evaluated on one corpus.
|
||||
|
||||
### Results (2026-09-14)
|
||||
|
||||
Seed 13 gave 42 candidates. 21 distinct accepted clusters were labelled blind (`probe/labels-recover.json`,
|
||||
3 labelled 2, 13 labelled 1, 5 labelled 0), then scored by `probe/evaluate_recover.py`:
|
||||
|
||||
| Method | Accepted | Precision (≥ 1) | Precision (2) | Distinct situations | Scene coverage | Success |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| B, gate on candidates | 4 | 1.00 | 0.00 | 4 | 13% | — |
|
||||
| M1, re-cluster the rejected | 4 | 1.00 | 0.00 | 4 | 13% | **fail** — 0 new |
|
||||
| M2, consensus clustering | 3 | 1.00 | **1.00** | 3 | 5% | **fail** — too few |
|
||||
| M3, prune then grow | 16 | 0.69 | 0.00 | 10 | 45% | **fail** — precision |
|
||||
|
||||
**No method meets the pre-registered bar.**
|
||||
|
||||
- **M1 adds nothing.** Re-clustering the 729 scenes outside B's clusters at k = 36 produced no cluster
|
||||
that passes the gate. The gate's selectivity does not come from dilution by one bad partition.
|
||||
- **M2 is the only method that produced sharp situations.** All three of its clusters are labelled 2
|
||||
— a wealthy suitor weighed against love, a spouse against a spouse over infidelity, coercion by
|
||||
threat — at 14–17 members, against B's 13–38. Consensus over many partitions isolates the scenes
|
||||
that genuinely travel together. It produces too few clusters to be the selection step on its own.
|
||||
- **M3 finds the most, and the gate cannot stop it diluting.** Pruning recovered 12 rejected
|
||||
candidates and M3 reached 10 distinct situations (B: 4), but 5 of its 16 clusters are not
|
||||
situations. **Growth was stopped by the 30-member cap in 10 of 16 clusters, not by the gate.**
|
||||
That exposes the gate's second size effect: S6 is a share, so it gets *easier* to satisfy as a
|
||||
cluster grows, and S1 moves slowly. The gate calibrated on k = 60 clusters does not constrain a
|
||||
cluster that is being built up one scene at a time.
|
||||
- **Duplicates survive.** Across methods, "a spouse against a spouse" appears three times (R006, R015)
|
||||
and courtship twice within M3 (R008, R010); 68 scenes sit in more than one M3 cluster.
|
||||
- **The seed-13 baseline itself accepted no label-2 cluster** (0 of 4), where development and test
|
||||
each had 2–3. The baseline's precision for label 2 is unstable across partitions.
|
||||
|
||||
### What this means
|
||||
|
||||
The gate, as frozen, is **size-dependent in both directions**: it structurally rejects clusters under
|
||||
~13 members and barely constrains clusters above ~25. Any method that changes cluster size — pruning,
|
||||
growing, finer re-clustering — changes what the gate measures. Every recovery method was, in effect,
|
||||
testing the gate at sizes it was not calibrated for.
|
||||
|
||||
Observation only, not pre-registered and not a result: B and M2 together would give 7 clusters,
|
||||
6 distinct situations, all labelled ≥ 1, and 3 labelled 2.
|
||||
|
||||
Directions this points to, none tested:
|
||||
|
||||
1. **Make the gate size-invariant before anything else.** Replace S6's share with a count or a
|
||||
significance test against the corpus, and judge S1 relative to what a random cluster of the same
|
||||
size scores. Then re-validate — which needs fresh labels on fresh partitions.
|
||||
2. **Use consensus (M2) to form clusters, not k-means.** It was the only source of sharp situations.
|
||||
Running more co-association partitions (more seeds, more k) and extracting cores at several
|
||||
agreement levels may raise its yield without losing precision.
|
||||
3. **Grow only with a size-invariant stopping rule.** M3's recall is real; its stopping rule is not.
|
||||
|
||||
---
|
||||
|
||||
## A size-invariant gate, G2 (pre-registered 2026-09-14, before any G2 signal was computed)
|
||||
|
||||
### Why each old signal depended on size
|
||||
|
||||
- **S6** is a share with a floor of 2 members: 2/size. Under ~13 members any repeated word fails the
|
||||
cut; above ~25 almost nothing does. A share is not evidence unless the count is improbable.
|
||||
- **S1**'s *expectation* does not depend on size — it is a mean over pairs — but its *noise* does:
|
||||
small clusters reach a high S1 by chance, which is part of why k-means' small clusters look tight.
|
||||
- **Nothing looked at individual members.** Adding one off-situation scene to 30 moves S1 by
|
||||
roughly a thirtieth of the difference, so growth was never stopped.
|
||||
|
||||
### G2 signals
|
||||
|
||||
| Signal | Definition | Why it is size-invariant |
|
||||
| --- | --- | --- |
|
||||
| **S1** | Unchanged: mean summary cosine over cross-story member pairs | Unbiased at any size |
|
||||
| **Z1** | (S1 − μₘ) / σₘ, where μₘ and σₘ come from random member sets of the same size m (≥ 4 stories, dominant ≤ 40%): 300 samples for m ≤ 40, 120 above, sizes 5–150, seeded | Requires more evidence where chance does more |
|
||||
| **MMIN** | The lowest, over members, of a member's mean cosine to members of *other* stories | A per-member quantity; one diluting member fails it at any size |
|
||||
| **W** | Among words shared by ≥ 2 members, those whose count is significant by a hypergeometric upper-tail test at Bonferroni 0.01 over the corpus vocabulary; W is the largest share among them (0 if none) | Counts only improbable sharing, then measures how dominant it is |
|
||||
|
||||
Word extraction, stopwords and the corpus are identical to `signals.py`. Pairwise similarities are
|
||||
precomputed once. Candidate conditions (≥ 5 members, ≥ 4 stories, dominant ≤ 40%) still apply.
|
||||
|
||||
### Choosing thresholds
|
||||
|
||||
- **Tuning set:** every labelled cluster so far — development (40), test (41) and the recovery pool
|
||||
(21): 102 clusters, 63 labelled ≥ 1, sizes 5–39. All have been seen; none is used for validation.
|
||||
- **Rule form:** S1 ≥ a AND Z1 ≥ b AND MMIN ≥ c AND W < d, where each term may be switched off. Grid:
|
||||
a and c at the deciles of the tuning set's values or off; b ∈ {off, 1, 2, 3, 4, 5}; d ∈ {off, 0.25,
|
||||
0.30, 0.35, 0.40, 0.50, 0.60}.
|
||||
- **Objective:** the most tuning clusters labelled ≥ 1 accepted, subject to precision (≥ 1) of at least
|
||||
**0.85**; ties on more label-2 accepted, then fewer active terms. The objective changes from the
|
||||
first gate (which maximised label-2 precision) because the failure now is recall, and label 1 —
|
||||
a situational theme — is usable material for a pack.
|
||||
- The tuning report also gives, per size band (≤ 12, 13–25, ≥ 26), how many clusters the old and new
|
||||
gates accept and at what precision.
|
||||
|
||||
### Validation on fresh data (nothing below has been computed)
|
||||
|
||||
- **V1, small clusters:** k = 100, seed 15; 30 candidates sampled at random (seed 15).
|
||||
- **V2, large clusters:** k = 40, seed 14; 30 candidates sampled at random (seed 14).
|
||||
- **V3, growth:** k = 60, seed 16; every candidate G2 accepts is grown by adding the non-member nearest
|
||||
its centroid while the result still passes G2, up to 45 members. The stop reason — gate or cap — is
|
||||
recorded.
|
||||
- All V1 and V2 samples and all V3 grown clusters are pooled, deduplicated, shuffled and labelled blind,
|
||||
with the set and method in a separate map file.
|
||||
- **Success, all three required:**
|
||||
1. G2's precision (≥ 1) is at least **0.80 on V1 and on V2 separately**, and G2 accepts at least 3
|
||||
clusters in each.
|
||||
2. In V1, G2 accepts **more** small clusters than the old gate.
|
||||
3. In V3, **most growths are stopped by the gate, not the cap**, and grown clusters have precision
|
||||
(≥ 1) of at least 0.80.
|
||||
|
||||
The old gate is scored on V1 and V2 alongside, for comparison.
|
||||
|
||||
### Frozen G2 (written after tuning, before any validation cluster was built)
|
||||
|
||||
`probe/tune_gate2.py`, log `probe/tune-gate2.log`. (Correction: the tuning set has 62 clusters
|
||||
labelled ≥ 1, not 63.)
|
||||
|
||||
> **Accept a candidate when S1 ≥ 0.6037 AND Z1 ≥ 5 AND W < 0.60.** MMIN is off.
|
||||
|
||||
| Tuning set, 102 clusters | Accepted | Label ≥ 1 | Precision (≥ 1) | Label 2 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Accept all | 102 | 62 | 0.61 | 13 |
|
||||
| Old gate | 31 | 26 | 0.84 | 8 |
|
||||
| **G2** | **40** | **34** | **0.85** | **9** |
|
||||
|
||||
| Size band | Clusters (≥ 1) | Old gate: accepted / P1+ | G2: accepted / P1+ |
|
||||
| --- | --- | --- | --- |
|
||||
| ≤ 12 | 20 (7) | 0 / — | 1 / 1.00 |
|
||||
| 13–25 | 52 (34) | 13 / 0.92 | 18 / 0.89 |
|
||||
| ≥ 26 | 30 (21) | 18 / 0.78 | 21 / 0.81 |
|
||||
|
||||
**Observations and predictions, recorded before validation:**
|
||||
|
||||
- **G2 is not size-invariant at the small end, and the objective chose that.** Z1 ≥ 5 is a fixed bar
|
||||
on a statistic whose value, for a fixed true cohesion, *grows* with size (σₘ shrinks as m grows).
|
||||
It replaces S6's structural rejection of small clusters with a milder one: 1 small cluster accepted
|
||||
instead of 0. **Prediction: criterion 2 passes narrowly or fails, and V1 yields fewer than 3
|
||||
accepted clusters, failing criterion 1.**
|
||||
- **MMIN, the signal designed to stop dilution, was not selected**, and W's cut (0.60) is loose enough
|
||||
to act only on near-total single-word clusters. Growing a cluster raises Z1 and barely moves S1.
|
||||
**Prediction: most V3 growths run to the cap, failing criterion 3.**
|
||||
- The gain over the old gate is real but modest, and it is in the mid and large bands: 8 more clusters
|
||||
labelled ≥ 1 accepted at unchanged precision.
|
||||
- The thresholds are not changed in light of these predictions. The validation tests the rule the
|
||||
pre-registered procedure produced.
|
||||
|
||||
### Validation results (2026-09-14)
|
||||
|
||||
73 fresh clusters labelled blind (`probe/labels-validate.json`: 6 labelled 2, 33 labelled 1, 34
|
||||
labelled 0), scored by `probe/validate_gate2.py score` (log `probe/validate-score.log`).
|
||||
|
||||
| Set | Sampled (≥ 1) | Old gate: accepted / ≥ 1 / P1+ | G2: accepted / ≥ 1 / P1+ |
|
||||
| --- | --- | --- | --- |
|
||||
| V1, k = 100 | 30 (13) | 3 / 2 / 0.67 | **10 / 8 / 0.80** (2 labelled 2) |
|
||||
| V2, k = 40 | 30 (16) | 1 / 1 / 1.00 | 4 / 3 / 0.75 |
|
||||
| V3, grown | 13 | — | 13 grown: **11 stopped by the cap**, 2 by the gate; 10 ≥ 1, P1+ 0.77 |
|
||||
|
||||
| Criterion | Result |
|
||||
| --- | --- |
|
||||
| 1. P1+ ≥ 0.80 and ≥ 3 accepted, on V1 and V2 | **fail** — V1 passes (0.80, 10), V2 does not (0.75, 4) |
|
||||
| 2. G2 accepts more V1 clusters than the old gate | **pass** — 10 against 3 |
|
||||
| 3. V3 mostly stopped by the gate, P1+ ≥ 0.80 | **fail** — 2 of 13, 0.77 |
|
||||
| **Overall** | **fail** |
|
||||
|
||||
By size, across V1 and V2 together:
|
||||
|
||||
| Size | Old gate: accepted (≥ 1) | G2: accepted (≥ 1) |
|
||||
| --- | --- | --- |
|
||||
| ≤ 9 | 0 | **0** — including two clusters labelled 2 (5 and 6 members) |
|
||||
| 10–12 | 0 | 3 (3) |
|
||||
| 13–25 | 3 (2) | 7 (5) |
|
||||
| ≥ 26 | 1 (1) | 4 (3) |
|
||||
| All | 4 (3), P1+ 0.75 | 14 (11), P1+ 0.79 |
|
||||
|
||||
### Verdict on G2
|
||||
|
||||
**G2 is a real improvement on the old gate, and it is not size-invariant.** It accepts three to four
|
||||
times as many clusters at slightly better precision, and it opens the 10–25 member range the old gate
|
||||
all but closed. Both pre-registered predictions of failure held:
|
||||
|
||||
- **The small end is still closed below 10 members.** Z1 ≥ 5 is unreachable for a cluster of 5–9
|
||||
however cohesive it is. The two sharpest small clusters on the sheet — a fare dispute (5 members,
|
||||
label 2) and a spouse against a spouse (6 members, label 2) — were both rejected.
|
||||
- **Growth is still unconstrained.** Growing a cluster raises Z1 and barely moves S1, so both gates
|
||||
accept a 45-member cluster as readily as a 15-member one. The one per-member signal built to stop
|
||||
this, MMIN, was not selected by the tuning objective.
|
||||
- **Large clusters are still mostly rejected** (4 of 30 in V2), including clear themes such as the
|
||||
saloon, dining rooms and illness.
|
||||
|
||||
**What was learned about size-invariance itself:** a fixed threshold on a z-score is not size-invariant;
|
||||
it reverses the old bias rather than removing it. A size-invariant cohesion test needs a threshold
|
||||
that scales with the null — for example a fixed *percentile* of the same-size random distribution
|
||||
combined with an effect-size floor on S1 — and a stopping rule for growth has to be a per-member
|
||||
test that the tuning cannot switch off. Both are untested proposals, and testing them needs another
|
||||
fresh partition and another round of blind labels, because every cluster labelled so far has now
|
||||
informed a design decision.
|
||||
|
||||
---
|
||||
|
||||
## G3: a percentile of the same-size null, and a mandatory member floor (pre-registered 2026-09-14, before any G3 signal was computed)
|
||||
|
||||
### Changes from G2
|
||||
|
||||
- **Z1 is replaced by PCT**: the fraction of same-size random member sets (≥ 4 stories, dominant ≤ 40%)
|
||||
whose S1 is below the cluster's. 1,000 samples per size for 5–60 members, 200 for 61–150, seeded.
|
||||
A percentile threshold asks "is this cluster as unusual as the top x% of random sets *of its own
|
||||
size*", which a z-score with a fixed bar does not.
|
||||
- **S1 floor is mandatory.** At large sizes the null is narrow and PCT saturates at 1.0 for any
|
||||
cluster slightly above average, so an effect-size floor has to carry the weight there.
|
||||
- **MMIN is mandatory**: every member's mean cosine to members of other stories must reach the floor.
|
||||
It is the growth stop; tuning chooses its value but cannot switch it off.
|
||||
- **W** is unchanged from G2 and remains optional.
|
||||
|
||||
### Tuning
|
||||
|
||||
- **Tuning set: all 175 labelled clusters** — development 40, test 41, recovery 21, G2 validation 73.
|
||||
- **Rule form:** S1 ≥ a AND PCT ≥ p AND MMIN ≥ c AND W < d. a and c at the tuning set's deciles (no "off");
|
||||
p ∈ {0.90, 0.95, 0.975, 0.99, 0.999}; d ∈ {off, 0.30, 0.40, 0.50, 0.60}.
|
||||
- **Objective unchanged from G2:** most clusters labelled ≥ 1 accepted, subject to precision (≥ 1) of at
|
||||
least 0.85; ties on more label-2 accepted, then fewer active terms.
|
||||
|
||||
**Risk named in advance:** MMIN is a minimum over per-member means. Small clusters have noisier
|
||||
member means but fewer of them; large clusters have steadier means but more chances to contain one
|
||||
low member. Its net size effect is not predictable, and it could re-introduce a size bias of its own.
|
||||
|
||||
### Validation on fresh partitions (seeds never used before)
|
||||
|
||||
- **V0, very small:** k = 150, seed 20; 25 candidates sampled (seed 20).
|
||||
- **V1, small:** k = 100, seed 17; 20 sampled (seed 17).
|
||||
- **V2, large:** k = 40, seed 18; 20 sampled (seed 18).
|
||||
- **V3, growth:** k = 60, seed 19; every G3-accepted candidate grown toward its nearest scene while it
|
||||
passes G3, cap 45, stop reason recorded.
|
||||
- Pooled, deduplicated, shuffled and labelled blind as before. The old gate and G2 are scored on the
|
||||
same clusters.
|
||||
|
||||
**Success, all four required:**
|
||||
|
||||
1. G3 precision (≥ 1) ≥ **0.80** and ≥ 3 accepted, on V1 and on V2 separately.
|
||||
2. **Small clusters:** among V0 and V1 clusters of ≤ 9 members, G3 accepts **at least 2**, with precision
|
||||
(≥ 1) of at least **0.67**, and more than G2 accepts.
|
||||
3. **Growth:** most V3 growths stopped by the gate, and grown clusters have precision (≥ 1) ≥ 0.80.
|
||||
4. G3 accepts at least as many clusters labelled ≥ 1 across V0–V2 as G2, at precision no lower than G2's.
|
||||
|
||||
### Frozen G3 (written after tuning, before any validation cluster was built)
|
||||
|
||||
`probe/tune_gate3.py`, log `probe/tune-gate3.log`.
|
||||
|
||||
> **Accept a candidate when S1 ≥ 0.6175 AND PCT ≥ 0.90 AND MMIN ≥ 0.5296 AND W < 0.60.**
|
||||
|
||||
| Tuning set, 175 clusters (101 ≥ 1, 19 label 2) | Accepted | Label ≥ 1 | Precision (≥ 1) | Label 2 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Old gate | 47 | 38 | 0.81 | 9 |
|
||||
| G2 | 67 | 55 | 0.82 | 12 |
|
||||
| **G3** | **47** | **40** | **0.85** | **13** |
|
||||
|
||||
| Size band | Clusters (≥ 1) | Old: accepted/≥ 1 | G2 | G3 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| ≤ 9 | 26 (5) | 0/0 | 0/0 | **4/3** |
|
||||
| 10–12 | 21 (11) | 0/0 | 4/4 | 4/4 |
|
||||
| 13–25 | 75 (47) | 17/15 | 26/22 | 17/17 |
|
||||
| ≥ 26 | 53 (38) | 30/23 | 37/29 | 22/16 |
|
||||
|
||||
**Observations and predictions, recorded before validation:**
|
||||
|
||||
- **G3 is the first gate to accept any cluster under 10 members** (4 on tuning, 3 of them ≥ 1).
|
||||
**Prediction: criterion 2 passes.**
|
||||
- **The percentile threshold settled at its loosest value, 0.90, and the S1 floor rose** from G2's 0.6037
|
||||
to 0.6175. The floor, not the percentile, is doing most of the work at mid and large sizes, and G3
|
||||
accepts 15 fewer large clusters than G2 on tuning. **Prediction: criterion 4 fails** — G3 finds fewer
|
||||
clusters labelled ≥ 1 than G2 (40 against 55 on tuning), though at higher precision. Criterion 1 is
|
||||
at risk on V2 for the same reason.
|
||||
- **MMIN at 0.53 is the growth stop.** Whether a nearest-to-centroid scene falls below it is not
|
||||
predictable from tuning, which contains no grown-under-G3 clusters. No prediction for criterion 3.
|
||||
- The trade is visible already: G3 exchanges recall at the large end for precision everywhere and for
|
||||
admission at the small end. Whether that is the right trade for a pack is a design question the
|
||||
validation cannot answer.
|
||||
|
||||
### Validation results (2026-09-14)
|
||||
|
||||
75 fresh clusters labelled blind (`probe/labels-validate3.json`: 4 labelled 2, 24 labelled 1, 47
|
||||
labelled 0), scored by `probe/validate_gate3.py score` (log `probe/validate3-score.log`).
|
||||
|
||||
| Set | Sampled (≥ 1) | Old: acc / ≥ 1 / P1+ | G2: acc / ≥ 1 / P1+ | G3: acc / ≥ 1 / P1+ |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| V0, k = 150 | 25 (5) | 2 / 1 / 0.50 | 8 / 4 / 0.50 | 11 / 4 / 0.36 |
|
||||
| V1, k = 100 | 20 (5) | 1 / 0 / 0.00 | 1 / 1 / 1.00 | 4 / 2 / 0.50 |
|
||||
| V2, k = 40 | 20 (12) | 0 / 0 / — | 4 / 4 / 1.00 | 2 / 2 / 1.00 |
|
||||
| ≤ 9 members, V0 + V1 | 27 (4) | 1 / 0 / 0.00 | 3 / 1 / 0.33 | 11 / 3 / 0.27 |
|
||||
| V0–V2 pooled | 65 (22) | 3 / 1 / 0.33 | **13 / 9 / 0.69** | 17 / 8 / 0.47 |
|
||||
| V3, grown under G3 | 10 | — | — | 7 stopped by the cap, 3 by the gate; P1+ 0.60 |
|
||||
|
||||
**All four criteria fail**, and G3 is worse than G2 on fresh data: more clusters accepted, fewer of
|
||||
them situations. G3's gain on the tuning set (P1+ 0.85, 13 label-2) did not survive a new partition.
|
||||
|
||||
### Why G3 failed — measured, not inferred
|
||||
|
||||
- **PCT carries no information.** Across all 175 tuning clusters, 173 reach PCT ≥ 0.90 and 141 reach
|
||||
1.0 — including 72 of the 74 clusters labelled 0. **The random-set null is the wrong null.** k-means
|
||||
groups each scene with its nearest neighbours, so every cluster it produces is far tighter than a
|
||||
random set of scenes of the same size, whether or not it is a situation. A cohesion test has to ask
|
||||
whether a cluster is tighter than *other clusters of its size*, not than random scenes.
|
||||
- **MMIN barely separates labels and favours small clusters.** Median MMIN is 0.536 for label 0,
|
||||
0.543 for label 1 and 0.564 for label 2; by size it is 0.547 for ≤ 9 members, 0.537 for 10–25 and
|
||||
0.552 for ≥ 26. A minimum over few, noisy per-member means is not reliably lower for small clusters,
|
||||
so the floor admitted 11 small clusters of which 8 are not situations. This is the risk recorded
|
||||
before tuning.
|
||||
- **Growth is still not stopped by the gate** (7 of 10 to the cap). A nearest-to-centroid scene
|
||||
typically clears a member floor calibrated on whole clusters.
|
||||
|
||||
### Where this leaves selection
|
||||
|
||||
Across three gates and five blind-labelled evaluations, **G2 remains the best**: pooled precision
|
||||
(≥ 1) of 0.79 on its own validation and 0.69 here, with the fewest non-situations admitted.
|
||||
None of the three is size-invariant, and none stops growth.
|
||||
|
||||
Two conclusions look robust enough to build on:
|
||||
|
||||
1. **Cohesion alone does not identify a situation.** Clusters labelled 0, 1 and 2 overlap heavily on
|
||||
every cohesion statistic tried (S1, Z1, PCT, MMIN). What separates the best-labelled clusters is
|
||||
visible to a reader — one transaction — and is not a geometric property of summary embeddings at
|
||||
the resolution tried.
|
||||
2. **The labelling budget is the binding constraint.** Each gate needs fresh partitions and 70–80 new
|
||||
blind labels to test honestly, and every labelled cluster so far has informed a design. Continuing
|
||||
to iterate gate designs on this corpus without a different kind of evidence is unlikely to converge.
|
||||
|
||||
Candidate directions, none tested: a null built from **k-means clusters of the same size on
|
||||
shuffled-story data** rather than random scenes; a selection step that uses the **inference model to
|
||||
name each cluster's transaction** and checks agreement across members (needs the GPU); or accepting
|
||||
G2 as the gate and moving the problem downstream to event construction, where a human pass over
|
||||
~40 candidate clusters is cheap compared with the labelling this probe has already required.
|
||||
|
||||
---
|
||||
|
||||
## G4: the model reads the cluster (pre-registered 2026-09-14, before any model call)
|
||||
|
||||
The finding above is that what makes a cluster a situation is visible to a reader and not to the
|
||||
geometry. So let a reader decide — the inference model — and measure it against the blind labels.
|
||||
|
||||
### Method
|
||||
|
||||
- For each cluster, up to **15 member summaries** (a seeded sample when larger), shuffled and numbered,
|
||||
go to the model in one call with the prompt below. It returns JSON: the single social transaction
|
||||
most of them clearly show, or NONE, and the numbers of the summaries that clearly show it.
|
||||
- **FIT** = (number of valid summary numbers listed) / (number shown); 0 when the answer is NONE or
|
||||
the reply cannot be parsed. Parse failures are counted and reported.
|
||||
- **Gate: accept when FIT ≥ θ**, plus the usual candidate conditions (≥ 5 members, ≥ 4 stories,
|
||||
dominant ≤ 40%), which every labelled cluster already meets.
|
||||
- Deterministic: temperature 0, 2,048-token context, `format: json`. One request at a time, as before.
|
||||
- **The prompt contains no example of a situation** — the 3B model copied the examples it was given
|
||||
in the summarisation pilot.
|
||||
|
||||
Prompt (system), fixed:
|
||||
|
||||
> You read short summaries of scenes from different stories and decide whether they share one kind
|
||||
> of social situation.
|
||||
>
|
||||
> A social situation is a specific transaction between people: who wants what from whom, and what
|
||||
> is at stake. Describe it in your own words.
|
||||
>
|
||||
> Rules:
|
||||
> - Find the single situation that the largest number of summaries clearly show.
|
||||
> - It is normal for many summaries not to fit. Include a summary only if it clearly shows that
|
||||
> situation.
|
||||
> - A shared place, mood, profession or word is not a situation.
|
||||
> - If no situation is clearly shown by at least three summaries, the situation is NONE.
|
||||
>
|
||||
> Reply with JSON only: {"situation": "<at most 10 words, or NONE>", "fits": [<numbers of the
|
||||
> summaries that clearly show it>]}
|
||||
|
||||
The user message is the numbered list. For `qwen3:14b` it ends with `/no_think`, and any think block
|
||||
is stripped before parsing.
|
||||
|
||||
### Models
|
||||
|
||||
- **Primary: `qwen2.5:3b-instruct`** — the design requires a small local model; the verdict rests on it.
|
||||
- **Secondary: `qwen3:14b`** — reported, to show whether capability is what limits the result.
|
||||
|
||||
### Data — no new labels
|
||||
|
||||
The model is never fitted to labels; the only fitted quantity is θ.
|
||||
|
||||
- **θ is chosen on development (40 clusters)** only, with the objective used for G2 and G3: most
|
||||
clusters labelled ≥ 1 accepted, subject to precision (≥ 1) ≥ 0.85; ties on more label-2 accepted,
|
||||
then the higher θ. Grid: θ ∈ {0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0}.
|
||||
- **Primary evaluation: the G2 and G3 validation sets, 148 clusters**, which tuned neither G2 nor G4,
|
||||
so G2 can be compared fairly on them. **Secondary:** test and recovery, 62 clusters.
|
||||
- Identical member sets appearing in more than one file are judged once.
|
||||
- **Pilot, allowed once:** the first 5 development clusters, to check the JSON parses. If more than 1
|
||||
of 5 fails to parse, one prompt revision is allowed and recorded here before any other call.
|
||||
|
||||
### Success, on the 148-cluster primary set, with the 3B model
|
||||
|
||||
1. Precision (≥ 1) at least **0.80**.
|
||||
2. **More** clusters labelled ≥ 1 accepted than G2 accepts on the same set.
|
||||
3. Among accepted clusters of ≤ 9 members, precision (≥ 1) at least 0.67, if there are at least 3.
|
||||
|
||||
Also reported: G4 AND G2; FIT distribution by label; the situations named for accepted clusters
|
||||
against the label names; and the 14B results against the same bar.
|
||||
|
||||
**Pilot (21:23):** 5 of 5 replies parsed with the 3B model, so the prompt is unchanged. About 1.6 s
|
||||
per cluster. Noted without drawing a conclusion from five: the situations named are often generic
|
||||
("Confrontation and hidden motives" for D04, a label-2 spouse-against-spouse cluster, FIT 0.47), and
|
||||
a label-1 argument cluster scored FIT 0.87.
|
||||
|
||||
### Results, `qwen2.5:3b-instruct` (21:24–21:29, 245 distinct clusters in 286 s, 1 parse failure)
|
||||
|
||||
Log `probe/evaluate-judge-3b.log`. θ chosen on development: **0.6** (9 accepted, P1+ 0.89).
|
||||
|
||||
| Primary set, 148 clusters (67 ≥ 1) | Accepted | ≥ 1 | Label 2 | P1+ | Recall |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| Accept all | 148 | 67 | 10 | 0.45 | 1.00 |
|
||||
| **G2** | **49** | **36** | 5 | **0.73** | **0.54** |
|
||||
| G4, FIT ≥ 0.6 | 50 | 24 | 4 | 0.48 | 0.36 |
|
||||
| G4 AND G2 | 18 | 11 | 1 | 0.61 | 0.16 |
|
||||
|
||||
On the secondary set (62) G4 reaches P1+ 0.71 against G2's 0.82. **All three criteria fail**
|
||||
(P1+ 0.48; 24 ≥ 1 against G2's 36; 13 accepted small clusters at P1+ 0.23).
|
||||
|
||||
**FIT does not separate the labels:** mean 0.39 for label 0, 0.49 for label 1 — and **0.42 for label
|
||||
2**, lower than for label 1. Precision at the primary set (0.48) is barely above accepting everything
|
||||
(0.45).
|
||||
|
||||
**Why, visible in the replies:** the 3B model names situations at a level of abstraction where almost
|
||||
anything fits — "confrontation", "power dynamics in social hierarchies", "tension between parties",
|
||||
"coercion and manipulation" — and then lists most summaries as fitting it. A 5-member cluster labelled
|
||||
0 scored FIT 1.00 as "power struggle/conflict". When it is specific it is often right ("marital
|
||||
conflict" for a spouse-against-spouse cluster, "con or scam", "argue over payment"), but specificity
|
||||
is not what FIT rewards. The rule against shared place, mood and profession was followed; a rule
|
||||
against *abstraction* was never written, and the model supplies abstraction freely.
|
||||
|
||||
### 14B pilot and the one allowed revision
|
||||
|
||||
The `qwen3:14b` pilot failed to parse **5 of 5**: every reply's content was empty. The model spent its
|
||||
whole 200-token budget in a separate thinking channel (Ollama returns it outside `content`), and the
|
||||
`/no_think` suffix did not stop it. **Revision, the one allowed by the pilot rule, affecting only the
|
||||
14B run:** requests set Ollama's `think: false` and allow 300 output tokens. The prompt text,
|
||||
sampling, θ procedure and success criteria are unchanged. The five empty results were deleted before
|
||||
re-running the pilot.
|
||||
|
||||
Re-run pilot: **5 of 5 parsed**, about 2.4 s per cluster. The situations named are more specific than
|
||||
the 3B model's ("Wealthy person pressures someone into marriage", "Struggling artist facing societal or
|
||||
personal challenges"), though an abstract one still scored FIT 0.80 ("conflict between individuals with
|
||||
differing perspectives").
|
||||
|
||||
### Results, `qwen3:14b` (21:31–21:45, 245 clusters in 832 s, 0 parse failures)
|
||||
|
||||
Log `probe/evaluate-judge-14b.log`. θ chosen on development: **0.6** (8 accepted, P1+ 0.88).
|
||||
|
||||
| Primary set, 148 clusters (67 ≥ 1) | Accepted | ≥ 1 | Label 2 | P1+ | Recall |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| Accept all | 148 | 67 | 10 | 0.45 | 1.00 |
|
||||
| **G2** | **49** | **36** | 5 | **0.73** | **0.54** |
|
||||
| G4 (14B), FIT ≥ 0.6 | 42 | 22 | 4 | 0.52 | 0.33 |
|
||||
| G4 (14B) AND G2 | 10 | 10 | 1 | 1.00 | 0.15 |
|
||||
|
||||
By size, G4 (14B) on the primary set: ≤ 9 members 17 accepted, P1+ 0.29; 10–25, 14 accepted, P1+ 0.50;
|
||||
≥ 26, 11 accepted, **P1+ 0.91**. Secondary set (62): G4 P1+ 0.60 against G2's 0.82.
|
||||
|
||||
**All three criteria fail with the 14B model as well** (P1+ 0.52; 22 ≥ 1 against G2's 36; 17 small
|
||||
clusters accepted at P1+ 0.29).
|
||||
|
||||
FIT now rises with the label — mean 0.40, 0.47, 0.52 for labels 0, 1, 2 — where the 3B model's did
|
||||
not, so capability helps. But the separation is small next to the spread, and the 14B model still
|
||||
names abstractions that fit almost anything ("Someone wants something from someone else", "Power
|
||||
dynamics between individuals", "Someone holds power over someone else"), most often for small
|
||||
clusters.
|
||||
|
||||
GPU host during both runs: no faults logged; peak 284.9 W (1 s), mean 163 W under load, 65 °C, PCIe
|
||||
Gen 3 x4 throughout. Logs in `probe/gpu-logs/*2122*`.
|
||||
|
||||
### Verdict on G4
|
||||
|
||||
**Neither model's judgement is a better gate than G2**, and the pre-registered verdict (on the 3B
|
||||
model) is a fail. The failure is specific and repeatable: asked for "the single situation most
|
||||
summaries share", a model answers at whatever level of abstraction makes the most summaries fit, and
|
||||
FIT rewards exactly that. Small clusters make it worse, because with 5–9 summaries an abstraction that
|
||||
covers four or five of them is always available.
|
||||
|
||||
Two observations, recorded as observations because neither was a pre-registered criterion:
|
||||
|
||||
- **G4 (14B) AND G2 accepted 10 clusters on the primary set, and all 10 are labelled ≥ 1.** The two
|
||||
gates fail in different ways — G2 admits cohesive clusters that are not situations, the model admits
|
||||
abstract "situations" that are not cohesive — and their intersection is precise. It is also
|
||||
narrow: recall 0.15.
|
||||
- **For clusters of 26 or more, the 14B model's precision is 0.91.** The abstraction problem is a
|
||||
small-cluster problem: 15 shown summaries rarely all fit one vague phrase *and* get listed.
|
||||
|
||||
What would test the obvious next step, none of it done: a prompt that asks for the situation **in
|
||||
terms of roles and a concrete want** and rejects answers without both; scoring the *specificity* of
|
||||
the named situation (for example, requiring the named situation to be closer to the member summaries
|
||||
than to the corpus average in embedding space); and G2 AND G4 evaluated as a pre-registered gate on a
|
||||
fresh partition, since every labelled cluster now informs this observation.
|
||||
|
||||
---
|
||||
|
||||
## Predicaments (recorded 2026-09-15, before any model call)
|
||||
|
||||
### What the first real review found
|
||||
|
||||
The designer opened `REVIEW.html` for run k60-s24 and read four groups closely:
|
||||
|
||||
- **Group 1 is artists.** It holds at least three different situations: an artist in a new place using
|
||||
the art to get on, being paid for the work against what the work is worth, and plain money trouble.
|
||||
- **Group 2 is police and court officers.** Inside it: being coerced, with the police watching or
|
||||
arriving; and a crime that is stopped or found out.
|
||||
- **Group 6 is flirting and infatuation.**
|
||||
- **Extra group 1 is room and board**: hotels, dinners and the like.
|
||||
|
||||
None of these is "one person wanting something specific from another". Checked across all 20 groups,
|
||||
most are held together by who is in the scenes or where they happen: *artist* in 44% of group 1's
|
||||
scenes, *policeman* in 54% of group 3's, *uncle* and *lawyer* in group 4, *marriage* and *wedding* in
|
||||
group 8, *restaurant* and *customers* in group 12, *hotel* in 41% of extra group 1.
|
||||
|
||||
**Why every earlier test missed it.** A topic group is as cohesive and as spread across stories as a
|
||||
situation group, so cohesion, story spread, word dominance and the model's own judgement all passed it.
|
||||
Summaries keep jobs and places, and the embeddings group on them. It took a person reading the groups.
|
||||
|
||||
### Decisions (designer, 2026-09-15)
|
||||
|
||||
1. **The unit is a predicament**, not a two-person transaction: what the main person is up against and
|
||||
what they want out of it, as The Ladder's existing events are (the rent bouncing, the mail cart).
|
||||
The sub-themes the designer found inside groups 1 and 2 are this kind of thing.
|
||||
2. **Re-describe every scene as a predicament, then regroup**, rather than splitting topic groups by hand.
|
||||
|
||||
### Method, fixed before any call
|
||||
|
||||
- `probe/redescribe.py` gives the model each scene's own prose (not its summary) and the prompt in that
|
||||
script: one sentence, at most 20 words, no names, jobs, places, businesses or story-specific objects,
|
||||
saying what the person is up against and what they want. No example of the output vocabulary.
|
||||
- Names are then removed deterministically (`strip_names.py`), the sentences embedded (`embed.py`), and
|
||||
the embeddings clustered at k = 60.
|
||||
- **Pilot first:** the first 40 scenes, with both `qwen2.5:3b-instruct` and `qwen3:14b`, read by the
|
||||
designer side by side before any full run. The full run uses the model the designer prefers.
|
||||
|
||||
### How it is judged
|
||||
|
||||
- **Topic share** (`probe/topic_share.py`): for each candidate group, the share of its scenes whose
|
||||
original summary contains the group's most common job, relationship or place word; median over groups
|
||||
and seeds 11–13 at k = 60. The baseline on the summary embeddings is measured before any call and
|
||||
recorded below. **Pass: the median falls to half the baseline or less**, with at least 30 candidate
|
||||
groups remaining at k = 60 (enough material for a two-stage pack).
|
||||
- **Leakage:** how many predicament sentences still contain a job, relationship or place word. Reported,
|
||||
not gated.
|
||||
- **The real test is the designer reading the regrouped top groups** in the review page: most should
|
||||
read as one predicament. Numbers alone passed topic groups before.
|
||||
|
||||
### Baseline (measured 2026-09-15, before any model call)
|
||||
|
||||
A first measurement counted words for a person with no role at all ("man", "woman", "girl"), and scored
|
||||
one group 90% topic-bound for saying "man". Those words (`GENERIC_PEOPLE` in `probe/topic_words.py`)
|
||||
were taken out of the measure, and the baseline re-measured, still before any predicament was generated.
|
||||
|
||||
`python3 topic_share.py summary_embeddings.json` (`probe/topic-share-baseline.log`):
|
||||
|
||||
| Seed | Candidate groups | Median top topic-word share |
|
||||
| --- | --- | --- |
|
||||
| 11 | 40 | 16% |
|
||||
| 12 | 41 | 20% |
|
||||
| 13 | 42 | 22% |
|
||||
| **Median** | **41** | **20%** |
|
||||
|
||||
Most topic-bound at seed 11: *wife* 47% of 15, *dinner* 47% of 15, *policeman* 40% of 15 and 38% of 16,
|
||||
*brother* 38% of 8, *artist* 36% of 11. **Pass mark for the predicament groups: median 10% or less, with
|
||||
at least 30 candidate groups.**
|
||||
|
||||
### Pilot (2026-09-15)
|
||||
|
||||
First 40 scenes, one request at a time on the GPU host (3B 39 s, 14B 111 s; no GPU faults; logs in
|
||||
`probe/gpu-logs/*-2026-09-15-1803*`). Read side by side in `probe/pilot-compare.html` (`pilot_page.py`).
|
||||
|
||||
| | qwen2.5:3b-instruct | qwen3:14b |
|
||||
| --- | --- | --- |
|
||||
| Has a job, relationship or place word | 15 of 40 | 13 of 40 (mostly *wife*, *husband*) |
|
||||
| Mean words (limit 20) | 14 | 25; 34 of 40 over |
|
||||
| Most common opening | "The Kid seeks…" ×3 | "A man struggles…" ×6 |
|
||||
|
||||
3B mostly retells the plot, keeping names, objects and places. 14B writes something close to a
|
||||
predicament but ignores the length limit and repeats a few openings. **Designer's read: scenes 1–10,
|
||||
14B dramatically better in every one; stopped there. The full run uses qwen3:14b.**
|
||||
|
||||
Found while reading: `split.py` skips a title written in quotation marks, so three stories were
|
||||
merged into the story before them. "Little Speck in Garnered Fruit" is inside "Dougherty's
|
||||
Eye-Opener", "The Guilty Party" inside "A Harlem Tragedy", and "What You Want" inside "The Duel". The
|
||||
corpus has 97 stories, not 94. Separately, `strip_names.py` treats *York*, *Manhattan* and *Broadway*
|
||||
as personal names, which gives "New someone" in 29 of 838 summaries.
|
||||
|
||||
### Corrections before the full run (2026-09-15, designer's go-ahead)
|
||||
|
||||
The v1 data and every G1–G4 record stay as they were, in `probe/`. The corrected corpus is built in
|
||||
`probe/v2/`, running the same scripts from that directory.
|
||||
|
||||
- **Split.** `split.py` accepts a quoted title and matches a contents entry by the part before a dash
|
||||
(“THE GUILTY PARTY”—AN EAST SIDE TRAGEDY). Result: **97 stories, 839 scenes**. Exactly the three
|
||||
missing stories were added, nothing was lost, and only their three host stories changed.
|
||||
- **Summaries carried over.** 829 of the 839 scenes have text identical to a v1 scene and keep that
|
||||
v1 summary verbatim (`carry_summaries.py`). The other 10 are summarised with the v1 model and prompt.
|
||||
- **Name stripper.** New York places (New York, Manhattan, Broadway, Harlem, the Bowery, and a few
|
||||
others) become "the city" before names are looked for, and days and holidays are never names. Months
|
||||
stay ambiguous ("May" is a person in scene 5) and are left alone.
|
||||
- **The baseline is re-measured on v2** (the v2 summaries, re-embedded), because both fixes change
|
||||
what is embedded. It is recorded below before any predicament is read.
|
||||
- **No trimming to 20 words.** Tried on the 14B pilot: cutting at the last clause break within 20
|
||||
words removed what the person is up against in most cut sentences ("…with a promise of future money"
|
||||
without "but his dishonesty sparks a violent confrontation"), and 10 of 40 had no clean break.
|
||||
The full 14B sentence is embedded.
|
||||
|
||||
### Result (2026-09-15)
|
||||
|
||||
Full run: 839 scenes re-described by `qwen3:14b`, one request at a time, 37 minutes on the GPU host
|
||||
(peak 270 W, mean 136 W, 65 °C, no faults; logs in `probe/gpu-logs/*-2026-09-15-1831*`). Sentences
|
||||
average 25 words. 219 of 839 still contain a job, relationship or place word, most often a relationship
|
||||
(*wife*, *husband*) — reported, not gated.
|
||||
|
||||
`topic_share.py`, k = 60, seeds 11–13, both measured on the v2 corpus:
|
||||
|
||||
| | Candidate groups | Median top topic-word share |
|
||||
| --- | --- | --- |
|
||||
| v2 summaries (baseline, `topic-share-baseline-v2.log`) | 45 | **20%** |
|
||||
| Predicaments (`topic-share-predicaments.log`) | 42 | **13%** |
|
||||
|
||||
**The pre-registered pass mark is not met.** It asked for half the baseline or less (10%); the measure
|
||||
fell by about a third, from 20% to 13%. The group-count mark is met (42 ≥ 30). The most topic-bound
|
||||
groups are also much weaker than the baseline's: the worst predicament group is *room* at 40% of 5
|
||||
scenes, against the baseline's *bride* at 62% of 8 and *policeman* at 60% of 20.
|
||||
|
||||
Not a pass and not a wash, so the decision goes to the reading test that this whole step exists for:
|
||||
`groups_page.py` builds `v2/groups-predicaments-k60-s24.html`, which lists each group's predicament
|
||||
sentences with each scene's original summary beneath, most cohesive group first, and asks the designer
|
||||
to mark each group **one predicament**, **two or three mixed**, or **not a predicament**. The openings
|
||||
repeat ("A man must" 45 times, "A man struggles" 37), so the risk to watch for is a group held together
|
||||
by that phrasing rather than by a predicament.
|
||||
@@ -0,0 +1,16 @@
|
||||
test: 41 candidates, 5 labelled 2
|
||||
FROZEN RULE S1 >= 0.6119 AND S6 <= 0.1603
|
||||
accepted 5 P2 0.40 P1+ 1.00 R2 0.40
|
||||
baseline accept all candidates accepted 41 P2 0.12 P1+ 0.51 R2 1.00
|
||||
baseline B0: tightness filter accepted 18 P2 0.17 P1+ 0.44 R2 0.60
|
||||
|
||||
accepted:
|
||||
T05 label 1 schemes, cons and theft over money
|
||||
T07 label 1 courtship
|
||||
T25 label 1 trying to impress or win someone's favour
|
||||
T35 label 2 coercion over money: threats, bribes, demands for payment
|
||||
T39 label 2 a spouse schemes against or confronts a spouse over infidelity
|
||||
rejected label-2:
|
||||
T16 a newcomer seeks acceptance in a social circle
|
||||
T17 a proposal or engagement, accepted or refused
|
||||
T30 a run-in with the police
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Check a saved review and turn it into situations.json.
|
||||
|
||||
python3 apply_review.py review/k60-s24 # finds the saved review itself
|
||||
python3 apply_review.py review/k60-s24 ~/Downloads/review-k60-s24.json
|
||||
|
||||
Without a file named, it uses the newest review-<run>*.json in the run directory, and
|
||||
otherwise the newest one in ~/Downloads, where a browser saves it. It prints every problem
|
||||
at once and writes nothing until the review passes; then it writes situations.json into the
|
||||
run directory.
|
||||
"""
|
||||
import datetime, json, os, pathlib, sys
|
||||
import review_format as rf
|
||||
|
||||
|
||||
def downloads_dir():
|
||||
return pathlib.Path(os.environ.get('STP_DOWNLOADS', pathlib.Path.home() / 'Downloads'))
|
||||
|
||||
|
||||
def find_review(run_dir, run_id):
|
||||
for folder in (run_dir, downloads_dir()):
|
||||
if folder.is_dir():
|
||||
found = sorted(folder.glob(f'review-{run_id}*.json'), key=lambda p: p.stat().st_mtime, reverse=True)
|
||||
if found:
|
||||
return found[0]
|
||||
return None
|
||||
|
||||
|
||||
def main(argv):
|
||||
if len(argv) not in (2, 3):
|
||||
print(__doc__.strip())
|
||||
return 2
|
||||
run_dir = pathlib.Path(argv[1])
|
||||
cand_path = run_dir / 'candidates.json'
|
||||
if not cand_path.exists():
|
||||
print(f'{run_dir} has no candidates.json. Check the folder name, or run review.py first.')
|
||||
return 1
|
||||
candidates = json.loads(cand_path.read_text(encoding='utf-8'))
|
||||
run_id = candidates['run']['id']
|
||||
|
||||
review_path = pathlib.Path(argv[2]).expanduser() if len(argv) == 3 else find_review(run_dir, run_id)
|
||||
if review_path is None or not review_path.exists():
|
||||
print(f'No saved review found for run {run_id}.')
|
||||
print(f'Open {run_dir / "REVIEW.html"} in a browser, and at the end press "Save review file".')
|
||||
print(f'It saves review-{run_id}.json into Downloads, where this looks for it.')
|
||||
return 1
|
||||
print(f'reading {review_path}')
|
||||
try:
|
||||
form = json.loads(review_path.read_text(encoding='utf-8'))
|
||||
except json.JSONDecodeError:
|
||||
print('That file is damaged and cannot be read. Save the review again from REVIEW.html; '
|
||||
'your progress is still in the browser.')
|
||||
return 1
|
||||
|
||||
errors, warnings, result = rf.validate(candidates, form)
|
||||
for w in warnings:
|
||||
print(f'note: {w}')
|
||||
if errors:
|
||||
print(f'\nThe review is not finished. {len(errors)} thing(s) to sort out in REVIEW.html, '
|
||||
f'then save the review file again:')
|
||||
for e in errors:
|
||||
print(f' - {e}')
|
||||
return 1
|
||||
|
||||
result['applied'] = datetime.datetime.now().astimezone().isoformat(timespec='seconds')
|
||||
result['review_file'] = str(review_path)
|
||||
out = run_dir / 'situations.json'
|
||||
out.write_text(json.dumps(result, indent=1, ensure_ascii=False), encoding='utf-8')
|
||||
c = result['counts']
|
||||
print(f'\n{out}: {c["situations"]} situations ({c["kept"]} kept, {c["same"]} merged in, '
|
||||
f'{c["dropped"]} dropped, {c["left_out"]} extras left out).')
|
||||
for s in result['situations']:
|
||||
print(f' {s["id"]} {s["situation"]} ({s["size"]} scenes, {s["stories"]} stories)')
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main(sys.argv))
|
||||
@@ -0,0 +1,824 @@
|
||||
{
|
||||
"D01": [
|
||||
105,
|
||||
232,
|
||||
241,
|
||||
242,
|
||||
248,
|
||||
271,
|
||||
277,
|
||||
336,
|
||||
387,
|
||||
390,
|
||||
418,
|
||||
451,
|
||||
465,
|
||||
528,
|
||||
568,
|
||||
709,
|
||||
778,
|
||||
782,
|
||||
790,
|
||||
820
|
||||
],
|
||||
"D02": [
|
||||
63,
|
||||
81,
|
||||
222,
|
||||
240,
|
||||
251,
|
||||
281,
|
||||
296,
|
||||
297,
|
||||
386,
|
||||
481,
|
||||
544,
|
||||
551,
|
||||
589,
|
||||
745
|
||||
],
|
||||
"D03": [
|
||||
20,
|
||||
57,
|
||||
65,
|
||||
125,
|
||||
135,
|
||||
178,
|
||||
196,
|
||||
198,
|
||||
326,
|
||||
353,
|
||||
359,
|
||||
361,
|
||||
364,
|
||||
373,
|
||||
375,
|
||||
380,
|
||||
381,
|
||||
459,
|
||||
519,
|
||||
540,
|
||||
543,
|
||||
639,
|
||||
640,
|
||||
642,
|
||||
692,
|
||||
739,
|
||||
779,
|
||||
799,
|
||||
802,
|
||||
819,
|
||||
833
|
||||
],
|
||||
"D04": [
|
||||
41,
|
||||
127,
|
||||
163,
|
||||
204,
|
||||
205,
|
||||
302,
|
||||
304,
|
||||
305,
|
||||
322,
|
||||
475,
|
||||
483,
|
||||
590,
|
||||
761,
|
||||
763,
|
||||
770
|
||||
],
|
||||
"D05": [
|
||||
73,
|
||||
82,
|
||||
156,
|
||||
157,
|
||||
158,
|
||||
225,
|
||||
389,
|
||||
430,
|
||||
485,
|
||||
486,
|
||||
487,
|
||||
488,
|
||||
514,
|
||||
653,
|
||||
787
|
||||
],
|
||||
"D06": [
|
||||
84,
|
||||
85,
|
||||
86,
|
||||
87,
|
||||
88,
|
||||
106,
|
||||
141,
|
||||
162,
|
||||
255,
|
||||
290,
|
||||
306,
|
||||
314,
|
||||
319,
|
||||
325,
|
||||
383,
|
||||
384,
|
||||
385,
|
||||
394,
|
||||
413,
|
||||
450,
|
||||
523,
|
||||
658,
|
||||
707
|
||||
],
|
||||
"D07": [
|
||||
37,
|
||||
40,
|
||||
42,
|
||||
62,
|
||||
151,
|
||||
154,
|
||||
175,
|
||||
199,
|
||||
201,
|
||||
215,
|
||||
218,
|
||||
223,
|
||||
254,
|
||||
264,
|
||||
267,
|
||||
269,
|
||||
279,
|
||||
345,
|
||||
348,
|
||||
350,
|
||||
352,
|
||||
354,
|
||||
370,
|
||||
371,
|
||||
453,
|
||||
479,
|
||||
521,
|
||||
567,
|
||||
595,
|
||||
700,
|
||||
815,
|
||||
821,
|
||||
822,
|
||||
826
|
||||
],
|
||||
"D08": [
|
||||
75,
|
||||
76,
|
||||
246,
|
||||
591,
|
||||
645,
|
||||
666,
|
||||
670,
|
||||
789
|
||||
],
|
||||
"D09": [
|
||||
133,
|
||||
134,
|
||||
144,
|
||||
168,
|
||||
192,
|
||||
207,
|
||||
276,
|
||||
376,
|
||||
379,
|
||||
399,
|
||||
461,
|
||||
517,
|
||||
545,
|
||||
619,
|
||||
625,
|
||||
649,
|
||||
731,
|
||||
805,
|
||||
809,
|
||||
810
|
||||
],
|
||||
"D10": [
|
||||
7,
|
||||
8,
|
||||
96,
|
||||
99,
|
||||
171,
|
||||
217,
|
||||
219,
|
||||
250,
|
||||
253,
|
||||
414,
|
||||
433,
|
||||
436,
|
||||
502,
|
||||
554,
|
||||
570,
|
||||
689
|
||||
],
|
||||
"D11": [
|
||||
21,
|
||||
104,
|
||||
124,
|
||||
138,
|
||||
140,
|
||||
208,
|
||||
263,
|
||||
282,
|
||||
330,
|
||||
331,
|
||||
369,
|
||||
377,
|
||||
401,
|
||||
437,
|
||||
440,
|
||||
492,
|
||||
518,
|
||||
569,
|
||||
629,
|
||||
633,
|
||||
634,
|
||||
636,
|
||||
637,
|
||||
638,
|
||||
641,
|
||||
643,
|
||||
646,
|
||||
687,
|
||||
727,
|
||||
736,
|
||||
804,
|
||||
811,
|
||||
831,
|
||||
832,
|
||||
834,
|
||||
835
|
||||
],
|
||||
"D12": [
|
||||
160,
|
||||
480,
|
||||
495,
|
||||
627,
|
||||
628,
|
||||
706,
|
||||
729,
|
||||
730,
|
||||
750,
|
||||
807,
|
||||
816
|
||||
],
|
||||
"D13": [
|
||||
11,
|
||||
173,
|
||||
265,
|
||||
367,
|
||||
398,
|
||||
407,
|
||||
500,
|
||||
511,
|
||||
512,
|
||||
515,
|
||||
696,
|
||||
698,
|
||||
699,
|
||||
718,
|
||||
771,
|
||||
830
|
||||
],
|
||||
"D14": [
|
||||
109,
|
||||
164,
|
||||
249,
|
||||
270,
|
||||
315,
|
||||
316,
|
||||
374,
|
||||
539,
|
||||
541,
|
||||
562,
|
||||
564,
|
||||
583,
|
||||
671,
|
||||
708,
|
||||
717,
|
||||
719,
|
||||
743,
|
||||
747
|
||||
],
|
||||
"D15": [
|
||||
9,
|
||||
103,
|
||||
110,
|
||||
111,
|
||||
112,
|
||||
228,
|
||||
259,
|
||||
288,
|
||||
312,
|
||||
448,
|
||||
469,
|
||||
477,
|
||||
478,
|
||||
557,
|
||||
616,
|
||||
620,
|
||||
621,
|
||||
623,
|
||||
624,
|
||||
711,
|
||||
737,
|
||||
786,
|
||||
827,
|
||||
829
|
||||
],
|
||||
"D16": [
|
||||
38,
|
||||
121,
|
||||
129,
|
||||
148,
|
||||
200,
|
||||
363,
|
||||
647,
|
||||
663,
|
||||
680,
|
||||
694,
|
||||
725,
|
||||
726,
|
||||
769
|
||||
],
|
||||
"D17": [
|
||||
14,
|
||||
292,
|
||||
346,
|
||||
524,
|
||||
525,
|
||||
527,
|
||||
529,
|
||||
575,
|
||||
664,
|
||||
744
|
||||
],
|
||||
"D18": [
|
||||
172,
|
||||
224,
|
||||
236,
|
||||
237,
|
||||
243,
|
||||
247,
|
||||
323,
|
||||
412,
|
||||
417,
|
||||
420,
|
||||
443,
|
||||
445,
|
||||
447,
|
||||
449,
|
||||
695,
|
||||
775,
|
||||
777,
|
||||
818
|
||||
],
|
||||
"D19": [
|
||||
98,
|
||||
142,
|
||||
226,
|
||||
238,
|
||||
262,
|
||||
309,
|
||||
338,
|
||||
349,
|
||||
351,
|
||||
404,
|
||||
419,
|
||||
602,
|
||||
659,
|
||||
665,
|
||||
764
|
||||
],
|
||||
"D20": [
|
||||
6,
|
||||
68,
|
||||
116,
|
||||
145,
|
||||
318,
|
||||
548,
|
||||
667,
|
||||
668,
|
||||
793,
|
||||
795
|
||||
],
|
||||
"D21": [
|
||||
28,
|
||||
67,
|
||||
136,
|
||||
177,
|
||||
185,
|
||||
260,
|
||||
272,
|
||||
278,
|
||||
494,
|
||||
598,
|
||||
600,
|
||||
604,
|
||||
632,
|
||||
688,
|
||||
704,
|
||||
705,
|
||||
713,
|
||||
746,
|
||||
783,
|
||||
796
|
||||
],
|
||||
"D22": [
|
||||
12,
|
||||
18,
|
||||
22,
|
||||
29,
|
||||
44,
|
||||
53,
|
||||
54,
|
||||
66,
|
||||
69,
|
||||
128,
|
||||
131,
|
||||
187,
|
||||
227,
|
||||
230,
|
||||
231,
|
||||
285,
|
||||
287,
|
||||
303,
|
||||
347,
|
||||
392,
|
||||
397,
|
||||
406,
|
||||
410,
|
||||
482,
|
||||
538,
|
||||
542,
|
||||
560,
|
||||
572,
|
||||
574,
|
||||
662,
|
||||
710,
|
||||
766,
|
||||
785,
|
||||
814
|
||||
],
|
||||
"D23": [
|
||||
123,
|
||||
176,
|
||||
391,
|
||||
516,
|
||||
652,
|
||||
654,
|
||||
655,
|
||||
791,
|
||||
792,
|
||||
794,
|
||||
800
|
||||
],
|
||||
"D24": [
|
||||
27,
|
||||
74,
|
||||
77,
|
||||
150,
|
||||
165,
|
||||
180,
|
||||
229,
|
||||
234,
|
||||
235,
|
||||
252,
|
||||
273,
|
||||
308,
|
||||
321,
|
||||
355,
|
||||
358,
|
||||
382,
|
||||
415,
|
||||
452,
|
||||
460,
|
||||
508,
|
||||
520,
|
||||
558,
|
||||
566,
|
||||
577,
|
||||
650,
|
||||
660,
|
||||
672,
|
||||
679,
|
||||
728,
|
||||
733,
|
||||
773,
|
||||
774,
|
||||
776
|
||||
],
|
||||
"D25": [
|
||||
5,
|
||||
17,
|
||||
46,
|
||||
58,
|
||||
59,
|
||||
60,
|
||||
78,
|
||||
139,
|
||||
212,
|
||||
221,
|
||||
342,
|
||||
362,
|
||||
372,
|
||||
378,
|
||||
435,
|
||||
439,
|
||||
489,
|
||||
522,
|
||||
537,
|
||||
571,
|
||||
582,
|
||||
587,
|
||||
685,
|
||||
734
|
||||
],
|
||||
"D26": [
|
||||
33,
|
||||
209,
|
||||
210,
|
||||
211,
|
||||
213,
|
||||
216,
|
||||
310,
|
||||
311,
|
||||
337,
|
||||
396,
|
||||
499,
|
||||
741,
|
||||
753
|
||||
],
|
||||
"D27": [
|
||||
23,
|
||||
174,
|
||||
261,
|
||||
266,
|
||||
298,
|
||||
299,
|
||||
360,
|
||||
368,
|
||||
509,
|
||||
576,
|
||||
603,
|
||||
607,
|
||||
644,
|
||||
735,
|
||||
806
|
||||
],
|
||||
"D28": [
|
||||
0,
|
||||
1,
|
||||
2,
|
||||
4,
|
||||
143,
|
||||
220,
|
||||
274,
|
||||
466,
|
||||
505,
|
||||
712,
|
||||
732,
|
||||
837
|
||||
],
|
||||
"D29": [
|
||||
51,
|
||||
61,
|
||||
97,
|
||||
130,
|
||||
132,
|
||||
147,
|
||||
149,
|
||||
153,
|
||||
182,
|
||||
239,
|
||||
257,
|
||||
472,
|
||||
473,
|
||||
474,
|
||||
484,
|
||||
536,
|
||||
561,
|
||||
618,
|
||||
828
|
||||
],
|
||||
"D30": [
|
||||
3,
|
||||
56,
|
||||
101,
|
||||
214,
|
||||
408,
|
||||
409,
|
||||
411,
|
||||
441,
|
||||
471,
|
||||
476,
|
||||
510,
|
||||
597,
|
||||
599,
|
||||
701,
|
||||
702
|
||||
],
|
||||
"D31": [
|
||||
47,
|
||||
70,
|
||||
113,
|
||||
170,
|
||||
275,
|
||||
343,
|
||||
416,
|
||||
421,
|
||||
422,
|
||||
424,
|
||||
470,
|
||||
497,
|
||||
549,
|
||||
563,
|
||||
565,
|
||||
683,
|
||||
749,
|
||||
754,
|
||||
755,
|
||||
784,
|
||||
808,
|
||||
836
|
||||
],
|
||||
"D32": [
|
||||
19,
|
||||
24,
|
||||
25,
|
||||
100,
|
||||
159,
|
||||
506,
|
||||
559,
|
||||
605,
|
||||
673,
|
||||
674,
|
||||
675,
|
||||
676,
|
||||
677,
|
||||
684,
|
||||
781
|
||||
],
|
||||
"D33": [
|
||||
137,
|
||||
186,
|
||||
301,
|
||||
356,
|
||||
425,
|
||||
427,
|
||||
596,
|
||||
606,
|
||||
608,
|
||||
631,
|
||||
651,
|
||||
657,
|
||||
691,
|
||||
738,
|
||||
803
|
||||
],
|
||||
"D34": [
|
||||
32,
|
||||
108,
|
||||
126,
|
||||
146,
|
||||
365,
|
||||
530,
|
||||
531,
|
||||
532,
|
||||
533,
|
||||
534,
|
||||
535,
|
||||
588,
|
||||
592,
|
||||
593,
|
||||
615,
|
||||
681,
|
||||
682,
|
||||
720,
|
||||
722,
|
||||
723,
|
||||
724,
|
||||
751,
|
||||
767,
|
||||
768
|
||||
],
|
||||
"D35": [
|
||||
16,
|
||||
26,
|
||||
39,
|
||||
52,
|
||||
55,
|
||||
71,
|
||||
107,
|
||||
166,
|
||||
167,
|
||||
258,
|
||||
289,
|
||||
291,
|
||||
400,
|
||||
426,
|
||||
446,
|
||||
493,
|
||||
550,
|
||||
573,
|
||||
584,
|
||||
586,
|
||||
622,
|
||||
648,
|
||||
661,
|
||||
686,
|
||||
690,
|
||||
697,
|
||||
801,
|
||||
817
|
||||
],
|
||||
"D36": [
|
||||
102,
|
||||
152,
|
||||
203,
|
||||
284,
|
||||
327,
|
||||
797
|
||||
],
|
||||
"D37": [
|
||||
10,
|
||||
80,
|
||||
115,
|
||||
161,
|
||||
313,
|
||||
317,
|
||||
357,
|
||||
395,
|
||||
444,
|
||||
498,
|
||||
513,
|
||||
556,
|
||||
594,
|
||||
693,
|
||||
703,
|
||||
740,
|
||||
765,
|
||||
825
|
||||
],
|
||||
"D38": [
|
||||
50,
|
||||
64,
|
||||
83,
|
||||
114,
|
||||
122,
|
||||
206,
|
||||
307,
|
||||
341,
|
||||
429,
|
||||
431,
|
||||
434,
|
||||
496,
|
||||
714,
|
||||
715,
|
||||
716,
|
||||
772
|
||||
],
|
||||
"D39": [
|
||||
13,
|
||||
43,
|
||||
45,
|
||||
48,
|
||||
49,
|
||||
120,
|
||||
244,
|
||||
339,
|
||||
340,
|
||||
344,
|
||||
464,
|
||||
578,
|
||||
579,
|
||||
580,
|
||||
581,
|
||||
610,
|
||||
611,
|
||||
612,
|
||||
613,
|
||||
614
|
||||
],
|
||||
"D40": [
|
||||
36,
|
||||
79,
|
||||
117,
|
||||
118,
|
||||
119,
|
||||
155,
|
||||
393,
|
||||
428,
|
||||
432,
|
||||
504,
|
||||
507,
|
||||
546,
|
||||
547,
|
||||
553,
|
||||
780
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,805 @@
|
||||
{
|
||||
"T01": [
|
||||
90,
|
||||
162,
|
||||
178,
|
||||
188,
|
||||
292,
|
||||
443,
|
||||
503,
|
||||
658,
|
||||
811,
|
||||
812
|
||||
],
|
||||
"T02": [
|
||||
116,
|
||||
125,
|
||||
214,
|
||||
227,
|
||||
261,
|
||||
288,
|
||||
312,
|
||||
363,
|
||||
375,
|
||||
448,
|
||||
478,
|
||||
547,
|
||||
593,
|
||||
594,
|
||||
647,
|
||||
702,
|
||||
703,
|
||||
711,
|
||||
721,
|
||||
786,
|
||||
829,
|
||||
831,
|
||||
832
|
||||
],
|
||||
"T03": [
|
||||
240,
|
||||
277,
|
||||
279,
|
||||
280,
|
||||
281,
|
||||
342,
|
||||
447,
|
||||
685,
|
||||
782,
|
||||
820,
|
||||
821
|
||||
],
|
||||
"T04": [
|
||||
3,
|
||||
73,
|
||||
74,
|
||||
110,
|
||||
111,
|
||||
113,
|
||||
115,
|
||||
161,
|
||||
271,
|
||||
272,
|
||||
309,
|
||||
336,
|
||||
395,
|
||||
444,
|
||||
600,
|
||||
679,
|
||||
693,
|
||||
696,
|
||||
698,
|
||||
765
|
||||
],
|
||||
"T05": [
|
||||
37,
|
||||
58,
|
||||
75,
|
||||
151,
|
||||
172,
|
||||
173,
|
||||
175,
|
||||
177,
|
||||
199,
|
||||
201,
|
||||
218,
|
||||
236,
|
||||
251,
|
||||
264,
|
||||
265,
|
||||
267,
|
||||
323,
|
||||
328,
|
||||
361,
|
||||
367,
|
||||
370,
|
||||
371,
|
||||
415,
|
||||
479,
|
||||
500,
|
||||
540,
|
||||
543,
|
||||
777,
|
||||
815,
|
||||
822
|
||||
],
|
||||
"T06": [
|
||||
140,
|
||||
146,
|
||||
183,
|
||||
184,
|
||||
186,
|
||||
190,
|
||||
195,
|
||||
197,
|
||||
402,
|
||||
403,
|
||||
404,
|
||||
405,
|
||||
410
|
||||
],
|
||||
"T07": [
|
||||
18,
|
||||
21,
|
||||
39,
|
||||
44,
|
||||
53,
|
||||
106,
|
||||
107,
|
||||
126,
|
||||
160,
|
||||
167,
|
||||
285,
|
||||
373,
|
||||
392,
|
||||
400,
|
||||
406,
|
||||
482,
|
||||
532,
|
||||
560,
|
||||
574,
|
||||
608,
|
||||
622,
|
||||
627,
|
||||
642,
|
||||
710,
|
||||
783,
|
||||
785,
|
||||
801,
|
||||
816,
|
||||
830
|
||||
],
|
||||
"T08": [
|
||||
103,
|
||||
134,
|
||||
165,
|
||||
360,
|
||||
477,
|
||||
545,
|
||||
571,
|
||||
576,
|
||||
577,
|
||||
602,
|
||||
625,
|
||||
632,
|
||||
729,
|
||||
810
|
||||
],
|
||||
"T09": [
|
||||
9,
|
||||
71,
|
||||
114,
|
||||
132,
|
||||
133,
|
||||
144,
|
||||
166,
|
||||
182,
|
||||
192,
|
||||
238,
|
||||
239,
|
||||
256,
|
||||
257,
|
||||
259,
|
||||
289,
|
||||
303,
|
||||
341,
|
||||
356,
|
||||
419,
|
||||
480,
|
||||
501,
|
||||
550,
|
||||
557,
|
||||
561,
|
||||
617,
|
||||
618,
|
||||
619,
|
||||
620,
|
||||
621,
|
||||
623,
|
||||
624,
|
||||
648,
|
||||
651,
|
||||
660,
|
||||
661,
|
||||
665,
|
||||
691,
|
||||
730,
|
||||
750,
|
||||
751,
|
||||
805,
|
||||
807,
|
||||
809
|
||||
],
|
||||
"T10": [
|
||||
86,
|
||||
337,
|
||||
358,
|
||||
359,
|
||||
364,
|
||||
435,
|
||||
439,
|
||||
440,
|
||||
441,
|
||||
650
|
||||
],
|
||||
"T11": [
|
||||
108,
|
||||
325,
|
||||
326,
|
||||
484,
|
||||
531,
|
||||
558,
|
||||
615,
|
||||
795,
|
||||
800
|
||||
],
|
||||
"T12": [
|
||||
27,
|
||||
168,
|
||||
180,
|
||||
207,
|
||||
378,
|
||||
399,
|
||||
452,
|
||||
461,
|
||||
506,
|
||||
517,
|
||||
598,
|
||||
603,
|
||||
659,
|
||||
674,
|
||||
677,
|
||||
688,
|
||||
731,
|
||||
742,
|
||||
749,
|
||||
806,
|
||||
808
|
||||
],
|
||||
"T13": [
|
||||
297,
|
||||
343,
|
||||
344,
|
||||
345,
|
||||
348,
|
||||
533,
|
||||
534,
|
||||
555,
|
||||
744
|
||||
],
|
||||
"T14": [
|
||||
5,
|
||||
46,
|
||||
60,
|
||||
78,
|
||||
139,
|
||||
174,
|
||||
196,
|
||||
212,
|
||||
221,
|
||||
230,
|
||||
333,
|
||||
362,
|
||||
369,
|
||||
372,
|
||||
423,
|
||||
489,
|
||||
492,
|
||||
522,
|
||||
537,
|
||||
569,
|
||||
587,
|
||||
609,
|
||||
635,
|
||||
641,
|
||||
643,
|
||||
734,
|
||||
735,
|
||||
797
|
||||
],
|
||||
"T15": [
|
||||
25,
|
||||
77,
|
||||
245,
|
||||
252,
|
||||
275,
|
||||
276,
|
||||
306,
|
||||
317,
|
||||
322,
|
||||
355,
|
||||
416,
|
||||
421,
|
||||
422,
|
||||
424,
|
||||
490,
|
||||
520,
|
||||
538,
|
||||
544,
|
||||
549,
|
||||
551,
|
||||
559,
|
||||
566,
|
||||
605,
|
||||
649,
|
||||
683,
|
||||
728,
|
||||
754,
|
||||
774,
|
||||
776,
|
||||
825,
|
||||
826,
|
||||
836
|
||||
],
|
||||
"T16": [
|
||||
23,
|
||||
24,
|
||||
26,
|
||||
64,
|
||||
65,
|
||||
136,
|
||||
143,
|
||||
185,
|
||||
202,
|
||||
494,
|
||||
496,
|
||||
606,
|
||||
798
|
||||
],
|
||||
"T17": [
|
||||
17,
|
||||
22,
|
||||
30,
|
||||
35,
|
||||
47,
|
||||
127,
|
||||
170,
|
||||
269,
|
||||
283,
|
||||
287,
|
||||
380,
|
||||
381,
|
||||
401,
|
||||
426,
|
||||
427,
|
||||
526,
|
||||
542,
|
||||
556,
|
||||
585,
|
||||
596,
|
||||
631,
|
||||
639,
|
||||
646,
|
||||
692,
|
||||
736,
|
||||
755,
|
||||
779,
|
||||
799,
|
||||
802,
|
||||
803
|
||||
],
|
||||
"T18": [
|
||||
19,
|
||||
43,
|
||||
45,
|
||||
48,
|
||||
49,
|
||||
159,
|
||||
339,
|
||||
340,
|
||||
365,
|
||||
578,
|
||||
579,
|
||||
580,
|
||||
581,
|
||||
610,
|
||||
611,
|
||||
612,
|
||||
613,
|
||||
614
|
||||
],
|
||||
"T19": [
|
||||
15,
|
||||
331,
|
||||
366,
|
||||
377,
|
||||
379,
|
||||
529,
|
||||
626,
|
||||
634,
|
||||
636,
|
||||
637,
|
||||
638,
|
||||
644,
|
||||
687
|
||||
],
|
||||
"T20": [
|
||||
96,
|
||||
217,
|
||||
219,
|
||||
220,
|
||||
250,
|
||||
274,
|
||||
313,
|
||||
432,
|
||||
433,
|
||||
502,
|
||||
505,
|
||||
689
|
||||
],
|
||||
"T21": [
|
||||
232,
|
||||
528,
|
||||
582,
|
||||
668,
|
||||
675,
|
||||
676,
|
||||
684
|
||||
],
|
||||
"T22": [
|
||||
222,
|
||||
329,
|
||||
425,
|
||||
568,
|
||||
775
|
||||
],
|
||||
"T23": [
|
||||
7,
|
||||
8,
|
||||
124,
|
||||
171,
|
||||
206,
|
||||
253,
|
||||
282,
|
||||
308,
|
||||
316,
|
||||
338,
|
||||
398,
|
||||
414,
|
||||
418,
|
||||
420,
|
||||
434,
|
||||
436,
|
||||
466,
|
||||
504,
|
||||
509,
|
||||
514,
|
||||
553,
|
||||
554,
|
||||
564,
|
||||
570,
|
||||
712,
|
||||
714,
|
||||
716,
|
||||
741
|
||||
],
|
||||
"T24": [
|
||||
142,
|
||||
446,
|
||||
565,
|
||||
584,
|
||||
588,
|
||||
686,
|
||||
697,
|
||||
704,
|
||||
732
|
||||
],
|
||||
"T25": [
|
||||
13,
|
||||
16,
|
||||
20,
|
||||
28,
|
||||
29,
|
||||
88,
|
||||
135,
|
||||
141,
|
||||
266,
|
||||
332,
|
||||
368,
|
||||
495,
|
||||
573,
|
||||
586,
|
||||
604,
|
||||
607,
|
||||
628,
|
||||
629,
|
||||
630,
|
||||
690,
|
||||
727,
|
||||
748,
|
||||
759,
|
||||
784,
|
||||
817,
|
||||
818,
|
||||
819
|
||||
],
|
||||
"T26": [
|
||||
189,
|
||||
191,
|
||||
193,
|
||||
194,
|
||||
203,
|
||||
204,
|
||||
205,
|
||||
247,
|
||||
263,
|
||||
284,
|
||||
592,
|
||||
595,
|
||||
669,
|
||||
695,
|
||||
709,
|
||||
804
|
||||
],
|
||||
"T27": [
|
||||
79,
|
||||
98,
|
||||
99,
|
||||
109,
|
||||
164,
|
||||
249,
|
||||
270,
|
||||
374,
|
||||
539,
|
||||
541,
|
||||
562,
|
||||
583,
|
||||
664,
|
||||
671,
|
||||
719,
|
||||
740,
|
||||
743,
|
||||
773
|
||||
],
|
||||
"T28": [
|
||||
181,
|
||||
346,
|
||||
347,
|
||||
349,
|
||||
350,
|
||||
353,
|
||||
460,
|
||||
518,
|
||||
535,
|
||||
722,
|
||||
726,
|
||||
737,
|
||||
738,
|
||||
814
|
||||
],
|
||||
"T29": [
|
||||
155,
|
||||
233,
|
||||
234,
|
||||
262,
|
||||
546,
|
||||
780,
|
||||
834
|
||||
],
|
||||
"T30": [
|
||||
11,
|
||||
243,
|
||||
315,
|
||||
407,
|
||||
511,
|
||||
512,
|
||||
515,
|
||||
589,
|
||||
699,
|
||||
700,
|
||||
720,
|
||||
723,
|
||||
747
|
||||
],
|
||||
"T31": [
|
||||
31,
|
||||
32,
|
||||
34,
|
||||
296,
|
||||
530,
|
||||
681,
|
||||
682,
|
||||
724,
|
||||
725,
|
||||
752,
|
||||
766,
|
||||
767,
|
||||
768
|
||||
],
|
||||
"T32": [
|
||||
76,
|
||||
100,
|
||||
105,
|
||||
128,
|
||||
235,
|
||||
237,
|
||||
246,
|
||||
327,
|
||||
382,
|
||||
397,
|
||||
472,
|
||||
473,
|
||||
474,
|
||||
483,
|
||||
507,
|
||||
548,
|
||||
645,
|
||||
666,
|
||||
667,
|
||||
670,
|
||||
678,
|
||||
708,
|
||||
717,
|
||||
718,
|
||||
770,
|
||||
788,
|
||||
789,
|
||||
790,
|
||||
792,
|
||||
793,
|
||||
828
|
||||
],
|
||||
"T33": [
|
||||
69,
|
||||
70,
|
||||
80,
|
||||
101,
|
||||
129,
|
||||
226,
|
||||
396,
|
||||
408,
|
||||
470,
|
||||
471,
|
||||
476,
|
||||
497,
|
||||
498,
|
||||
499,
|
||||
510,
|
||||
527,
|
||||
563,
|
||||
597,
|
||||
599,
|
||||
701,
|
||||
769,
|
||||
771,
|
||||
813
|
||||
],
|
||||
"T34": [
|
||||
4,
|
||||
118,
|
||||
229,
|
||||
351,
|
||||
417,
|
||||
449,
|
||||
450,
|
||||
656,
|
||||
657,
|
||||
772,
|
||||
781
|
||||
],
|
||||
"T35": [
|
||||
10,
|
||||
12,
|
||||
38,
|
||||
42,
|
||||
59,
|
||||
62,
|
||||
121,
|
||||
324,
|
||||
352,
|
||||
354,
|
||||
409,
|
||||
662,
|
||||
680,
|
||||
694,
|
||||
739,
|
||||
823
|
||||
],
|
||||
"T36": [
|
||||
123,
|
||||
209,
|
||||
254,
|
||||
521,
|
||||
791
|
||||
],
|
||||
"T37": [
|
||||
67,
|
||||
68,
|
||||
104,
|
||||
137,
|
||||
169,
|
||||
231,
|
||||
260,
|
||||
273,
|
||||
278,
|
||||
298,
|
||||
321,
|
||||
429,
|
||||
431,
|
||||
493,
|
||||
508,
|
||||
572,
|
||||
705,
|
||||
713,
|
||||
733,
|
||||
745,
|
||||
746,
|
||||
796
|
||||
],
|
||||
"T38": [
|
||||
57,
|
||||
61,
|
||||
63,
|
||||
66,
|
||||
97,
|
||||
120,
|
||||
130,
|
||||
147,
|
||||
150,
|
||||
198,
|
||||
242,
|
||||
244,
|
||||
314,
|
||||
438,
|
||||
445,
|
||||
464,
|
||||
519,
|
||||
536,
|
||||
601,
|
||||
640,
|
||||
824,
|
||||
827,
|
||||
833
|
||||
],
|
||||
"T39": [
|
||||
40,
|
||||
41,
|
||||
72,
|
||||
131,
|
||||
163,
|
||||
187,
|
||||
268,
|
||||
293,
|
||||
301,
|
||||
302,
|
||||
304,
|
||||
305,
|
||||
391,
|
||||
411,
|
||||
475,
|
||||
567,
|
||||
590,
|
||||
663,
|
||||
756,
|
||||
762,
|
||||
763
|
||||
],
|
||||
"T40": [
|
||||
122,
|
||||
299,
|
||||
412,
|
||||
707,
|
||||
778
|
||||
],
|
||||
"T41": [
|
||||
82,
|
||||
102,
|
||||
156,
|
||||
157,
|
||||
158,
|
||||
225,
|
||||
290,
|
||||
430,
|
||||
485,
|
||||
486,
|
||||
487,
|
||||
488,
|
||||
516,
|
||||
652,
|
||||
653,
|
||||
654,
|
||||
655,
|
||||
787,
|
||||
794
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Carry v1 scene summaries over to a re-split corpus, summarising only scenes whose text is new.
|
||||
|
||||
cd v2 && STP_OLLAMA=http://host:11434 python3 ../carry_summaries.py ../summaries.json summaries.json
|
||||
|
||||
The v1 split merged three quoted-title stories into their neighbours (SELECTION.md, "Pilot"). Re-splitting
|
||||
renumbers every later scene but leaves almost every scene's text identical, so a scene whose text matches
|
||||
a v1 scene keeps its v1 summary verbatim, and only the rest go to the model, with summarise.py's model
|
||||
and prompt. Output has summarise.py's layout, in the new scene order, with 'carried' marking which is which.
|
||||
"""
|
||||
import json, pathlib, re, sys, time
|
||||
import ollama
|
||||
|
||||
src, dst = pathlib.Path(sys.argv[1]), pathlib.Path(sys.argv[2])
|
||||
old = json.loads(src.read_text(encoding='utf-8'))
|
||||
old_chunks = json.loads((src.parent / 'chunks.json').read_text(encoding='utf-8'))
|
||||
chunks = json.loads(pathlib.Path('chunks.json').read_text(encoding='utf-8'))
|
||||
|
||||
# summarise.py runs on import, so its prompt is read from the file; it must be the one v1 was made with.
|
||||
code = (pathlib.Path(__file__).parent / 'summarise.py').read_text(encoding='utf-8')
|
||||
SYSTEM = re.search(r'SYSTEM = """(.*?)"""', code, re.S).group(1)
|
||||
END = re.compile(r'(?<!\bMr)(?<!\bMrs)(?<!\bMs)(?<!\bDr)(?<!\bSt)(?<!\bNo)[.!?](?:\s|$)')
|
||||
if SYSTEM != old['prompt']:
|
||||
sys.exit('summarise.py prompt differs from the one v1 summaries were made with')
|
||||
|
||||
by_text = {old_chunks[x['chunk']]['text']: x for x in old['summaries']}
|
||||
out, fresh, t0 = [], 0, time.time()
|
||||
for i, c in enumerate(chunks):
|
||||
prev = by_text.get(c['text'])
|
||||
if prev:
|
||||
out.append({'chunk': i, 'story': c['story'], 'summary': prev['summary'],
|
||||
'model_output': prev['model_output'], 'carried': prev['chunk']})
|
||||
continue
|
||||
text, _, _ = ollama.chat(old['model'], SYSTEM, c['text'])
|
||||
t = re.sub(r'\s+', ' ', text).strip().strip('"')
|
||||
m = END.search(t)
|
||||
out.append({'chunk': i, 'story': c['story'], 'summary': t[:m.end()].strip() if m else t,
|
||||
'model_output': text, 'carried': None})
|
||||
fresh += 1
|
||||
print(f' new scene {i} ({c["title"]}): {out[-1]["summary"]}', flush=True)
|
||||
|
||||
dst.write_text(json.dumps({'model': old['model'], 'prompt': SYSTEM, 'summaries': out}, indent=1), encoding='utf-8')
|
||||
print(f'{len(out)} summaries: {len(out) - fresh} carried from {src}, {fresh} new, {time.time() - t0:.0f}s')
|
||||
@@ -0,0 +1,108 @@
|
||||
# Situation catalogue: classify, don't cluster
|
||||
|
||||
Built 2026-09-17 from the three hand references: O. Henry (`../v2/claude/`), Wharton (`../wharton/claude/`)
|
||||
and Jacobs (`../jacobs/claude/`). This is the step that replaces embed-and-cluster in the proposed pipeline:
|
||||
a model assigns each scene's predicament one catalogue entry, or `none`.
|
||||
|
||||
## Files
|
||||
|
||||
| File | What |
|
||||
| --- | --- |
|
||||
| `catalogue_src.py` | The 36 entries (definition, includes, excludes, tone, recurs) and `SOURCES`, which maps every hand group in the three references to exactly one entry. Running it checks the mapping and writes the rest. No inference. |
|
||||
| `catalogue.json` | The entries, with up to three worked examples per corpus, each tagged with its corpus |
|
||||
| `labels-<corpus>.json` | Every scene's catalogue label from the hand grouping, or `none`. This is the answer key for scoring |
|
||||
| `recurrence.log` | One recurrence rule applied to all three: at least 5 scenes, at least max(3, 4% of the stories) stories, and no story over 50% |
|
||||
| `classify.py` | Classifies one scene per request through `../ollama.py`. Leave-one-corpus-out: the examples never come from the corpus being classified. `--k 0` offers the whole catalogue |
|
||||
| `score.py` | Scores classifier output against `labels-*.json`. `--selftest` checks the scorer on hand-vs-hand and on random labels |
|
||||
| `emb/` | Cached nomic-embed-text vectors for the catalogue and the O. Henry 14B predicaments |
|
||||
| `out/` | Classifier output |
|
||||
|
||||
## Results so far
|
||||
|
||||
**1. One rule for all three corpora** (`recurrence.log`). O. Henry keeps 16 recurring situations and
|
||||
Wharton keeps 16. Jacobs falls from its hand-counted 19 to **6**, because its groups had used a two-story
|
||||
minimum. With 12 stories, three stories a group is the least that means "recurs", and most of its
|
||||
groups do not reach it. Only `rival`, `strings` and `newcomer` are found in all three corpora; ten more
|
||||
are in two. A 20-30 story corpus is a real floor.
|
||||
|
||||
**2. Clustering fails even on the best text.** My 839 hand-written O. Henry predicaments were
|
||||
embedded exactly as `embed.py` embedded the 14B ones (`../v2/claude/predicament_embeddings-claude.json`),
|
||||
and the same k = 60 k-means was run on them (`../v2/claude/compare-claude-embeddings.log`). The groups
|
||||
agree with my own hand grouping at **ARI 0.05** (0.05-0.05 over four seeds), no better than the 14B text's
|
||||
0.07. The fault is the embed-and-cluster step, not the predicament text. Better rewriting will not
|
||||
rescue clustering.
|
||||
|
||||
**3. Embeddings cannot even shortlist.** With the 14B predicaments against the catalogue definitions, the
|
||||
hand label is the nearest entry for 13% of situated scenes, is in the top 8 for 50%, and is in the top 20
|
||||
for 83%. So `classify.py` now offers the whole catalogue (`--k 0`), with the catalogue in the system prompt
|
||||
so the server can reuse the cached prefix.
|
||||
|
||||
**4. Classifier pilot: 40 scenes only, too small to judge** (`pilot40.log`; qwen3:14b, shortlist k = 8,
|
||||
14B predicaments). The model called 34 of 40 scenes situated where the hand labels have 14. Where both
|
||||
called a scene situated, its label was right in 3 of 13 (23%). Kappa was 0.09. The prompt has since been
|
||||
changed: the whole catalogue, a note to expect about half `none`, and a fit rating from 1 to 3 that
|
||||
`score.py --minfit=` can threshold. The changed prompt has not been measured.
|
||||
|
||||
**4b. The changed prompt, on 30 scenes before the host hung** (`out/ohenry-q14-dev1.partial.json`, scored
|
||||
in `pilot40.log`): it tells situated scenes from `none` better (precision 67%, recall 100%, kappa 0.23), but
|
||||
where both call a scene situated, its label is right in only 2 of 14. Reading the confusions, some of its
|
||||
labels are defensible. I labelled a whole story arc with one situation (every scene of *A Lickpenny Lover* is
|
||||
`station`), while the model judges each scene alone (`pretence`, `unspoken`). And the 14B predicaments
|
||||
("A man struggles to reconcile his pride with...") are too vague to classify from. So the next development
|
||||
round should (a) also give the classifier the scene summary, or the predicaments either side, and (b) score
|
||||
at the level a pack uses: which situations recur, with what members, rather than scene-exact agreement.
|
||||
|
||||
## Not done: the inference host's GPU fell off the bus
|
||||
|
||||
At **22:19:07 on 2026-09-17**, the inference host's GPU fell off the PCIe bus (kernel: `Xid 79`, then `Xid 154, GPU Reset
|
||||
Required`). It happened in the middle of a `classify.py` development request whose prompt was about 2,450 tokens (the
|
||||
whole catalogue). Over the ten minutes before, calls had slowed from about 2 s a scene to about 20 s. This is the
|
||||
second such fault; the first was on 2026-09-13. **No power or link loggers were running**, because SSH was refused
|
||||
while the key was out of the agent, so there is no evidence either way on power. After the fault, Ollama carried on
|
||||
on the CPU. That is why nomic-embed-text still answered, and finding 2 was computed on CPU embeddings, which does
|
||||
not change the method. All client processes were stopped. The host needs a reboot (reset required). The NVIDIA
|
||||
driver asks for `nvidia-bug-report.sh` to be run as root before the module is unloaded.
|
||||
|
||||
The prompt design matters for this host: offering the whole catalogue makes every call a long prefill. Either keep
|
||||
the catalogue as a cached prefix (the current `classify.py`, which puts it in the system prompt; untested on the
|
||||
GPU), or shortlist with something better than embeddings, or run with a lower power limit and the loggers on.
|
||||
|
||||
### Third fault, with the loggers running: 2026-09-18 01:00:03
|
||||
|
||||
After a reboot, with the power limit set to 200 W (from 280 W) and all three loggers running, a 20-scene pilot
|
||||
(`classify.py ohenry q14 --limit 20 --tag=-dev1`, catalogue as the cached system prompt) lost the GPU about 15 s in.
|
||||
The logs are in `gpu-logs/*-2026-09-18-0056.*`. From the link/power CSV:
|
||||
|
||||
| Time | Power | Utilisation | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| 00:59:54 | 309.31 W | 49% | enforced limit 200 W; throttle reason 0x4 (SW power cap) |
|
||||
| 00:59:55 to 00:59:59 | about 178 to 202 W | 100% | sustained prefill |
|
||||
| 01:00:02 | 174.96 W | | last good sample |
|
||||
| 01:00:03 | | | "GPU is lost"; dmon shows `-` from here |
|
||||
|
||||
The temperature never went above 39 °C, so the cause is not thermal. The link was PCIe gen 3 x4. The card drew
|
||||
309 W for a sample despite a 200 W cap, and then dropped after about ten seconds of load at the cap. That points to
|
||||
power delivery to the card or the OCuLink dock, not to the prompt or the software. A lower cap did not prevent it.
|
||||
**No more classification runs on that host until its hardware has been looked at.** The pilot output is not a result.
|
||||
|
||||
`gpu-logs/nvidia-bug-report-2026-09-18.log.gz` is the driver's report, taken after the fault. The kernel log in it
|
||||
shows no AER or PCIe error messages at any fault. That is not evidence of a clean link: at boot the kernel logs `_OSC: platform does not support [SHPCHotplug AER LTR DPC]`, so the firmware never gives the OS AER control and link errors would not be reported. There
|
||||
was an `Xid 32` (corrupted push-buffer stream) from Ollama at 00:54:48, five minutes before this drop, and an `Xid 13`
|
||||
graphics exception on 2026-09-13, before the first drop.
|
||||
|
||||
## Next, once the host is healthy
|
||||
|
||||
The plan below is fixed before any of the runs, so they cannot be tuned after the fact.
|
||||
|
||||
- **Development slice:** O. Henry scenes 0-119. Tune the prompt and the `--minfit` threshold only here.
|
||||
(`classify.py ohenry q14 --limit 120 --tag=-dev1`)
|
||||
- **Test:** O. Henry scenes 120-838, run once (`--start 120 --tag=-test`). Score with
|
||||
`score.py out/ohenry-q14-test.json --range=120:839`. Then run the same prompt on the hand-written
|
||||
predicaments (`ohenry claude`) to separate rewrite quality from classification quality. Then run
|
||||
`wharton claude` and `jacobs claude`, which have no local-model predicaments yet, to see how the
|
||||
catalogue transfers.
|
||||
- **What counts as success** is precision per situation, not the number of situations that pass:
|
||||
`--selftest` shows random labels also "pass" all 16. The bar is set before the test run: for the
|
||||
situations both hand and model call recurring, a median model precision of at least 60%.
|
||||
- At about 2 s a scene, O. Henry takes about 30 minutes a text set. That is a long run, so start the
|
||||
loggers first.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,271 @@
|
||||
"""The situation catalogue: a closed list a model classifies scenes against, instead of clustering them.
|
||||
|
||||
Seeded from three hand-made references (O. Henry ../v2/claude, Wharton ../wharton/claude, Jacobs ../jacobs/claude).
|
||||
Every hand group in those references maps to exactly one catalogue entry (SOURCES). Running this file:
|
||||
1. checks the mapping is total and every entry has at least one source,
|
||||
2. writes catalogue.json (entries + worked examples, tagged by corpus so a classifier can leave a corpus out),
|
||||
3. writes labels-<corpus>.json: every scene's catalogue label from the hand grouping, or "none",
|
||||
4. applies ONE recurrence rule to all three corpora and writes recurrence.log.
|
||||
No inference.
|
||||
"""
|
||||
import collections, json, math, pathlib, sys
|
||||
HERE = pathlib.Path(__file__).resolve().parent
|
||||
PROBE = HERE.parent
|
||||
|
||||
E = lambda id, name, definition, includes, excludes, tone='any', recurs=True: dict(
|
||||
id=id, name=name, definition=definition, includes=includes, excludes=excludes, tone=tone, recurs=recurs)
|
||||
|
||||
CATALOGUE = [
|
||||
E('rival', 'A rival for the same person',
|
||||
'Someone else wants the person you want, and only one of you can have them.',
|
||||
'suitors competing; fear of losing a partner to someone else; temptation to strike at the rival',
|
||||
'competing for money or a prize that is not a person (your_scheme)'),
|
||||
E('strings', 'Help with strings attached',
|
||||
'Help, money or a chance is offered to you, but only on terms that will cost you something you value.',
|
||||
'family money conditional on obeying them; a backer who now wants something; a windfall with a hidden cost',
|
||||
'the terms require doing something you think wrong (compromise_for_money); a stranger\'s trap (sure_thing)'),
|
||||
E('compromise_for_money', 'Money for going along',
|
||||
'You can have money, success or a meal if you go along with something you think wrong or false.',
|
||||
'selling out your standards for pay; the client wants the flattering or fake version; a compromising offer when you are hungry',
|
||||
'the cost is only inconvenient, not wrong (strings)'),
|
||||
E('sure_thing', 'The sure thing',
|
||||
'A plausible person offers an easy fortune that is probably a trap set for you.',
|
||||
'confidence tricks aimed at you; too-good-to-be-true bargains; strangers who befriend you for your money',
|
||||
'you are the one running the scheme (your_scheme)'),
|
||||
E('your_scheme', 'Your scheme turns on you',
|
||||
'You are running a scheme or quietly tipping a deal your way, and it may be about to turn on you.',
|
||||
'cheating a partner; rigging a contest; a trick coming apart in front of the people you meant to fool',
|
||||
'someone else is scheming against you (sure_thing)'),
|
||||
E('after_your_money', 'People after your money',
|
||||
'You have money, or are thought to, and people around you are suddenly courting it.',
|
||||
'heirs circling; flatterers and hangers-on; pressure to reveal a hidden windfall',
|
||||
'a single stranger\'s con (sure_thing)'),
|
||||
E('past_comes_back', 'The past comes back',
|
||||
'Someone from your past, or your partner\'s, turns up with a claim or a hold that threatens your present life.',
|
||||
'blackmail over an old secret; a former spouse or intimate who still has a claim; an old associate who could expose you',
|
||||
'you simply discover something a person close to you hid (what_they_hid)'),
|
||||
E('hiding_from_close', 'Hiding it from someone close',
|
||||
'You are hiding something, and someone close to you keeps innocently coming near it.',
|
||||
'keeping a guilty or embarrassing secret from a spouse or family; near misses',
|
||||
'the secret belongs to someone else (their_secret)'),
|
||||
E('their_secret', 'Keeping someone else\'s secret',
|
||||
'You have been drawn into another person\'s secret and must decide whether to lie, stay silent or give it up.',
|
||||
'covering for a friend or employer; being questioned by a suspicious superior',
|
||||
'your own secret (hiding_from_close)'),
|
||||
E('what_they_hid', 'What they hid',
|
||||
'You discover that a person close to you has been hiding something, and must decide what it changes.',
|
||||
'a partner\'s concealed past or disappointment; a friend\'s lie revealed',
|
||||
'the hidden thing arrives as a claim or threat from outside (past_comes_back)'),
|
||||
E('pretence', 'Passing for more than you are',
|
||||
'You are pretending to be more, or other, than you are, to impress someone, and may be found out.',
|
||||
'dressing and talking above your station; invented titles or histories; a thrilling story nobody can check',
|
||||
'you are genuinely above or below the other person and must decide what to show (station)'),
|
||||
E('caught_out', 'Caught out',
|
||||
'You have been caught, or are about to be seen, doing or being what you meant nobody to see.',
|
||||
'a private arrangement about to be judged publicly; caught somewhere you should not be; a joke taken in earnest',
|
||||
'you are blamed for something you did not do (wrongly_judged)'),
|
||||
E('wrongly_judged', 'What it looked like',
|
||||
'Someone whose opinion matters has judged you by appearances, for something you did not do, and will not hear you out.',
|
||||
'an innocent moment misread; suspected of a theft; taken for a fraud because of how you look',
|
||||
'you really did it (caught_out)'),
|
||||
E('let_another_take_blame', 'Someone else is taking the blame',
|
||||
'Suspicion has fallen on someone innocent for what you did, and you could let it stay there.',
|
||||
'an innocent person punished for your act; a stranger\'s blunder gives you a scapegoat',
|
||||
'you are the innocent one (wrongly_judged)'),
|
||||
E('cant_prove_it', 'Sure, but cannot prove it',
|
||||
'You are sure who wronged you, and you cannot prove it.',
|
||||
'accusing without evidence; authorities brushing you off; trying to trap the suspect',
|
||||
'you are the one accused (wrongly_judged)'),
|
||||
E('station', 'Across the gap in station',
|
||||
'Someone well above or below you in station wants you, and each of you must decide how much to show of where you really stand.',
|
||||
'courting or marrying across class; ashamed of your origins before a grander partner; a rich suitor not believed',
|
||||
'pretending to a station you do not have (pretence)'),
|
||||
E('home_unseen', 'Unseen at home',
|
||||
'You and a partner at home have stopped really seeing each other, and one of you wants to be noticed.',
|
||||
'neglect; taking a spouse for granted; a partner whose blind approval or coarseness makes nothing matter',
|
||||
'open quarrels about blame (peace_at_home)'),
|
||||
E('peace_at_home', 'Keeping the peace at home',
|
||||
'Someone at home blames you or demands their way, and you must decide whether to give way to keep the peace.',
|
||||
'being held responsible for household trouble; backing down before a spouse or elder; household quarrels',
|
||||
'loneliness without conflict (home_unseen); an outsider in the house (unwelcome_in_home)'),
|
||||
E('unwelcome_in_home', 'An unwelcome guest',
|
||||
'Someone you do not want has settled into your home, and you cannot seem to make them go.',
|
||||
'a relative or intruder who stays; having to make polite room for someone who holds power over the household',
|
||||
'a former partner\'s claim (past_comes_back)'),
|
||||
E('power_over_you', 'Under someone\'s thumb',
|
||||
'Someone with power over you treats you with contempt, or must be humoured though you resent them, and you want respect or to turn the tables.',
|
||||
'a superior who humiliates you; having to be civil and helpful to someone you fear or dislike because of your position',
|
||||
'a well-meaning benefactor (benefactor)'),
|
||||
E('benefactor', 'Help nobody asked for',
|
||||
'Someone with money wants to do good to someone below them on their own terms: either you are offered such help, or you are the one offering it.',
|
||||
'charity with a sermon or a price in pride; reformers who stop you working; a rich person seeking gratitude or restitution',
|
||||
'a conditional offer between equals (strings)'),
|
||||
E('patron', 'A handshake from the top',
|
||||
'Someone powerful could be seen with you, and getting that notice will cost you.',
|
||||
'currying favour with a great man; spending to be seen with the important; wanting a patron at your wedding',
|
||||
'being humiliated by them (power_over_you)'),
|
||||
E('pressed_to_wrong', 'Pressed to do wrong',
|
||||
'Someone is pressing you to do something you believe is wrong or dangerous, including turning on a friend you owe.',
|
||||
'pressure to testify against or turn in a friend; a loved one begging you to do something wicked; being invited into a crooked scheme',
|
||||
'you are merely paid to compromise your standards (compromise_for_money)'),
|
||||
E('go_between', 'Carrying someone else\'s case',
|
||||
'You are asked to carry another person\'s claim or warning to a third party, knowing it may cost you with one side or the other.',
|
||||
'pleading a case you doubt; passing on an unwelcome message; warning an outsider about a relative',
|
||||
'keeping a secret (their_secret)'),
|
||||
E('sacrifice', 'Giving it up for them',
|
||||
'You can help or protect someone you care for only by giving up something of your own, and they may never know.',
|
||||
'selling a treasure for a gift; secret humble work; giving your savings or name away for another',
|
||||
'being paid to give something up (strings)'),
|
||||
E('owning_up', 'Owning up',
|
||||
'You once wronged someone, and now face them, or a stand-in, and must decide how much to admit or how to make amends.',
|
||||
'confession; seeking forgiveness; a public show of devotion to win someone back',
|
||||
'an innocent is blamed right now for what you did (let_another_take_blame)'),
|
||||
E('own_words_back', 'Held to your own words',
|
||||
'A principle, promise or rule you once used for your own ends is now turned against you.',
|
||||
'hoist by your own code; a promise you never expected to be called in', '', recurs=False),
|
||||
E('newcomer', 'New to the place',
|
||||
'You are new to a place or household that ignores you, warns you, or leaves you lost, and you must find your footing.',
|
||||
'arriving in the city; a new post with warnings about the person in charge; waking somewhere strange',
|
||||
'news from the place you left (homecoming)'),
|
||||
E('homecoming', 'Word from home',
|
||||
'The people you left want you back, and word of them has reached you.',
|
||||
'a messenger from home; a parent waiting; a reunion with an old friend from where you came from',
|
||||
'a claim or threat from the past (past_comes_back)'),
|
||||
E('dread', 'Something in the dark',
|
||||
'Something frightening may be close by, and you must decide whether to face it.',
|
||||
'fear of what a wish or deed has brought; noises in the dark; a thing you must undo before it arrives',
|
||||
'ordinary social danger', tone='horror'),
|
||||
E('waiting', 'Waiting on their decision',
|
||||
'Your hopes hang on a decision entirely in another\'s hands, and nothing you do seems to hurry it.',
|
||||
'waiting for a proposal, a verdict or a signal; pretending indifference while it hangs',
|
||||
'you are the one who cannot speak (unspoken)'),
|
||||
E('unspoken', 'You cannot say it',
|
||||
'Someone you want is waiting for you to say it, and you cannot find the words or the moment.',
|
||||
'shy lovers; a single short chance to speak; feelings hidden behind a business arrangement',
|
||||
'waiting for the other to decide (waiting)'),
|
||||
E('overlooked', 'Beside the admired one',
|
||||
'You are the plain or overlooked one beside someone admired, and want, once, to be noticed.',
|
||||
'the plain co-worker; going to dances on someone else\'s arm',
|
||||
'a partner at home who ignores you (home_unseen)'),
|
||||
E('sign', 'A sign',
|
||||
'Someone in a bad way wants a sign to tell them what to do, and one seems to have turned up.',
|
||||
'fortune-tellers, omens, mysterious messages, reading a coincidence as meant for you',
|
||||
'real supernatural danger (dread)'),
|
||||
E('despised', 'The one nobody thinks much of',
|
||||
'Someone everybody looks down on wants a chance, and may be the only one who can help.',
|
||||
'a tramp, a foreigner, a crook or a failure who turns out to be the helper',
|
||||
'someone powerful (patron)'),
|
||||
E('habit', 'The old habit',
|
||||
'An old habit gives you away at exactly the moment you need to be someone new.',
|
||||
'a former trade\'s manner resurfacing; an old craving at the worst moment',
|
||||
'', recurs=False),
|
||||
]
|
||||
|
||||
# every hand group in the three references -> one catalogue id
|
||||
SOURCES = {
|
||||
'ohenry': {'pose': 'pretence', 'benefactor': 'benefactor', 'taken_for_granted': 'home_unseen', 'station': 'station',
|
||||
'newcomer': 'newcomer', 'easy_money': 'sure_thing', 'sacrifice': 'sacrifice', 'rival': 'rival',
|
||||
'price': 'compromise_for_money', 'despised': 'despised', 'loyalty': 'pressed_to_wrong',
|
||||
'appearances': 'wrongly_judged', 'sign': 'sign', 'strings': 'strings', 'honest_work': 'compromise_for_money',
|
||||
'unspoken': 'unspoken', 'overlooked': 'overlooked', 'homecoming': 'homecoming', 'patron': 'patron', 'habit': 'habit'},
|
||||
'wharton': {'tainted_money': 'compromise_for_money', 'strings_attached': 'strings', 'old_attachment': 'past_comes_back',
|
||||
'what_they_hid': 'what_they_hid', 'keep_their_secret': 'their_secret', 'exposed': 'caught_out',
|
||||
'civil_to_them': 'power_over_you', 'plead_their_case': 'go_between', 'own_words_back': 'own_words_back',
|
||||
'give_it_up': 'sacrifice', 'keep_the_peace': 'peace_at_home', 'unseen_at_home': 'home_unseen',
|
||||
'new_and_warned': 'newcomer', 'the_rival': 'rival', 'owning_up': 'owning_up', 'waiting_on_them': 'waiting'},
|
||||
'jacobs': {'G01': 'past_comes_back', 'G02': 'hiding_from_close', 'G03': 'let_another_take_blame', 'G04': 'rival',
|
||||
'G05': 'your_scheme', 'G06': 'your_scheme', 'G07': 'after_your_money', 'G08': 'strings', 'G09': 'your_scheme',
|
||||
'G10': 'power_over_you', 'G11': 'unwelcome_in_home', 'G12': 'dread', 'G13': 'pressed_to_wrong',
|
||||
'G14': 'pretence', 'G15': 'caught_out', 'G16': 'peace_at_home', 'G17': 'cant_prove_it', 'G18': 'newcomer',
|
||||
'G19': 'owning_up'},
|
||||
}
|
||||
|
||||
def load_corpus(name):
|
||||
"""-> chunks, {chunk: predicament}, {chunk: hand group id}"""
|
||||
if name == 'ohenry':
|
||||
d = PROBE / 'v2'
|
||||
sys.path.insert(0, str(d / 'claude')); from assign import ASSIGN
|
||||
pred = {x['chunk']: x['summary'] for x in json.load(open(d / 'claude/predicaments-claude.json'))['summaries']}
|
||||
return json.load(open(d / 'chunks.json')), pred, dict(ASSIGN)
|
||||
d = PROBE / name
|
||||
groups = json.load(open(d / 'claude/groups.json'))['groups']
|
||||
hand = {}
|
||||
for g in groups:
|
||||
for m in g['members']:
|
||||
c = m if isinstance(m, int) else m['chunk']
|
||||
assert c not in hand, (name, c); hand[c] = g['id']
|
||||
if name == 'wharton':
|
||||
pred = {x['chunk']: x['summary'] for x in json.load(open(d / 'claude/predicaments.json'))['summaries']}
|
||||
else:
|
||||
rec = json.load(open(d / 'claude/records.json')); rec = rec if isinstance(rec, list) else rec['records']
|
||||
pred = {r['chunk']: r['predicament'] for r in rec}
|
||||
return json.load(open(d / 'chunks.json')), pred, hand
|
||||
|
||||
CORPORA = ['ohenry', 'wharton', 'jacobs']
|
||||
|
||||
# ---- one recurrence rule for all three --------------------------------------------------------------
|
||||
def min_stories(n_stories): return max(3, math.ceil(0.04 * n_stories)) # 3 for 10-12 stories, 4 for 97
|
||||
MIN_SCENES, MAX_DOMINANT = 5, 0.50
|
||||
def recurs(members, story):
|
||||
st = collections.Counter(story[c] for c in members)
|
||||
return len(members) >= MIN_SCENES and len(st) >= min_stories(len(set(story))) and max(st.values()) / len(members) <= MAX_DOMINANT, len(st), (max(st.values()) / len(members) if members else 0)
|
||||
|
||||
if __name__ == '__main__':
|
||||
ids = [e['id'] for e in CATALOGUE]
|
||||
assert len(ids) == len(set(ids)), 'duplicate id'
|
||||
problems = []
|
||||
for corpus, m in SOURCES.items():
|
||||
for g, c in m.items():
|
||||
if c not in ids: problems.append(f'{corpus}:{g} -> unknown {c}')
|
||||
used = {c for m in SOURCES.values() for c in m.values()}
|
||||
problems += [f'catalogue entry {i} has no source group' for i in ids if i not in used]
|
||||
for e in CATALOGUE:
|
||||
for x in e['excludes'].split(';'):
|
||||
for i in ids:
|
||||
pass
|
||||
examples = collections.defaultdict(list)
|
||||
log = []
|
||||
for corpus in CORPORA:
|
||||
chunks, pred, hand = load_corpus(corpus)
|
||||
unmapped = sorted({g for g in hand.values() if g not in SOURCES[corpus]})
|
||||
if unmapped: problems.append(f'{corpus}: hand groups not in SOURCES: {unmapped}')
|
||||
labels = {i: SOURCES[corpus].get(hand[i], 'none') if i in hand else 'none' for i in range(len(chunks))}
|
||||
(HERE / f'labels-{corpus}.json').write_text(json.dumps(
|
||||
{'corpus': corpus, 'from': 'hand groups mapped through catalogue_src.SOURCES',
|
||||
'labels': [{'chunk': i, 'story': chunks[i]['story'], 'label': labels[i], 'predicament': pred[i]} for i in range(len(chunks))]},
|
||||
indent=0, ensure_ascii=False))
|
||||
# examples: up to 3 per (entry, corpus), spread across stories, taken from the hand members
|
||||
per = collections.defaultdict(list)
|
||||
for i in sorted(labels):
|
||||
if labels[i] != 'none': per[labels[i]].append(i)
|
||||
for lab, mem in per.items():
|
||||
seen = set()
|
||||
for i in mem:
|
||||
if chunks[i]['story'] in seen: continue
|
||||
seen.add(chunks[i]['story']); examples[lab].append({'corpus': corpus, 'chunk': i, 'predicament': pred[i]})
|
||||
if len(seen) == 3: break
|
||||
story = [c['story'] for c in chunks]
|
||||
ns = len(set(story))
|
||||
log.append(f'\n== {corpus}: {ns} stories, {len(chunks)} scenes; rule: >= {MIN_SCENES} scenes, >= {min_stories(ns)} stories, no story over {MAX_DOMINANT:.0%}')
|
||||
log.append(f' scenes labelled: {sum(l != "none" for l in labels.values())} ({sum(l != "none" for l in labels.values())/len(chunks):.0%})')
|
||||
passing = []
|
||||
for lab in sorted(per, key=lambda k: -len(per[k])):
|
||||
ok, nst, dom = recurs(per[lab], story)
|
||||
passing += [lab] if ok else []
|
||||
log.append(f' {"PASS" if ok else " "} {lab:24s} {len(per[lab]):3d} scenes {nst:3d} stories top story {dom:4.0%}')
|
||||
log.append(f' -> {len(passing)} recurring situations of {len(per)} present')
|
||||
cover = collections.defaultdict(set)
|
||||
for corpus, m in SOURCES.items():
|
||||
for c in m.values(): cover[c].add(corpus)
|
||||
log.append('\n== catalogue coverage: entries found in 3 / 2 / 1 corpora')
|
||||
for k in (3, 2, 1):
|
||||
log.append(f' {k}: ' + ', '.join(i for i in ids if len(cover[i]) == k))
|
||||
if problems:
|
||||
print('\n'.join(problems)); sys.exit(1)
|
||||
out = {'about': 'Situation catalogue for story-to-pack, seeded by hand from O. Henry, Wharton and Jacobs references (2026-09-17). '
|
||||
'A classifier assigns each scene predicament one entry or "none". Examples carry their corpus so a test '
|
||||
'on one corpus can use only the others.',
|
||||
'entries': [dict(e, corpora=sorted(cover[e['id']]), examples=examples[e['id']]) for e in CATALOGUE]}
|
||||
(HERE / 'catalogue.json').write_text(json.dumps(out, indent=1, ensure_ascii=False))
|
||||
(HERE / 'recurrence.log').write_text('\n'.join(log).lstrip() + '\n')
|
||||
print(f'catalogue.json: {len(CATALOGUE)} entries'); print('\n'.join(log))
|
||||
@@ -0,0 +1,107 @@
|
||||
"""Classify scene predicaments against the catalogue, one scene at a time.
|
||||
|
||||
STP_OLLAMA=http://<host>:11434 python3 classify.py <corpus> <text-set> [--limit N]
|
||||
corpus ohenry | wharton | jacobs
|
||||
text-set claude (the hand-written predicaments) | q14 (qwen3:14b predicaments, O. Henry only)
|
||||
|
||||
Leave-one-corpus-out: the worked examples shown never come from the corpus being classified, so this
|
||||
tests the catalogue on a corpus it has not seen. Embeddings (nomic-embed-text) only narrow the choice:
|
||||
the scene's K nearest entries, plus "none", go to the model. Checkpoints after every scene; resumes.
|
||||
Writes out/<corpus>-<text-set>.json.
|
||||
"""
|
||||
import argparse, json, math, pathlib, re, sys, time
|
||||
HERE = pathlib.Path(__file__).resolve().parent
|
||||
PROBE = HERE.parent
|
||||
sys.path.insert(0, str(PROBE))
|
||||
import ollama
|
||||
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument('corpus'); ap.add_argument('textset')
|
||||
ap.add_argument('--limit', type=int); ap.add_argument('--k', type=int, default=0, help='0 = offer the whole catalogue')
|
||||
ap.add_argument('--start', type=int, default=0)
|
||||
ap.add_argument('--tag', default='')
|
||||
ap.add_argument('--model', default='qwen3:14b')
|
||||
args = ap.parse_args()
|
||||
EMB = 'nomic-embed-text'
|
||||
|
||||
cat = json.load(open(HERE / 'catalogue.json'))['entries']
|
||||
labels = json.load(open(HERE / f'labels-{args.corpus}.json'))['labels']
|
||||
if args.textset == 'claude':
|
||||
texts = [x['predicament'] for x in labels]
|
||||
elif args.textset == 'q14':
|
||||
assert args.corpus == 'ohenry'
|
||||
q = {x['chunk']: x['summary'] for x in json.load(open(PROBE / 'v2/predicaments_clean.json'))['summaries']}
|
||||
texts = [q[x['chunk']] for x in labels]
|
||||
else:
|
||||
sys.exit('text-set must be claude or q14')
|
||||
|
||||
def norm(v):
|
||||
m = math.sqrt(sum(x * x for x in v)) or 1.0
|
||||
return [x / m for x in v]
|
||||
|
||||
# embeddings, cached
|
||||
def cached_embed(name, items):
|
||||
p = HERE / 'emb' / f'{name}.json'; p.parent.mkdir(exist_ok=True)
|
||||
if p.exists():
|
||||
d = json.load(open(p))
|
||||
if d['texts'] == items: return d['vectors']
|
||||
vecs = []
|
||||
for i in range(0, len(items), 16):
|
||||
vecs += ollama.embed(EMB, ['search_query: ' + t if name != 'catalogue' else 'search_document: ' + t for t in items[i:i + 16]])
|
||||
p.write_text(json.dumps({'model': EMB, 'texts': items, 'vectors': vecs}))
|
||||
return vecs
|
||||
|
||||
entry_text = [f"{e['name']}. {e['definition']} Includes: {e['includes']}." for e in cat]
|
||||
E = [norm(v) for v in cached_embed('catalogue', entry_text)]
|
||||
S = [norm(v) for v in cached_embed(f'{args.corpus}-{args.textset}', texts)]
|
||||
|
||||
def examples_for(e):
|
||||
ex = [x for x in e['examples'] if x['corpus'] != args.corpus][:2]
|
||||
return ''.join(f'\n e.g. "{x["predicament"]}"' for x in ex)
|
||||
|
||||
SYSTEM = """You sort scenes from short stories into recurring SITUATIONS for a life-simulation game.
|
||||
|
||||
You get one scene, described as its main person's predicament, and a short list of candidate situations.
|
||||
Answer with the ONE candidate that the predicament is an instance of, or "none".
|
||||
|
||||
Choose "none" when the scene is narration, scene-setting, a description of a place or type of person, or a
|
||||
one-off turn that is not really any of the candidates. A shared word or setting is not enough: the
|
||||
predicament itself -- what the person is up against and what they want -- must match the situation.
|
||||
|
||||
Most scenes in a story are NOT one of these situations: expect about half to be "none".
|
||||
Also rate how clearly the predicament is an instance of your choice: 3 = plainly this situation,
|
||||
2 = arguably, 1 = only loosely.
|
||||
|
||||
Reply with JSON only: {"label": "<candidate id or none>", "fit": <1, 2 or 3>}"""
|
||||
|
||||
out_path = HERE / 'out' / f'{args.corpus}-{args.textset}{args.tag}.json'; out_path.parent.mkdir(exist_ok=True)
|
||||
state = json.load(open(out_path)) if out_path.exists() else {'model': args.model, 'k': args.k, 'results': []}
|
||||
done = {r['chunk'] for r in state['results']}
|
||||
n = len(texts) if not args.limit else min(args.limit, len(texts))
|
||||
t0 = time.time(); new = 0
|
||||
for i in range(args.start, n):
|
||||
if labels[i]['chunk'] in done: continue
|
||||
order = sorted(range(len(cat)), key=lambda j: -sum(a * b for a, b in zip(S[i], E[j])))
|
||||
sims = order[:args.k] if args.k else list(range(len(cat)))
|
||||
cands = '\n'.join(f"- {cat[j]['id']}: {cat[j]['definition']}{examples_for(cat[j])}" for j in sims)
|
||||
# candidates go in the system prompt, identical from scene to scene when the whole catalogue is offered,
|
||||
# so the server can reuse its cached prefix; the predicament comes last
|
||||
system = SYSTEM + '\n\nCandidates:\n' + cands + '\n- none: not one of these situations'
|
||||
user = f'Predicament: "{texts[i]}"'
|
||||
raw, _, _ = ollama.chat(args.model, system, user, num_ctx=4096, num_predict=40, fmt='json',
|
||||
think=False if args.model.startswith('qwen3') else None)
|
||||
try:
|
||||
j = json.loads(raw); lab = str(j.get('label', '')).strip(); fit = j.get('fit')
|
||||
except Exception:
|
||||
m = re.search(r'"label"\s*:\s*"([^"]+)"', raw); lab = m.group(1) if m else ''; fit = None
|
||||
valid = {cat[j]['id'] for j in sims} | {'none'}
|
||||
state['results'].append({'chunk': labels[i]['chunk'], 'label': lab if lab in valid else 'none',
|
||||
'fit': fit, 'raw': raw if lab not in valid else None, 'candidates': [cat[j]['id'] for j in sims],
|
||||
'nearest': cat[sims[0]]['id']})
|
||||
new += 1
|
||||
if new % 10 == 0 or i == n - 1:
|
||||
out_path.write_text(json.dumps(state, indent=0))
|
||||
el = time.time() - t0
|
||||
print(f' {len(state["results"])}/{n} {el:.0f}s {el / new:.2f}s/scene', flush=True)
|
||||
out_path.write_text(json.dumps(state, indent=0))
|
||||
print('done', out_path.name, len(state['results']))
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,948 @@
|
||||
{
|
||||
"corpus": "jacobs",
|
||||
"from": "hand groups mapped through catalogue_src.SOURCES",
|
||||
"labels": [
|
||||
{
|
||||
"chunk": 0,
|
||||
"story": 0,
|
||||
"label": "peace_at_home",
|
||||
"predicament": "Someone waiting up for a relative who comes in late wants to reproach him, and gets cheek back."
|
||||
},
|
||||
{
|
||||
"chunk": 1,
|
||||
"story": 0,
|
||||
"label": "peace_at_home",
|
||||
"predicament": "A man learns a relative has promised a stranger his hospitality without asking, and fears what his spouse will say."
|
||||
},
|
||||
{
|
||||
"chunk": 2,
|
||||
"story": 0,
|
||||
"label": "unwelcome_in_home",
|
||||
"predicament": "A man who wants to turn an unwelcome guest away cannot find the nerve to say it."
|
||||
},
|
||||
{
|
||||
"chunk": 3,
|
||||
"story": 0,
|
||||
"label": "peace_at_home",
|
||||
"predicament": "A man who has let something happen under his roof fears his spouse will hold him responsible for it."
|
||||
},
|
||||
{
|
||||
"chunk": 4,
|
||||
"story": 0,
|
||||
"label": "rival",
|
||||
"predicament": "A young man who brought the person he is courting along is pushed aside by an older man who takes charge of her."
|
||||
},
|
||||
{
|
||||
"chunk": 5,
|
||||
"story": 0,
|
||||
"label": "rival",
|
||||
"predicament": "A jealous suitor tries to put his rival in his place, and only makes himself look childish."
|
||||
},
|
||||
{
|
||||
"chunk": 6,
|
||||
"story": 0,
|
||||
"label": "none",
|
||||
"predicament": "A spurned man realises he holds something over the rival who humiliated him, and decides to use it."
|
||||
},
|
||||
{
|
||||
"chunk": 7,
|
||||
"story": 0,
|
||||
"label": "hiding_from_close",
|
||||
"predicament": "A man hiding an innocent but compromising secret is met by his spouse, while the one person who could clear him refuses to."
|
||||
},
|
||||
{
|
||||
"chunk": 8,
|
||||
"story": 0,
|
||||
"label": "hiding_from_close",
|
||||
"predicament": "A man tries to hurry his spouse away from a secret while someone signals behind his back that something is up."
|
||||
},
|
||||
{
|
||||
"chunk": 9,
|
||||
"story": 0,
|
||||
"label": "caught_out",
|
||||
"predicament": "A man caught with an innocent secret is disowned by the one person who could explain it."
|
||||
},
|
||||
{
|
||||
"chunk": 10,
|
||||
"story": 0,
|
||||
"label": "let_another_take_blame",
|
||||
"predicament": "A man whose spite has got an innocent person abused must decide whether to own up and defend her."
|
||||
},
|
||||
{
|
||||
"chunk": 11,
|
||||
"story": 0,
|
||||
"label": "owning_up",
|
||||
"predicament": "A man tries to win back someone he wronged with a dangerous public show of devotion."
|
||||
},
|
||||
{
|
||||
"chunk": 12,
|
||||
"story": 0,
|
||||
"label": "your_scheme",
|
||||
"predicament": "A man pretends to be in danger to win sympathy, and the one who saved him sees through it."
|
||||
},
|
||||
{
|
||||
"chunk": 13,
|
||||
"story": 0,
|
||||
"label": "owning_up",
|
||||
"predicament": "Two men who quarrelled over a woman are both left out in the cold by the women they wronged."
|
||||
},
|
||||
{
|
||||
"chunk": 14,
|
||||
"story": 1,
|
||||
"label": "caught_out",
|
||||
"predicament": "A man losing a friendly contest tries to distract from his mistake, and is caught at it by his family."
|
||||
},
|
||||
{
|
||||
"chunk": 15,
|
||||
"story": 1,
|
||||
"label": "none",
|
||||
"predicament": "A guest who wants to leave a dangerous subject alone is coaxed by his hosts' curiosity into bringing it out."
|
||||
},
|
||||
{
|
||||
"chunk": 16,
|
||||
"story": 1,
|
||||
"label": "strings",
|
||||
"predicament": "Someone offered a thing that grants wishes is warned by the man who used it that it brings sorrow."
|
||||
},
|
||||
{
|
||||
"chunk": 17,
|
||||
"story": 1,
|
||||
"label": "strings",
|
||||
"predicament": "A man is offered a dangerous gift by someone who begs him to destroy it, and wants to keep it anyway."
|
||||
},
|
||||
{
|
||||
"chunk": 18,
|
||||
"story": 1,
|
||||
"label": "strings",
|
||||
"predicament": "A man half-believing in a promise of easy money must decide whether to test it and what to ask for."
|
||||
},
|
||||
{
|
||||
"chunk": 19,
|
||||
"story": 1,
|
||||
"label": "dread",
|
||||
"predicament": "A family that tempted fate laughs off their fears in daylight while one of them cannot shake them."
|
||||
},
|
||||
{
|
||||
"chunk": 20,
|
||||
"story": 1,
|
||||
"label": "strings",
|
||||
"predicament": "A family waiting on a hoped-for windfall sees a stranger approach who might bring it, or bad news."
|
||||
},
|
||||
{
|
||||
"chunk": 21,
|
||||
"story": 1,
|
||||
"label": "none",
|
||||
"predicament": "Parents receive news that their only child has been killed, from someone who cannot bring himself to say it."
|
||||
},
|
||||
{
|
||||
"chunk": 22,
|
||||
"story": 1,
|
||||
"label": "strings",
|
||||
"predicament": "A man learns the money he wished for has come as compensation for his child's death."
|
||||
},
|
||||
{
|
||||
"chunk": 23,
|
||||
"story": 1,
|
||||
"label": "pressed_to_wrong",
|
||||
"predicament": "A grieving woman seizes on a desperate chance to undo a death, and her spouse is afraid of what it would bring back."
|
||||
},
|
||||
{
|
||||
"chunk": 24,
|
||||
"story": 1,
|
||||
"label": "pressed_to_wrong",
|
||||
"predicament": "A man pressed by his grieving spouse must decide whether to do something he believes is wicked and dangerous."
|
||||
},
|
||||
{
|
||||
"chunk": 25,
|
||||
"story": 1,
|
||||
"label": "dread",
|
||||
"predicament": "A man who dreads what his wish may bring home must stop his spouse from opening the door to it."
|
||||
},
|
||||
{
|
||||
"chunk": 26,
|
||||
"story": 1,
|
||||
"label": "dread",
|
||||
"predicament": "A man must use his last chance to undo what his spouse most longs for, before she lets it in."
|
||||
},
|
||||
{
|
||||
"chunk": 27,
|
||||
"story": 2,
|
||||
"label": "after_your_money",
|
||||
"predicament": "A man who has hoarded his savings all his life falls ill far from anywhere he could spend them."
|
||||
},
|
||||
{
|
||||
"chunk": 28,
|
||||
"story": 2,
|
||||
"label": "after_your_money",
|
||||
"predicament": "A dying miser, surrounded by people hoping to inherit, is determined that nobody will get his money."
|
||||
},
|
||||
{
|
||||
"chunk": 29,
|
||||
"story": 2,
|
||||
"label": "pressed_to_wrong",
|
||||
"predicament": "Someone who spots a colleague quietly scheming to take unclaimed money must decide whether to expose him or join in."
|
||||
},
|
||||
{
|
||||
"chunk": 30,
|
||||
"story": 2,
|
||||
"label": "your_scheme",
|
||||
"predicament": "Two partners convinced a weaker person is secretly holding a fortune want to find it before anyone else learns of it."
|
||||
},
|
||||
{
|
||||
"chunk": 31,
|
||||
"story": 2,
|
||||
"label": "cant_prove_it",
|
||||
"predicament": "A schemer whose secret searches keep failing decides to confront the person he suspects directly."
|
||||
},
|
||||
{
|
||||
"chunk": 32,
|
||||
"story": 2,
|
||||
"label": "after_your_money",
|
||||
"predicament": "Someone suspected of hiding a windfall is pressed with bribes and threats by people who have no right to it."
|
||||
},
|
||||
{
|
||||
"chunk": 33,
|
||||
"story": 2,
|
||||
"label": "your_scheme",
|
||||
"predicament": "Someone forced to share a hidden windfall admits it is now stuck where neither partner can get at it."
|
||||
},
|
||||
{
|
||||
"chunk": 34,
|
||||
"story": 2,
|
||||
"label": "your_scheme",
|
||||
"predicament": "Distrustful partners stare at a fortune they cannot reach without getting caught, and blame each other for it."
|
||||
},
|
||||
{
|
||||
"chunk": 35,
|
||||
"story": 2,
|
||||
"label": "your_scheme",
|
||||
"predicament": "A man wants something a superior owns without revealing why, and tries flattery to get it."
|
||||
},
|
||||
{
|
||||
"chunk": 36,
|
||||
"story": 2,
|
||||
"label": "your_scheme",
|
||||
"predicament": "A buyer who cannot reveal why he wants something is made to pay more by a seller who senses his eagerness."
|
||||
},
|
||||
{
|
||||
"chunk": 37,
|
||||
"story": 2,
|
||||
"label": "your_scheme",
|
||||
"predicament": "A man who wants to cheat his partner out of a shared prize is watched too closely to slip away."
|
||||
},
|
||||
{
|
||||
"chunk": 38,
|
||||
"story": 2,
|
||||
"label": "your_scheme",
|
||||
"predicament": "A man trying to slip away with a shared prize is held up until the partner he meant to cheat catches him."
|
||||
},
|
||||
{
|
||||
"chunk": 39,
|
||||
"story": 2,
|
||||
"label": "your_scheme",
|
||||
"predicament": "A man who schemed to cheat a partner finds the prize gone and realises he may have been cheated himself."
|
||||
},
|
||||
{
|
||||
"chunk": 40,
|
||||
"story": 2,
|
||||
"label": "your_scheme",
|
||||
"predicament": "A schemer who was outwitted must stand and smile at the people who got the better of him."
|
||||
},
|
||||
{
|
||||
"chunk": 41,
|
||||
"story": 3,
|
||||
"label": "none",
|
||||
"predicament": "A man about to marry is needled by a dependent relative who hints at something to his discredit."
|
||||
},
|
||||
{
|
||||
"chunk": 42,
|
||||
"story": 3,
|
||||
"label": "none",
|
||||
"predicament": "A man in desperate debt makes veiled hints to the relative he hopes will pay it."
|
||||
},
|
||||
{
|
||||
"chunk": 43,
|
||||
"story": 3,
|
||||
"label": "past_comes_back",
|
||||
"predicament": "A man is blackmailed by a relative with old letters that could cost him the person he loves."
|
||||
},
|
||||
{
|
||||
"chunk": 44,
|
||||
"story": 3,
|
||||
"label": "let_another_take_blame",
|
||||
"predicament": "A man who has done something terrible in anger must cover for a relative's sudden disappearance."
|
||||
},
|
||||
{
|
||||
"chunk": 45,
|
||||
"story": 3,
|
||||
"label": "hiding_from_close",
|
||||
"predicament": "A man with a guilty secret must keep calm while someone he loves insists on lingering right at the spot."
|
||||
},
|
||||
{
|
||||
"chunk": 46,
|
||||
"story": 3,
|
||||
"label": "hiding_from_close",
|
||||
"predicament": "A man hiding a crime hears it discussed innocently and betrays his feelings with an outburst."
|
||||
},
|
||||
{
|
||||
"chunk": 47,
|
||||
"story": 3,
|
||||
"label": "hiding_from_close",
|
||||
"predicament": "A guilty man tries to hurry someone away from the scene of his crime, and she insists on staying."
|
||||
},
|
||||
{
|
||||
"chunk": 48,
|
||||
"story": 3,
|
||||
"label": "hiding_from_close",
|
||||
"predicament": "A man hiding something learns it may be discovered by an innocent accident, and must not show his fear."
|
||||
},
|
||||
{
|
||||
"chunk": 49,
|
||||
"story": 3,
|
||||
"label": "hiding_from_close",
|
||||
"predicament": "A man must retrieve something from where his secret lies before anyone else goes looking."
|
||||
},
|
||||
{
|
||||
"chunk": 50,
|
||||
"story": 3,
|
||||
"label": "hiding_from_close",
|
||||
"predicament": "A man secretly searching where his crime lies keeps pulling up reminders of it."
|
||||
},
|
||||
{
|
||||
"chunk": 51,
|
||||
"story": 3,
|
||||
"label": "hiding_from_close",
|
||||
"predicament": "A man who has failed to fix things quietly must now involve others while still keeping his secret."
|
||||
},
|
||||
{
|
||||
"chunk": 52,
|
||||
"story": 3,
|
||||
"label": "hiding_from_close",
|
||||
"predicament": "A man hiding something is offered help he cannot accept without being found out."
|
||||
},
|
||||
{
|
||||
"chunk": 53,
|
||||
"story": 3,
|
||||
"label": "none",
|
||||
"predicament": "A man who must face the thing he did alone gathers his nerve while others watch and wonder."
|
||||
},
|
||||
{
|
||||
"chunk": 54,
|
||||
"story": 3,
|
||||
"label": "your_scheme",
|
||||
"predicament": "A man who hid a crime is dragged down by it at the moment he tries to undo its consequences."
|
||||
},
|
||||
{
|
||||
"chunk": 55,
|
||||
"story": 4,
|
||||
"label": "peace_at_home",
|
||||
"predicament": "A woman who has lost something valuable suspects the people around her, and resents her spouse for not caring as much."
|
||||
},
|
||||
{
|
||||
"chunk": 56,
|
||||
"story": 4,
|
||||
"label": "cant_prove_it",
|
||||
"predicament": "Someone who has lost something accuses the most defenceless person nearby, and punishes her before any proof."
|
||||
},
|
||||
{
|
||||
"chunk": 57,
|
||||
"story": 4,
|
||||
"label": "peace_at_home",
|
||||
"predicament": "A man who thinks a household fuss is foolish keeps defending the accused and is scolded for taking her side."
|
||||
},
|
||||
{
|
||||
"chunk": 58,
|
||||
"story": 4,
|
||||
"label": "caught_out",
|
||||
"predicament": "A man who finds a family inquiry ridiculous cannot keep a straight face in front of its self-important leader."
|
||||
},
|
||||
{
|
||||
"chunk": 59,
|
||||
"story": 4,
|
||||
"label": "let_another_take_blame",
|
||||
"predicament": "A man who sees an innocent neighbour blamed for evidence he himself left must decide whether to own up."
|
||||
},
|
||||
{
|
||||
"chunk": 60,
|
||||
"story": 4,
|
||||
"label": "caught_out",
|
||||
"predicament": "A man who knows more than he says can barely hide his amusement from the self-important person running the inquiry."
|
||||
},
|
||||
{
|
||||
"chunk": 61,
|
||||
"story": 4,
|
||||
"label": "cant_prove_it",
|
||||
"predicament": "A self-important man, teased that a neighbour fancies him, resolves to prove his point by trapping her."
|
||||
},
|
||||
{
|
||||
"chunk": 62,
|
||||
"story": 4,
|
||||
"label": "pressed_to_wrong",
|
||||
"predicament": "A man refuses to let his home be used to entrap a friend, so the schemer plans to go behind his back."
|
||||
},
|
||||
{
|
||||
"chunk": 63,
|
||||
"story": 4,
|
||||
"label": "unwelcome_in_home",
|
||||
"predicament": "A man stuck with an unwelcome relative in his home sees a chance to have him disgraced and driven off."
|
||||
},
|
||||
{
|
||||
"chunk": 64,
|
||||
"story": 4,
|
||||
"label": "power_over_you",
|
||||
"predicament": "A man who has trapped his rival in an embarrassing hiding place decides to make the most of it."
|
||||
},
|
||||
{
|
||||
"chunk": 65,
|
||||
"story": 4,
|
||||
"label": "caught_out",
|
||||
"predicament": "A trapped man must listen in silence while someone tells a neighbour a story about him that he cannot deny."
|
||||
},
|
||||
{
|
||||
"chunk": 66,
|
||||
"story": 4,
|
||||
"label": "caught_out",
|
||||
"predicament": "A man caught somewhere he should not be is handed an embarrassing explanation, and has no better one to offer."
|
||||
},
|
||||
{
|
||||
"chunk": 67,
|
||||
"story": 5,
|
||||
"label": "past_comes_back",
|
||||
"predicament": "A man found out in wrongdoing is ordered by his partner to give up everything and leave by morning."
|
||||
},
|
||||
{
|
||||
"chunk": 68,
|
||||
"story": 5,
|
||||
"label": "past_comes_back",
|
||||
"predicament": "A man offered a harsh but fair way out of his disgrace wants a quieter bargain the other will not give."
|
||||
},
|
||||
{
|
||||
"chunk": 69,
|
||||
"story": 5,
|
||||
"label": "pressed_to_wrong",
|
||||
"predicament": "A man facing ruin threatens the only person who can expose him, and must choose whether to go through with it."
|
||||
},
|
||||
{
|
||||
"chunk": 70,
|
||||
"story": 5,
|
||||
"label": "none",
|
||||
"predicament": "A man who has just done something terrible in rage panics and must steady himself when a figure of the law appears."
|
||||
},
|
||||
{
|
||||
"chunk": 71,
|
||||
"story": 5,
|
||||
"label": "none",
|
||||
"predicament": "A man with a crime behind him and a few hours' start must decide what to take and where to run."
|
||||
},
|
||||
{
|
||||
"chunk": 72,
|
||||
"story": 5,
|
||||
"label": "none",
|
||||
"predicament": "A fugitive planning his escape finds that every choice might be the one that gets him caught."
|
||||
},
|
||||
{
|
||||
"chunk": 73,
|
||||
"story": 5,
|
||||
"label": "dread",
|
||||
"predicament": "A man alone with what he has done hears noises in the dark and fears it has come back for him."
|
||||
},
|
||||
{
|
||||
"chunk": 74,
|
||||
"story": 5,
|
||||
"label": "let_another_take_blame",
|
||||
"predicament": "A guilty man realises a stranger's blunder gives him someone else to blame."
|
||||
},
|
||||
{
|
||||
"chunk": 75,
|
||||
"story": 5,
|
||||
"label": "let_another_take_blame",
|
||||
"predicament": "A man framing an innocent person must act the outraged victim in front of the law."
|
||||
},
|
||||
{
|
||||
"chunk": 76,
|
||||
"story": 5,
|
||||
"label": "let_another_take_blame",
|
||||
"predicament": "A man must play the shocked innocent while the person he framed pleads the truth."
|
||||
},
|
||||
{
|
||||
"chunk": 77,
|
||||
"story": 5,
|
||||
"label": "let_another_take_blame",
|
||||
"predicament": "Someone falsely accused points to the real culprit, who must hold his nerve and keep up his story."
|
||||
},
|
||||
{
|
||||
"chunk": 78,
|
||||
"story": 5,
|
||||
"label": "your_scheme",
|
||||
"predicament": "A man who thought he had got away with it learns the one witness who could hang him is still alive."
|
||||
},
|
||||
{
|
||||
"chunk": 79,
|
||||
"story": 5,
|
||||
"label": "your_scheme",
|
||||
"predicament": "A man whose deceit has collapsed can only watch as the truth comes out and he is taken away."
|
||||
},
|
||||
{
|
||||
"chunk": 80,
|
||||
"story": 6,
|
||||
"label": "none",
|
||||
"predicament": "A penniless newcomer forces his way into a place he does not belong, counting on a secret to back him."
|
||||
},
|
||||
{
|
||||
"chunk": 81,
|
||||
"story": 6,
|
||||
"label": "past_comes_back",
|
||||
"predicament": "A man who has built a respectable new life is found by an old associate who threatens to expose his past."
|
||||
},
|
||||
{
|
||||
"chunk": 82,
|
||||
"story": 6,
|
||||
"label": "past_comes_back",
|
||||
"predicament": "A man pays off a blackmailer and learns the price will be half of everything, for good."
|
||||
},
|
||||
{
|
||||
"chunk": 83,
|
||||
"story": 6,
|
||||
"label": "past_comes_back",
|
||||
"predicament": "A reformed man warns a blackmailer not to push him too far, while the blackmailer sets his sights on the man's family."
|
||||
},
|
||||
{
|
||||
"chunk": 84,
|
||||
"story": 6,
|
||||
"label": "none",
|
||||
"predicament": "A man who has seized power over another knows the danger he is in, and guards himself even as he gloats."
|
||||
},
|
||||
{
|
||||
"chunk": 85,
|
||||
"story": 6,
|
||||
"label": "unwelcome_in_home",
|
||||
"predicament": "A household must make polite space for someone who holds them hostage."
|
||||
},
|
||||
{
|
||||
"chunk": 86,
|
||||
"story": 6,
|
||||
"label": "unwelcome_in_home",
|
||||
"predicament": "Someone watches a person she trusts give way at every turn to an intruder, and demands that he stand up for himself."
|
||||
},
|
||||
{
|
||||
"chunk": 87,
|
||||
"story": 6,
|
||||
"label": "past_comes_back",
|
||||
"predicament": "A man who is being blackmailed hears his tormentor threaten someone he loves, and must choose between swallowing it and hitting back."
|
||||
},
|
||||
{
|
||||
"chunk": 88,
|
||||
"story": 6,
|
||||
"label": "your_scheme",
|
||||
"predicament": "A blackmailer's hold depends on his victim's life, and that victim suddenly collapses."
|
||||
},
|
||||
{
|
||||
"chunk": 89,
|
||||
"story": 6,
|
||||
"label": "none",
|
||||
"predicament": "A blackmailer whose victim is dying must grab what he can before his hold disappears."
|
||||
},
|
||||
{
|
||||
"chunk": 90,
|
||||
"story": 6,
|
||||
"label": "past_comes_back",
|
||||
"predicament": "A dying man pressed by his tormentor bargains for time and for provision for someone he loves."
|
||||
},
|
||||
{
|
||||
"chunk": 91,
|
||||
"story": 6,
|
||||
"label": "your_scheme",
|
||||
"predicament": "A man who thinks his helpless victim is at his mercy leans in close to hear the secret."
|
||||
},
|
||||
{
|
||||
"chunk": 92,
|
||||
"story": 6,
|
||||
"label": "power_over_you",
|
||||
"predicament": "A man who pretended to be helpless strikes at the tormentor who threatened his family."
|
||||
},
|
||||
{
|
||||
"chunk": 93,
|
||||
"story": 6,
|
||||
"label": "let_another_take_blame",
|
||||
"predicament": "A man who has done a desperate thing arranges things so that suspicion falls elsewhere."
|
||||
},
|
||||
{
|
||||
"chunk": 94,
|
||||
"story": 7,
|
||||
"label": "none",
|
||||
"predicament": "A craftsman told his work looks like something else quietly fixes it before anyone else notices."
|
||||
},
|
||||
{
|
||||
"chunk": 95,
|
||||
"story": 7,
|
||||
"label": "pretence",
|
||||
"predicament": "An old man looking for a free drink offers a story in exchange."
|
||||
},
|
||||
{
|
||||
"chunk": 96,
|
||||
"story": 7,
|
||||
"label": "dread",
|
||||
"predicament": "People who laughed at a distant danger panic when it seems to have arrived on their doorstep."
|
||||
},
|
||||
{
|
||||
"chunk": 97,
|
||||
"story": 7,
|
||||
"label": "dread",
|
||||
"predicament": "Someone must choose between keeping himself safe behind a barred door and letting in a stranger in danger."
|
||||
},
|
||||
{
|
||||
"chunk": 98,
|
||||
"story": 7,
|
||||
"label": "pretence",
|
||||
"predicament": "A stranger with a thrilling tale of danger is rewarded by a frightened crowd without anyone checking it."
|
||||
},
|
||||
{
|
||||
"chunk": 99,
|
||||
"story": 7,
|
||||
"label": "dread",
|
||||
"predicament": "A community living in fear of an unseen threat ignores the one doubter, because he has a bad name."
|
||||
},
|
||||
{
|
||||
"chunk": 100,
|
||||
"story": 7,
|
||||
"label": "cant_prove_it",
|
||||
"predicament": "A man who has suffered a loss goes to the authorities for help and is brushed off as a nuisance."
|
||||
},
|
||||
{
|
||||
"chunk": 101,
|
||||
"story": 7,
|
||||
"label": "none",
|
||||
"predicament": "A community suffering repeated losses cannot get help, and cannot agree how to protect itself."
|
||||
},
|
||||
{
|
||||
"chunk": 102,
|
||||
"story": 7,
|
||||
"label": "pretence",
|
||||
"predicament": "A man tells a dramatic story of narrow escape to an audience eager to believe it."
|
||||
},
|
||||
{
|
||||
"chunk": 103,
|
||||
"story": 7,
|
||||
"label": "pretence",
|
||||
"predicament": "A man caught in a boastful lie is 'backed up' by a rival, who now has him trapped by it."
|
||||
},
|
||||
{
|
||||
"chunk": 104,
|
||||
"story": 7,
|
||||
"label": "none",
|
||||
"predicament": "People told their fears are nonsense by outsiders decide to take matters into their own hands."
|
||||
},
|
||||
{
|
||||
"chunk": 105,
|
||||
"story": 7,
|
||||
"label": "none",
|
||||
"predicament": "A band that set out to face a danger returns relieved to have found nothing, and is then called to face it for real."
|
||||
},
|
||||
{
|
||||
"chunk": 106,
|
||||
"story": 7,
|
||||
"label": "caught_out",
|
||||
"predicament": "A man with something to hide has an armed crowd force its way into his home."
|
||||
},
|
||||
{
|
||||
"chunk": 107,
|
||||
"story": 7,
|
||||
"label": "caught_out",
|
||||
"predicament": "A man caught with the evidence of his thefts brazens it out and dares his accusers to prove anything."
|
||||
},
|
||||
{
|
||||
"chunk": 108,
|
||||
"story": 7,
|
||||
"label": "caught_out",
|
||||
"predicament": "A man accused of wrongdoing turns his accusers' own exaggerations into his defence."
|
||||
},
|
||||
{
|
||||
"chunk": 109,
|
||||
"story": 7,
|
||||
"label": "cant_prove_it",
|
||||
"predicament": "People who know they were swindled cannot prove it, and must watch the swindler profit."
|
||||
},
|
||||
{
|
||||
"chunk": 110,
|
||||
"story": 8,
|
||||
"label": "none",
|
||||
"predicament": "A man nervously setting out to do something that matters to him is stopped by a friend with questions."
|
||||
},
|
||||
{
|
||||
"chunk": 111,
|
||||
"story": 8,
|
||||
"label": "rival",
|
||||
"predicament": "A man discovers a friend is after the same person he is, on the same day, and each races to get there first."
|
||||
},
|
||||
{
|
||||
"chunk": 112,
|
||||
"story": 8,
|
||||
"label": "rival",
|
||||
"predicament": "Two rivals agree someone must go first, and neither will be the one to wait."
|
||||
},
|
||||
{
|
||||
"chunk": 113,
|
||||
"story": 8,
|
||||
"label": "rival",
|
||||
"predicament": "Two rivals for the same prize trade claims of priority, and each suspects the other of a trick."
|
||||
},
|
||||
{
|
||||
"chunk": 114,
|
||||
"story": 8,
|
||||
"label": "your_scheme",
|
||||
"predicament": "Two rivals agree to settle who goes first by a game, and one slips in a condition the other does not examine."
|
||||
},
|
||||
{
|
||||
"chunk": 115,
|
||||
"story": 8,
|
||||
"label": "your_scheme",
|
||||
"predicament": "A man who tried to tip a contest in his favour loses it through his own oversight."
|
||||
},
|
||||
{
|
||||
"chunk": 116,
|
||||
"story": 8,
|
||||
"label": "rival",
|
||||
"predicament": "A man who lost the right to try first must wait while his rival takes his chance."
|
||||
},
|
||||
{
|
||||
"chunk": 117,
|
||||
"story": 8,
|
||||
"label": "rival",
|
||||
"predicament": "A man finds the rival he lost to has been refused, but can still use the terms of their bargain to keep him out."
|
||||
},
|
||||
{
|
||||
"chunk": 118,
|
||||
"story": 8,
|
||||
"label": "rival",
|
||||
"predicament": "A man bound by a promise he made watches a rival use it to keep him from what he wants."
|
||||
},
|
||||
{
|
||||
"chunk": 119,
|
||||
"story": 8,
|
||||
"label": "caught_out",
|
||||
"predicament": "A man who has been avoiding someone he cares for must explain why, and the only honest answer is embarrassing."
|
||||
},
|
||||
{
|
||||
"chunk": 120,
|
||||
"story": 8,
|
||||
"label": "none",
|
||||
"predicament": "A man bound by a rash promise finds the person he hoped to win is willing to help him get round it."
|
||||
},
|
||||
{
|
||||
"chunk": 121,
|
||||
"story": 8,
|
||||
"label": "none",
|
||||
"predicament": "Someone arranges to accept the suitor she prefers in a way his rival cannot object to."
|
||||
},
|
||||
{
|
||||
"chunk": 122,
|
||||
"story": 9,
|
||||
"label": "newcomer",
|
||||
"predicament": "A man wakes up somewhere strange with no idea how he got there."
|
||||
},
|
||||
{
|
||||
"chunk": 123,
|
||||
"story": 9,
|
||||
"label": "newcomer",
|
||||
"predicament": "A man in an unknown place looks for his one companion and a first clue to how they came to be there."
|
||||
},
|
||||
{
|
||||
"chunk": 124,
|
||||
"story": 9,
|
||||
"label": "power_over_you",
|
||||
"predicament": "A man who has always been obeyed tries to give orders somewhere nobody knows or believes who he is."
|
||||
},
|
||||
{
|
||||
"chunk": 125,
|
||||
"story": 9,
|
||||
"label": "your_scheme",
|
||||
"predicament": "Two people in trouble together try to put on a united front and end up blaming each other."
|
||||
},
|
||||
{
|
||||
"chunk": 126,
|
||||
"story": 9,
|
||||
"label": "power_over_you",
|
||||
"predicament": "Two people who claim to be respected professionals are treated as impostors and forced to do menial work."
|
||||
},
|
||||
{
|
||||
"chunk": 127,
|
||||
"story": 9,
|
||||
"label": "power_over_you",
|
||||
"predicament": "People brought low insist on their old status, and are mocked for it by those around them."
|
||||
},
|
||||
{
|
||||
"chunk": 128,
|
||||
"story": 9,
|
||||
"label": "power_over_you",
|
||||
"predicament": "A humiliated man pretends to submit while he quietly prepares his revenge."
|
||||
},
|
||||
{
|
||||
"chunk": 129,
|
||||
"story": 9,
|
||||
"label": "power_over_you",
|
||||
"predicament": "A man mocked for claiming skill is suddenly needed for it, and names his price."
|
||||
},
|
||||
{
|
||||
"chunk": 130,
|
||||
"story": 9,
|
||||
"label": "power_over_you",
|
||||
"predicament": "A man who has gained the upper hand over those who humiliated him enjoys every moment of it."
|
||||
},
|
||||
{
|
||||
"chunk": 131,
|
||||
"story": 9,
|
||||
"label": "power_over_you",
|
||||
"predicament": "A man who has secretly caused the trouble only he can cure makes the most of it."
|
||||
},
|
||||
{
|
||||
"chunk": 132,
|
||||
"story": 9,
|
||||
"label": "none",
|
||||
"predicament": "A man who swore he would never pay the person he scorned is forced by suffering to haggle with him."
|
||||
},
|
||||
{
|
||||
"chunk": 133,
|
||||
"story": 9,
|
||||
"label": "power_over_you",
|
||||
"predicament": "People who scorned two outsiders now depend on them completely, and pay for the privilege."
|
||||
},
|
||||
{
|
||||
"chunk": 134,
|
||||
"story": 9,
|
||||
"label": "power_over_you",
|
||||
"predicament": "A man who got even with his tormentors leaves them knowing they were had."
|
||||
},
|
||||
{
|
||||
"chunk": 135,
|
||||
"story": 10,
|
||||
"label": "strings",
|
||||
"predicament": "Someone given news that might change their life cannot take it in, while the people around them itch to know."
|
||||
},
|
||||
{
|
||||
"chunk": 136,
|
||||
"story": 10,
|
||||
"label": "strings",
|
||||
"predicament": "Someone who has never had luck is told of a windfall and is afraid to believe it."
|
||||
},
|
||||
{
|
||||
"chunk": 137,
|
||||
"story": 10,
|
||||
"label": "strings",
|
||||
"predicament": "A man sees a relative come into money and resents that luck never comes his way."
|
||||
},
|
||||
{
|
||||
"chunk": 138,
|
||||
"story": 10,
|
||||
"label": "after_your_money",
|
||||
"predicament": "A man flattered by new friends is told they are really after a relative's money."
|
||||
},
|
||||
{
|
||||
"chunk": 139,
|
||||
"story": 10,
|
||||
"label": "after_your_money",
|
||||
"predicament": "A man trying to expose flattering suitors trips over his own family's sensibilities."
|
||||
},
|
||||
{
|
||||
"chunk": 140,
|
||||
"story": 10,
|
||||
"label": "rival",
|
||||
"predicament": "Two rivals courting the same person keep topping each other's compliments while the family grows fed up."
|
||||
},
|
||||
{
|
||||
"chunk": 141,
|
||||
"story": 10,
|
||||
"label": "rival",
|
||||
"predicament": "A slow-talking man losing a contest to a glib rival looks for a trick instead of better words."
|
||||
},
|
||||
{
|
||||
"chunk": 142,
|
||||
"story": 10,
|
||||
"label": "rival",
|
||||
"predicament": "A man hires someone to spread a false rumour that will drive off his rival, and the hireling drives a hard bargain."
|
||||
},
|
||||
{
|
||||
"chunk": 143,
|
||||
"story": 10,
|
||||
"label": "your_scheme",
|
||||
"predicament": "A man courting for money pretends not to care about it and flatters the one he courts by letting her win."
|
||||
},
|
||||
{
|
||||
"chunk": 144,
|
||||
"story": 10,
|
||||
"label": "your_scheme",
|
||||
"predicament": "A schemer who thinks his deal is done walks away gloating too soon."
|
||||
},
|
||||
{
|
||||
"chunk": 145,
|
||||
"story": 10,
|
||||
"label": "your_scheme",
|
||||
"predicament": "A man makes a public show of selfless devotion, sure that it will cost him nothing."
|
||||
},
|
||||
{
|
||||
"chunk": 146,
|
||||
"story": 10,
|
||||
"label": "your_scheme",
|
||||
"predicament": "A schemer whose plan has worked is thanked for his goodness while the ground starts to shift under him."
|
||||
},
|
||||
{
|
||||
"chunk": 147,
|
||||
"story": 10,
|
||||
"label": "your_scheme",
|
||||
"predicament": "A man who faked a disaster to win a prize learns the disaster is real, and he is bound to his promise."
|
||||
},
|
||||
{
|
||||
"chunk": 148,
|
||||
"story": 11,
|
||||
"label": "pretence",
|
||||
"predicament": "A sceptic among believers is pressed to admit whether he has ever seen anything he could not explain."
|
||||
},
|
||||
{
|
||||
"chunk": 149,
|
||||
"story": 11,
|
||||
"label": "newcomer",
|
||||
"predicament": "A young man who arrived to find no one expecting him has to fill empty days in a strange place."
|
||||
},
|
||||
{
|
||||
"chunk": 150,
|
||||
"story": 11,
|
||||
"label": "newcomer",
|
||||
"predicament": "A traveller lost in the dark and the cold must find shelter before night closes in."
|
||||
},
|
||||
{
|
||||
"chunk": 151,
|
||||
"story": 11,
|
||||
"label": "dread",
|
||||
"predicament": "A worn-out traveller must choose between a long walk on and a nearby shelter he has been warned against."
|
||||
},
|
||||
{
|
||||
"chunk": 152,
|
||||
"story": 11,
|
||||
"label": "dread",
|
||||
"predicament": "A stranger taken in by odd hosts who seem keen to keep him wonders whether he has walked into a trap."
|
||||
},
|
||||
{
|
||||
"chunk": 153,
|
||||
"story": 11,
|
||||
"label": "dread",
|
||||
"predicament": "A guest must sit down in the dark with someone he cannot see, on hosts' terms he does not understand."
|
||||
},
|
||||
{
|
||||
"chunk": 154,
|
||||
"story": 11,
|
||||
"label": "dread",
|
||||
"predicament": "A guest left alone with a stranger he cannot see grows afraid, and must decide whether to trust his hosts."
|
||||
},
|
||||
{
|
||||
"chunk": 155,
|
||||
"story": 11,
|
||||
"label": "dread",
|
||||
"predicament": "A frightened man sees what he feared, and learns it is someone who was hurt and ashamed to be seen."
|
||||
},
|
||||
{
|
||||
"chunk": 156,
|
||||
"story": 11,
|
||||
"label": "owning_up",
|
||||
"predicament": "A man who shrank from someone must make amends, and finds they deserve his respect."
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,36 @@
|
||||
Pilot, qwen3:14b, shortlist k=8, 14B predicaments, O. Henry scenes 0-39. The output file was later deleted;
|
||||
these figures were copied by hand from the session transcript (score.py output):
|
||||
situated? precision 38% recall 93% (model calls 34, hand 14)
|
||||
label right where both situated: 23% of 13
|
||||
kappa over all labels incl. none: 0.09
|
||||
embedding-only baseline (nearest entry) on hand-situated scenes: 36%
|
||||
hand label inside the shortlist offered: 71%
|
||||
ohenry-q14-dev1.partial: 30 scenes
|
||||
situated? precision 67% recall 100% (model calls 21, hand 14)
|
||||
label right where both situated: 14% of 14
|
||||
kappa over all labels incl. none: 0.23
|
||||
ohenry-q14-dev1.partial fit>=3: 30 scenes
|
||||
situated? precision 62% recall 57% (model calls 13, hand 14)
|
||||
label right where both situated: 0% of 8
|
||||
kappa over all labels incl. none: 0.09
|
||||
6 hand=none model=unspoken fit=3 | Someone and the narrator sit in silence, caught between the quiet intimacy of the moment and the unspoken weig
|
||||
9 hand=home_unseen model=home_unseen fit=2 | Someone longs for connection but is trapped in a life of monotony and shallow conversation.
|
||||
10 hand=none model=caught_out fit=3 | A man tries to pay for a purchase with a promise of future money, but his dishonesty sparks a violent confront
|
||||
11 hand=none model=caught_out fit=2 | A man flees from police pursuit, seeking refuge in an unknown vehicle that offers him a chance to escape, but
|
||||
12 hand=none model=their_secret fit=3 | A man finds himself unexpectedly drawn into a personal conflict he did not ask for, yet must navigate with cou
|
||||
13 hand=none model=owning_up fit=3 | Someone must confront a man who has wronged a woman he now feels compelled to protect, risking his own safety
|
||||
14 hand=none model=compromise_for_money fit=2 | A man struggles to reconcile his growing affection for a woman with the rigid expectations of his social circl
|
||||
16 hand=station model=compromise_for_money fit=2 | A man finds himself trapped in an awkward encounter with a shopgirl whose icy beauty challenges his composure,
|
||||
17 hand=station model=pretence fit=2 | A man struggling with his pride and social conventions finds himself awkwardly drawn to a shopgirl, determined
|
||||
18 hand=station model=compromise_for_money fit=2 | A young woman balances between curiosity and caution as she agrees to meet a man she barely knows, hoping to e
|
||||
19 hand=station model=unspoken fit=3 | A man torn between his growing feelings for a woman he barely knows and his fear of losing control of his life
|
||||
20 hand=station model=pretence fit=3 | A man insists a woman marry him, claiming his wealth and status make them equal, but she sees through his word
|
||||
21 hand=station model=strings fit=2 | A man tries to convince a hesitant woman to leave her mundane life and embark on an extravagant, uncertain jou
|
||||
22 hand=station model=pretence fit=3 | A woman hides her growing feelings for a man while pretending to be uninterested, hoping to keep their connect
|
||||
23 hand=none model=hiding_from_close fit=3 | Big someone must navigate the rigid codes of his underground world while seeking a way to break free and claim
|
||||
24 hand=home_unseen model=home_unseen fit=2 | A man struggles to balance his cultural identity and personal life while navigating the expectations of his co
|
||||
25 hand=home_unseen model=habit fit=3 | A man accustomed to routine is confronted with an unexpected request for companionship, forcing him to reconsi
|
||||
26 hand=home_unseen model=hiding_from_close fit=3 | A man struggling to reconcile his desire to display his wife publicly with his fear of judgment finds himself
|
||||
27 hand=home_unseen model=what_they_hid fit=3 | A man struggles to reconcile his pride with the growing awareness of his wife's hidden talents and the shame o
|
||||
28 hand=home_unseen model=hiding_from_close fit=3 | A man struggles to reconcile his quiet life with the sudden, overwhelming attention his wife receives, threate
|
||||
29 hand=home_unseen model=peace_at_home fit=3 | A man faces a choice between social obligation and personal pride, seeking to assert his dignity while preserv
|
||||
@@ -0,0 +1,68 @@
|
||||
== ohenry: 97 stories, 839 scenes; rule: >= 5 scenes, >= 4 stories, no story over 50%
|
||||
scenes labelled: 475 (57%)
|
||||
PASS pretence 52 scenes 12 stories top story 15%
|
||||
PASS benefactor 48 scenes 14 stories top story 17%
|
||||
PASS home_unseen 47 scenes 11 stories top story 15%
|
||||
PASS station 47 scenes 10 stories top story 17%
|
||||
PASS compromise_for_money 38 scenes 13 stories top story 18%
|
||||
PASS newcomer 32 scenes 8 stories top story 22%
|
||||
PASS sure_thing 30 scenes 7 stories top story 23%
|
||||
PASS sacrifice 25 scenes 8 stories top story 28%
|
||||
PASS rival 24 scenes 6 stories top story 29%
|
||||
PASS despised 20 scenes 7 stories top story 30%
|
||||
PASS pressed_to_wrong 19 scenes 5 stories top story 32%
|
||||
PASS wrongly_judged 19 scenes 7 stories top story 26%
|
||||
PASS sign 17 scenes 5 stories top story 35%
|
||||
strings 15 scenes 4 stories top story 53%
|
||||
PASS unspoken 13 scenes 4 stories top story 38%
|
||||
overlooked 9 scenes 3 stories top story 67%
|
||||
PASS homecoming 8 scenes 4 stories top story 50%
|
||||
PASS patron 8 scenes 4 stories top story 50%
|
||||
habit 4 scenes 3 stories top story 50%
|
||||
-> 16 recurring situations of 19 present
|
||||
|
||||
== wharton: 10 stories, 262 scenes; rule: >= 5 scenes, >= 3 stories, no story over 50%
|
||||
scenes labelled: 129 (49%)
|
||||
PASS caught_out 12 scenes 4 stories top story 50%
|
||||
PASS compromise_for_money 12 scenes 4 stories top story 50%
|
||||
PASS past_comes_back 12 scenes 3 stories top story 50%
|
||||
PASS what_they_hid 10 scenes 6 stories top story 20%
|
||||
PASS peace_at_home 8 scenes 5 stories top story 38%
|
||||
PASS newcomer 8 scenes 3 stories top story 50%
|
||||
PASS rival 8 scenes 4 stories top story 50%
|
||||
PASS power_over_you 8 scenes 4 stories top story 50%
|
||||
PASS sacrifice 8 scenes 5 stories top story 38%
|
||||
PASS owning_up 8 scenes 4 stories top story 38%
|
||||
PASS home_unseen 7 scenes 4 stories top story 43%
|
||||
PASS go_between 6 scenes 3 stories top story 50%
|
||||
PASS waiting 6 scenes 3 stories top story 50%
|
||||
PASS own_words_back 6 scenes 3 stories top story 50%
|
||||
PASS strings 5 scenes 4 stories top story 40%
|
||||
PASS their_secret 5 scenes 3 stories top story 40%
|
||||
-> 16 recurring situations of 16 present
|
||||
|
||||
== jacobs: 12 stories, 157 scenes; rule: >= 5 scenes, >= 3 stories, no story over 50%
|
||||
scenes labelled: 137 (87%)
|
||||
PASS your_scheme 23 scenes 8 stories top story 39%
|
||||
PASS dread 12 scenes 4 stories top story 42%
|
||||
rival 11 scenes 3 stories top story 55%
|
||||
power_over_you 11 scenes 3 stories top story 82%
|
||||
hiding_from_close 10 scenes 2 stories top story 80%
|
||||
PASS caught_out 10 scenes 5 stories top story 40%
|
||||
PASS let_another_take_blame 8 scenes 5 stories top story 50%
|
||||
strings 8 scenes 2 stories top story 62%
|
||||
past_comes_back 8 scenes 3 stories top story 62%
|
||||
peace_at_home 5 scenes 2 stories top story 60%
|
||||
PASS pressed_to_wrong 5 scenes 4 stories top story 40%
|
||||
after_your_money 5 scenes 2 stories top story 60%
|
||||
PASS cant_prove_it 5 scenes 3 stories top story 40%
|
||||
pretence 5 scenes 2 stories top story 80%
|
||||
unwelcome_in_home 4 scenes 3 stories top story 50%
|
||||
newcomer 4 scenes 2 stories top story 50%
|
||||
owning_up 3 scenes 2 stories top story 67%
|
||||
-> 6 recurring situations of 17 present
|
||||
|
||||
== catalogue coverage: entries found in 3 / 2 / 1 corpora
|
||||
3: rival, strings, newcomer
|
||||
2: compromise_for_money, past_comes_back, pretence, caught_out, home_unseen, peace_at_home, power_over_you, pressed_to_wrong, sacrifice, owning_up
|
||||
1: sure_thing, your_scheme, after_your_money, hiding_from_close, their_secret, what_they_hid, wrongly_judged, let_another_take_blame, cant_prove_it, station, unwelcome_in_home, benefactor, patron, go_between, own_words_back, homecoming, dread, waiting, unspoken, overlooked, sign, despised, habit
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Score classifier output against the hand labels. No inference.
|
||||
|
||||
python3 score.py out/ohenry-q14.json [more files] # or --selftest
|
||||
|
||||
Reports: (1) does it find the situated scenes (label vs none); (2) on scenes both call situated, is the
|
||||
label right; (3) Cohen's kappa over everything; (4) the embedding-only baseline (nearest entry, no
|
||||
model) on the same scenes; (5) the recurring situations a pack would get under the one shared rule,
|
||||
from the model's labels against the hand labels. The recall of the K-nearest shortlist is reported
|
||||
too: a hand label that was never offered cannot be chosen.
|
||||
"""
|
||||
import collections, json, pathlib, random, sys
|
||||
HERE = pathlib.Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(HERE))
|
||||
from catalogue_src import recurs, load_corpus
|
||||
|
||||
def kappa(a, b):
|
||||
n = len(a); po = sum(x == y for x, y in zip(a, b)) / n
|
||||
ca, cb = collections.Counter(a), collections.Counter(b)
|
||||
pe = sum(ca[k] * cb[k] for k in ca) / n / n
|
||||
return (po - pe) / (1 - pe) if pe < 1 else 1.0
|
||||
|
||||
def score(corpus, hand, pred, nearest=None, cands=None, name=''):
|
||||
chunks = sorted(pred)
|
||||
h = [hand[c] for c in chunks]; p = [pred[c] for c in chunks]
|
||||
tp = sum(x != 'none' and y != 'none' for x, y in zip(h, p))
|
||||
prec = tp / max(1, sum(y != 'none' for y in p)); rec = tp / max(1, sum(x != 'none' for x in h))
|
||||
both = [(x, y) for x, y in zip(h, p) if x != 'none' and y != 'none']
|
||||
acc = sum(x == y for x, y in both) / max(1, len(both))
|
||||
lines = [f'{name}: {len(chunks)} scenes',
|
||||
f' situated? precision {prec:.0%} recall {rec:.0%} (model calls {sum(y != "none" for y in p)}, hand {sum(x != "none" for x in h)})',
|
||||
f' label right where both situated: {acc:.0%} of {len(both)}',
|
||||
f' kappa over all labels incl. none: {kappa(h, p):.2f}']
|
||||
if nearest:
|
||||
sit = [c for c in chunks if hand[c] != 'none']
|
||||
lines.append(f' embedding-only baseline (nearest entry) on hand-situated scenes: {sum(nearest[c] == hand[c] for c in sit) / max(1, len(sit)):.0%}')
|
||||
lines.append(f' hand label inside the shortlist offered: {sum(hand[c] in cands[c] for c in sit) / max(1, len(sit)):.0%}')
|
||||
chunks_all, _, _ = load_corpus(corpus); story = [c['story'] for c in chunks_all]
|
||||
def passing(lab):
|
||||
mem = collections.defaultdict(list)
|
||||
for c, l in lab.items():
|
||||
if l != 'none': mem[l].append(c)
|
||||
return {l: m for l, m in mem.items() if recurs(m, story)[0]}
|
||||
ph = passing({c: hand[c] for c in chunks}); pp = passing({c: pred[c] for c in chunks})
|
||||
common = set(ph) & set(pp)
|
||||
lines.append(f' recurring situations: hand {len(ph)}, model {len(pp)}, both {len(common)}')
|
||||
lines.append(f' model only: {", ".join(sorted(set(pp) - set(ph))) or "-"}')
|
||||
lines.append(f' hand only: {", ".join(sorted(set(ph) - set(pp))) or "-"}')
|
||||
for l in sorted(common, key=lambda l: -len(ph[l])):
|
||||
a, b = set(ph[l]), set(pp[l])
|
||||
lines.append(f' {l:24s} hand {len(a):3d} model {len(b):3d} overlap {len(a & b):3d} (model precision {len(a & b) / len(b):.0%}, recall {len(a & b) / len(a):.0%})')
|
||||
return '\n'.join(lines)
|
||||
|
||||
if __name__ == '__main__':
|
||||
out = []
|
||||
if sys.argv[1:] == ['--selftest']:
|
||||
lab = json.load(open(HERE / 'labels-ohenry.json'))['labels']; hand = {x['chunk']: x['label'] for x in lab}
|
||||
out.append(score('ohenry', hand, dict(hand), name='SELFTEST hand vs itself (expect 100% / kappa 1.00)'))
|
||||
rnd = random.Random(1); ids = sorted(set(hand.values()))
|
||||
out.append(score('ohenry', hand, {c: rnd.choice(ids) for c in hand}, name='SELFTEST random labels (expect kappa ~0)'))
|
||||
for f in sys.argv[1:]:
|
||||
if f.startswith('--'): continue
|
||||
corpus = pathlib.Path(f).stem.split('-')[0]
|
||||
lab = json.load(open(HERE / f'labels-{corpus}.json'))['labels']; hand = {x['chunk']: x['label'] for x in lab}
|
||||
res = json.load(open(f))['results']
|
||||
minfit = int(next((a.split('=')[1] for a in sys.argv if a.startswith('--minfit=')), 0))
|
||||
rng = next((a.split('=')[1] for a in sys.argv if a.startswith('--range=')), None)
|
||||
if rng: lo, hi = map(int, rng.split(':')); res = [r for r in res if lo <= r['chunk'] < hi]
|
||||
pred = {r['chunk']: (r['label'] if (r.get('fit') or 3) >= minfit else 'none') for r in res}
|
||||
out.append(score(corpus, hand, pred, {r['chunk']: r['nearest'] for r in res}, {r['chunk']: r['candidates'] for r in res}, name=pathlib.Path(f).stem + (f' fit>={minfit}' if minfit else '') + (f' scenes {rng}' if rng else '')))
|
||||
print('\n\n'.join(out))
|
||||
@@ -0,0 +1,30 @@
|
||||
dev: 40 candidates, 5 labelled 2, rule must accept >= 3 of them
|
||||
|
||||
baseline accept all candidates accepted 40 P2 0.12 P1+ 0.62 R2 1.00
|
||||
baseline B0: tightness filter accepted 17 P2 0.24 P1+ 0.76 R2 0.80
|
||||
|
||||
best single-signal rule per signal:
|
||||
S1 >= 0.6153 accepted 9 P2 0.44 P1+ 0.89 R2 0.80
|
||||
S2 >= 0.9083 accepted 20 P2 0.15 P1+ 0.60 R2 0.60
|
||||
S3 >= 0.4580 accepted 31 P2 0.16 P1+ 0.68 R2 1.00
|
||||
S4 >= 0.0295 accepted 14 P2 0.21 P1+ 0.64 R2 0.60
|
||||
S5 >= 0.1733 accepted 10 P2 0.30 P1+ 0.60 R2 0.60
|
||||
S6 <= 0.2137 accepted 33 P2 0.15 P1+ 0.70 R2 1.00
|
||||
|
||||
leaderboard (all eligible rules):
|
||||
S1 >= 0.6119 AND S6 <= 0.1603 accepted 5 P2 0.60 P1+ 1.00 R2 0.60
|
||||
S1 >= 0.6119 AND S6 <= 0.1742 accepted 5 P2 0.60 P1+ 1.00 R2 0.60
|
||||
S1 >= 0.6153 AND S6 <= 0.1603 accepted 5 P2 0.60 P1+ 1.00 R2 0.60
|
||||
S1 >= 0.6153 AND S6 <= 0.1742 accepted 5 P2 0.60 P1+ 1.00 R2 0.60
|
||||
S1 >= 0.6153 AND S5 >= 0.1733 accepted 5 P2 0.60 P1+ 0.80 R2 0.60
|
||||
S1 >= 0.6119 AND S6 <= 0.2137 accepted 7 P2 0.57 P1+ 1.00 R2 0.80
|
||||
S1 >= 0.6119 AND S6 <= 0.3012 accepted 7 P2 0.57 P1+ 1.00 R2 0.80
|
||||
S1 >= 0.6153 AND S6 <= 0.2137 accepted 7 P2 0.57 P1+ 1.00 R2 0.80
|
||||
S1 >= 0.6153 AND S6 <= 0.3012 accepted 7 P2 0.57 P1+ 1.00 R2 0.80
|
||||
S1 >= 0.6153 AND S5 >= 0.1278 accepted 7 P2 0.57 P1+ 0.86 R2 0.80
|
||||
S1 >= 0.6153 AND S5 >= 0.1318 accepted 7 P2 0.57 P1+ 0.86 R2 0.80
|
||||
S1 >= 0.6153 AND S5 >= 0.1356 accepted 7 P2 0.57 P1+ 0.86 R2 0.80
|
||||
|
||||
CHOSEN: S1 >= 0.6119 AND S6 <= 0.1603 -> rule-frozen.json
|
||||
accepted on dev: D04(2), D09(1), D13(2), D16(2), D33(1)
|
||||
exit 0
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Cut stories into scene-sized chunks on paragraph boundaries."""
|
||||
import json, pathlib, re
|
||||
|
||||
TARGET = 320 # words; a scene is a situation with people in it
|
||||
|
||||
stories = json.loads(pathlib.Path('stories.json').read_text(encoding='utf-8'))
|
||||
chunks = []
|
||||
for si, s in enumerate(stories):
|
||||
paras = [p.strip() for p in re.split(r'\n\s*\n', s['text']) if p.strip()]
|
||||
buf, n = [], 0
|
||||
for p in paras:
|
||||
w = len(p.split())
|
||||
if n + w > TARGET and buf:
|
||||
chunks.append({'story': si, 'title': s['title'], 'volume': s['volume'],
|
||||
'text': ' '.join(buf), 'words': n})
|
||||
buf, n = [], 0
|
||||
buf.append(p); n += w
|
||||
if buf and n >= 80:
|
||||
chunks.append({'story': si, 'title': s['title'], 'volume': s['volume'],
|
||||
'text': ' '.join(buf), 'words': n})
|
||||
|
||||
pathlib.Path('chunks.json').write_text(json.dumps(chunks), encoding='utf-8')
|
||||
ws = sorted(c['words'] for c in chunks)
|
||||
print(f'chunks: {len(chunks)} from {len(stories)} stories')
|
||||
print(f'words/chunk min {ws[0]} median {ws[len(ws)//2]} max {ws[-1]}')
|
||||
print(f'total words: {sum(ws):,}')
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,80 @@
|
||||
"""Cluster scene embeddings and report what the clusters actually contain.
|
||||
|
||||
The question this answers: does a thematically coherent corpus contain
|
||||
RECURRING situations, or 800 singletons? A pack needs ~15 repeatable
|
||||
situations per stage; a cluster that draws from many different stories is a
|
||||
recurring situation, one that draws from a single story is just a scene.
|
||||
"""
|
||||
import json, math, pathlib, random, sys
|
||||
|
||||
K = int(sys.argv[1]) if len(sys.argv) > 1 else 40
|
||||
SEED = 11
|
||||
|
||||
chunks = json.loads(pathlib.Path('chunks.json').read_text(encoding='utf-8'))
|
||||
vecs = json.loads(pathlib.Path('embeddings.json').read_text(encoding='utf-8'))
|
||||
n = min(len(chunks), len(vecs))
|
||||
chunks, vecs = chunks[:n], vecs[:n]
|
||||
print(f'clustering {n} chunks into k={K}\n')
|
||||
|
||||
def norm(v):
|
||||
m = math.sqrt(sum(x * x for x in v)) or 1.0
|
||||
return [x / m for x in v]
|
||||
vecs = [norm(v) for v in vecs]
|
||||
dim = len(vecs[0])
|
||||
|
||||
def dot(a, b): return sum(x * y for x, y in zip(a, b))
|
||||
|
||||
# k-means++ init on cosine distance (vectors are unit length, so dot == cos)
|
||||
random.seed(SEED)
|
||||
cent = [vecs[random.randrange(n)]]
|
||||
while len(cent) < K:
|
||||
d2 = [min((1 - dot(v, c)) for c in cent) ** 2 for v in vecs]
|
||||
tot = sum(d2) or 1.0
|
||||
r = random.random() * tot
|
||||
acc = 0.0
|
||||
for i, x in enumerate(d2):
|
||||
acc += x
|
||||
if acc >= r:
|
||||
cent.append(vecs[i]); break
|
||||
else:
|
||||
cent.append(vecs[random.randrange(n)])
|
||||
|
||||
assign = [0] * n
|
||||
for it in range(30):
|
||||
moved = 0
|
||||
for i, v in enumerate(vecs):
|
||||
best, bs = 0, -2.0
|
||||
for k, c in enumerate(cent):
|
||||
s = dot(v, c)
|
||||
if s > bs: bs, best = s, k
|
||||
if assign[i] != best: assign[i] = best; moved += 1
|
||||
for k in range(K):
|
||||
mem = [vecs[i] for i in range(n) if assign[i] == k]
|
||||
if not mem: continue
|
||||
cent[k] = norm([sum(m[d] for m in mem) / len(mem) for d in range(dim)])
|
||||
if moved == 0: break
|
||||
|
||||
groups = {}
|
||||
for i, k in enumerate(assign): groups.setdefault(k, []).append(i)
|
||||
|
||||
rows = []
|
||||
for k, idx in groups.items():
|
||||
stories = {chunks[i]['story'] for i in idx}
|
||||
coh = sum(dot(vecs[i], cent[k]) for i in idx) / len(idx)
|
||||
rows.append({'k': k, 'size': len(idx), 'stories': len(stories), 'coh': coh, 'idx': idx})
|
||||
rows.sort(key=lambda r: (-r['stories'], -r['size']))
|
||||
|
||||
print(f"{'size':>5} {'stories':>8} {'coh':>6} sample titles")
|
||||
print('-' * 92)
|
||||
for r in rows:
|
||||
titles = []
|
||||
for i in r['idx']:
|
||||
t = chunks[i]['title']
|
||||
if t not in titles: titles.append(t)
|
||||
print(f"{r['size']:5} {r['stories']:8} {r['coh']:6.3f} {', '.join(titles[:4])[:66]}")
|
||||
|
||||
multi = [r for r in rows if r['stories'] >= 4]
|
||||
print(f"\nclusters drawing on >=4 different stories: {len(multi)} of {K}")
|
||||
print(f"clusters that are essentially one story: {sum(1 for r in rows if r['stories'] <= 2)}")
|
||||
pathlib.Path('clusters.json').write_text(json.dumps(
|
||||
[{'k': r['k'], 'size': r['size'], 'stories': r['stories'], 'coh': r['coh'], 'idx': r['idx']} for r in rows]))
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,56 @@
|
||||
838 texts, resuming at 0, model nomic-embed-text
|
||||
16/838 2s (7.22/s)
|
||||
32/838 3s (10.12/s)
|
||||
48/838 4s (11.75/s)
|
||||
64/838 5s (12.87/s)
|
||||
80/838 6s (13.54/s)
|
||||
96/838 7s (14.07/s)
|
||||
112/838 8s (14.48/s)
|
||||
128/838 9s (14.78/s)
|
||||
144/838 10s (15.01/s)
|
||||
160/838 11s (15.15/s)
|
||||
176/838 12s (15.29/s)
|
||||
192/838 12s (15.41/s)
|
||||
208/838 13s (15.52/s)
|
||||
224/838 14s (15.61/s)
|
||||
240/838 15s (15.70/s)
|
||||
256/838 16s (15.77/s)
|
||||
272/838 17s (15.83/s)
|
||||
288/838 18s (15.88/s)
|
||||
304/838 19s (15.91/s)
|
||||
320/838 20s (15.94/s)
|
||||
336/838 21s (15.98/s)
|
||||
352/838 22s (15.99/s)
|
||||
368/838 23s (16.01/s)
|
||||
384/838 24s (16.00/s)
|
||||
400/838 25s (16.00/s)
|
||||
416/838 26s (16.00/s)
|
||||
432/838 27s (16.03/s)
|
||||
448/838 28s (16.04/s)
|
||||
464/838 29s (16.07/s)
|
||||
480/838 30s (16.07/s)
|
||||
496/838 31s (16.07/s)
|
||||
512/838 32s (16.08/s)
|
||||
528/838 33s (16.08/s)
|
||||
544/838 34s (16.08/s)
|
||||
560/838 35s (16.08/s)
|
||||
576/838 36s (16.07/s)
|
||||
592/838 37s (16.06/s)
|
||||
608/838 38s (16.06/s)
|
||||
624/838 39s (16.07/s)
|
||||
640/838 40s (16.05/s)
|
||||
656/838 41s (16.04/s)
|
||||
672/838 42s (16.03/s)
|
||||
688/838 43s (16.02/s)
|
||||
704/838 44s (16.01/s)
|
||||
720/838 45s (16.00/s)
|
||||
736/838 46s (15.98/s)
|
||||
752/838 47s (15.96/s)
|
||||
768/838 48s (15.95/s)
|
||||
784/838 49s (15.94/s)
|
||||
800/838 50s (15.93/s)
|
||||
816/838 51s (15.91/s)
|
||||
832/838 52s (15.90/s)
|
||||
838/838 53s (15.76/s)
|
||||
done: 838 vectors, 53s this session
|
||||
exit 0
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Embed texts, checkpointing after every batch so an interrupted run resumes.
|
||||
|
||||
python3 embed.py chunks.json embeddings.json # the scenes' prose
|
||||
python3 embed.py summaries.json summary_embeddings.json # one-line situations
|
||||
python3 embed.py chunks.json embeddings.json --limit 48 # pilot
|
||||
|
||||
Input is a JSON list of objects with a 'text' field (chunks.json), or of
|
||||
strings/objects with a 'summary' field (summaries.json).
|
||||
"""
|
||||
import argparse, json, os, pathlib, time
|
||||
import ollama
|
||||
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument('src'); ap.add_argument('dst')
|
||||
ap.add_argument('--limit', type=int, default=None)
|
||||
ap.add_argument('--batch', type=int, default=16)
|
||||
args = ap.parse_args()
|
||||
MODEL = os.environ.get('STP_EMBED_MODEL', 'nomic-embed-text')
|
||||
|
||||
items = json.loads(pathlib.Path(args.src).read_text(encoding='utf-8'))
|
||||
if isinstance(items, dict): items = items['summaries']
|
||||
texts = [x if isinstance(x, str) else (x.get('summary') or x['text']) for x in items]
|
||||
if args.limit: texts = texts[:args.limit]
|
||||
|
||||
out = pathlib.Path(args.dst)
|
||||
state = json.loads(out.read_text()) if out.exists() else {'model': MODEL, 'vectors': []}
|
||||
if state['model'] != MODEL: raise SystemExit(f'{out} was made with {state["model"]}, not {MODEL}')
|
||||
done = state['vectors']
|
||||
print(f'{len(texts)} texts, resuming at {len(done)}, model {MODEL}', flush=True)
|
||||
|
||||
t0, start = time.time(), len(done)
|
||||
for i in range(start, len(texts), args.batch):
|
||||
done.extend(ollama.embed(MODEL, texts[i:i + args.batch]))
|
||||
out.write_text(json.dumps(state))
|
||||
el = time.time() - t0
|
||||
print(f' {len(done)}/{len(texts)} {el:.0f}s ({(len(done) - start) / max(el, 1):.2f}/s)', flush=True)
|
||||
print(f'done: {len(done)} vectors, {time.time() - t0:.0f}s this session')
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"file": "embeddings.json",
|
||||
"chunks": 838,
|
||||
"rows": [
|
||||
{
|
||||
"k": 25,
|
||||
"sized": 25,
|
||||
"median_dominant": 0.25,
|
||||
"recurring": 21,
|
||||
"coverage": 0.9284009546539379,
|
||||
"tight_recurring": 9,
|
||||
"tight_coverage": 0.2863961813842482
|
||||
},
|
||||
{
|
||||
"k": 40,
|
||||
"sized": 39,
|
||||
"median_dominant": 0.35714285714285715,
|
||||
"recurring": 21,
|
||||
"coverage": 0.7088305489260143,
|
||||
"tight_recurring": 5,
|
||||
"tight_coverage": 0.09069212410501193
|
||||
},
|
||||
{
|
||||
"k": 60,
|
||||
"sized": 55,
|
||||
"median_dominant": 0.5714285714285714,
|
||||
"recurring": 20,
|
||||
"coverage": 0.5501193317422435,
|
||||
"tight_recurring": 3,
|
||||
"tight_coverage": 0.02028639618138425
|
||||
},
|
||||
{
|
||||
"k": 100,
|
||||
"sized": 78,
|
||||
"median_dominant": 0.6666666666666666,
|
||||
"recurring": 16,
|
||||
"coverage": 0.29594272076372313,
|
||||
"tight_recurring": 0,
|
||||
"tight_coverage": 0.0
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
qwen3:14b: 250 labelled cluster rows; parse failures 0
|
||||
theta chosen on dev: 0.6
|
||||
dev FIT >= 0.3 accepted 29 >=1 19 label2 5 P1+ 0.66 recall 0.76
|
||||
dev FIT >= 0.4 accepted 28 >=1 18 label2 5 P1+ 0.64 recall 0.72
|
||||
dev FIT >= 0.5 accepted 14 >=1 10 label2 2 P1+ 0.71 recall 0.40
|
||||
dev FIT >= 0.6 accepted 8 >=1 7 label2 2 P1+ 0.88 recall 0.28
|
||||
dev FIT >= 0.7 accepted 4 >=1 4 label2 1 P1+ 1.00 recall 0.16
|
||||
dev FIT >= 0.8 accepted 2 >=1 2 label2 0 P1+ 1.00 recall 0.08
|
||||
dev FIT >= 0.9 accepted 0 >=1 0 label2 0 P1+ 0.00 recall 0.00
|
||||
dev FIT >= 1.0 accepted 0 >=1 0 label2 0 P1+ 0.00 recall 0.00
|
||||
|
||||
mean FIT by label (all rows):
|
||||
label 0: n=121 mean 0.40 median 0.42
|
||||
label 1: n=106 mean 0.47 median 0.47
|
||||
label 2: n= 23 mean 0.52 median 0.53
|
||||
|
||||
PRIMARY: validate + validate3: 148 clusters, 67 labelled >=1
|
||||
all accepted 148 >=1 67 label2 10 P1+ 0.45 recall 1.00
|
||||
G2 accepted 49 >=1 36 label2 5 P1+ 0.73 recall 0.54
|
||||
G4 FIT >= 0.6 accepted 42 >=1 22 label2 4 P1+ 0.52 recall 0.33
|
||||
G4 AND G2 accepted 10 >=1 10 label2 1 P1+ 1.00 recall 0.15
|
||||
G4 size <=9 accepted 17 >=1 5 label2 2 P1+ 0.29 recall 0.56 (of 48)
|
||||
G4 size 10-25 accepted 14 >=1 7 label2 2 P1+ 0.50 recall 0.23 (of 63)
|
||||
G4 size >=26 accepted 11 >=1 10 label2 0 P1+ 0.91 recall 0.37 (of 37)
|
||||
|
||||
secondary: test + recover: 62 clusters, 37 labelled >=1
|
||||
all accepted 62 >=1 37 label2 8 P1+ 0.60 recall 1.00
|
||||
G2 accepted 28 >=1 23 label2 5 P1+ 0.82 recall 0.62
|
||||
G4 FIT >= 0.6 accepted 15 >=1 9 label2 1 P1+ 0.60 recall 0.24
|
||||
G4 AND G2 accepted 11 >=1 8 label2 1 P1+ 0.73 recall 0.22
|
||||
G4 size <=9 accepted 1 >=1 0 label2 0 P1+ 0.00 recall 0.00 (of 8)
|
||||
G4 size 10-25 accepted 5 >=1 3 label2 1 P1+ 0.60 recall 0.15 (of 30)
|
||||
G4 size >=26 accepted 9 >=1 6 label2 0 P1+ 0.67 recall 0.35 (of 24)
|
||||
|
||||
criterion 1 (P1+ >= 0.80): fail (0.52)
|
||||
criterion 2 (more >=1 than G2): fail (22 vs 36)
|
||||
criterion 3 (<=9 members P1+ >= 0.67 if >= 3 accepted): fail (17 accepted, P1+ 0.29)
|
||||
OVERALL: fail
|
||||
|
||||
accepted on the primary set, model situation vs label name:
|
||||
Y026 size 6 label 0 fit 1.00 model: Someone wants something from someone else, but there is | label:
|
||||
Y052 size 23 label 2 fit 1.00 model: Planning or attending a dinner with hidden intentions | label: an invitation to dine
|
||||
X002 size 10 label 1 fit 0.90 model: Someone seeks artistic validation or support from anoth | label: struggling artists and money
|
||||
Y010 size 18 label 1 fit 0.87 model: Artist seeking financial support or patronage | label: struggling artists and money
|
||||
Y053 size 45 label 1 fit 0.87 model: Conflict between individuals over personal issues | label: a brawl or showdown
|
||||
X015 size 38 label 0 fit 0.80 model: Seeking something from someone in a city setting | label:
|
||||
X030 size 18 label 1 fit 0.80 model: Confrontation between individuals or groups leading to | label: a brawl or showdown
|
||||
X055 size 21 label 2 fit 0.80 model: Marital conflict and tension | label: a spouse schemes against or confronts a spouse over infidelity
|
||||
Y008 size 5 label 0 fit 0.80 model: Someone holds power over someone else | label:
|
||||
Y011 size 15 label 0 fit 0.80 model: Someone confronts someone else over a personal issue | label:
|
||||
Y034 size 5 label 0 fit 0.80 model: conflict between perception and reality | label:
|
||||
X038 size 8 label 0 fit 0.75 model: Struggling to find acceptance or connection in a hostil | label:
|
||||
X048 size 20 label 1 fit 0.73 model: Financial transaction with potential conflict | label: money owed and repayment
|
||||
X058 size 16 label 0 fit 0.73 model: A host welcomes a visitor with mixed emotions and inten | label:
|
||||
Y017 size 45 label 1 fit 0.73 model: Invitation to dinner with hidden intentions | label: an invitation to dine
|
||||
Y073 size 36 label 1 fit 0.73 model: Someone demands information or action from another, who | label: writers and editors
|
||||
X013 size 7 label 1 fit 0.71 model: Seeking financial advice | label: what to do with money, and whose advice to take
|
||||
X024 size 7 label 0 fit 0.71 model: Demanding repayment or compensation | label:
|
||||
Y030 size 7 label 1 fit 0.71 model: Social interaction at a dining establishment | label: who holds sway in a dining room or hotel
|
||||
X020 size 24 label 0 fit 0.67 model: secretive interactions between individuals | label:
|
||||
X050 size 45 label 1 fit 0.67 model: Financial transactions involving coercion or manipulati | label: schemes, cons and theft over money
|
||||
X059 size 19 label 1 fit 0.67 model: Marriage proposals and preparations | label: a proposal or engagement, accepted or refused
|
||||
X068 size 6 label 2 fit 0.67 model: Marital conflict and confrontation | label: a spouse schemes against or confronts a spouse over infidelity
|
||||
X072 size 17 label 0 fit 0.67 model: Resentment over unreciprocated affection | label:
|
||||
Y031 size 6 label 0 fit 0.67 model: Older person mentoring younger person | label:
|
||||
Y036 size 6 label 0 fit 0.67 model: envy and desire for wealth or power | label:
|
||||
Y040 size 6 label 1 fit 0.67 model: Competing for social status through dining experiences | label: who holds sway in a dining room or hotel
|
||||
Y068 size 6 label 0 fit 0.67 model: Conflict between individuals with opposing goals | label:
|
||||
X010 size 11 label 0 fit 0.64 model: competition for status or position in a city | label:
|
||||
Y050 size 8 label 0 fit 0.62 model: Someone trying to win someone else's affection | label:
|
||||
Y063 size 8 label 0 fit 0.62 model: Power dynamics between individuals | label:
|
||||
X009 size 45 label 1 fit 0.60 model: conflict over marriage proposals and social expectation | label: a proposal or engagement, accepted or refused
|
||||
X012 size 10 label 0 fit 0.60 model: A person of high status seeking to help someone in need | label:
|
||||
X014 size 10 label 0 fit 0.60 model: Someone threatens someone else | label:
|
||||
X037 size 5 label 0 fit 0.60 model: conflict between individuals due to past relationships | label:
|
||||
X042 size 5 label 2 fit 0.60 model: Dispute over payment for a service | label: a public quarrel with a shopkeeper, driver or official
|
||||
X043 size 38 label 1 fit 0.60 model: Dinner party with social dynamics | label: who holds sway in a dining room or hotel
|
||||
X056 size 45 label 1 fit 0.60 model: Attempting to gain favor or influence through social in | label: trying to impress or win someone's favour
|
||||
X057 size 45 label 1 fit 0.60 model: Someone tries to manipulate someone else for personal g | label: schemes, cons and theft over money
|
||||
Y021 size 45 label 1 fit 0.60 model: Someone manipulates someone into a transaction for pers | label: schemes, cons and theft over money
|
||||
Y033 size 5 label 0 fit 0.60 model: Someone is suspected of a crime they did not commit | label:
|
||||
Y043 size 31 label 1 fit 0.60 model: Confrontation over a dispute or accusation | label: a run-in with the police
|
||||
@@ -0,0 +1,90 @@
|
||||
qwen2.5:3b-instruct: 250 labelled cluster rows; parse failures 1
|
||||
theta chosen on dev: 0.6
|
||||
dev FIT >= 0.3 accepted 22 >=1 15 label2 4 P1+ 0.68 recall 0.60
|
||||
dev FIT >= 0.4 accepted 20 >=1 13 label2 3 P1+ 0.65 recall 0.52
|
||||
dev FIT >= 0.5 accepted 16 >=1 11 label2 2 P1+ 0.69 recall 0.44
|
||||
dev FIT >= 0.6 accepted 9 >=1 8 label2 2 P1+ 0.89 recall 0.32
|
||||
dev FIT >= 0.7 accepted 6 >=1 5 label2 2 P1+ 0.83 recall 0.20
|
||||
dev FIT >= 0.8 accepted 4 >=1 4 label2 2 P1+ 1.00 recall 0.16
|
||||
dev FIT >= 0.9 accepted 2 >=1 2 label2 1 P1+ 1.00 recall 0.08
|
||||
dev FIT >= 1.0 accepted 1 >=1 1 label2 0 P1+ 1.00 recall 0.04
|
||||
|
||||
mean FIT by label (all rows):
|
||||
label 0: n=121 mean 0.39 median 0.40
|
||||
label 1: n=106 mean 0.49 median 0.50
|
||||
label 2: n= 23 mean 0.42 median 0.33
|
||||
|
||||
PRIMARY: validate + validate3: 148 clusters, 67 labelled >=1
|
||||
all accepted 148 >=1 67 label2 10 P1+ 0.45 recall 1.00
|
||||
G2 accepted 49 >=1 36 label2 5 P1+ 0.73 recall 0.54
|
||||
G4 FIT >= 0.6 accepted 50 >=1 24 label2 4 P1+ 0.48 recall 0.36
|
||||
G4 AND G2 accepted 18 >=1 11 label2 1 P1+ 0.61 recall 0.16
|
||||
G4 size <=9 accepted 13 >=1 3 label2 1 P1+ 0.23 recall 0.33 (of 48)
|
||||
G4 size 10-25 accepted 24 >=1 12 label2 3 P1+ 0.50 recall 0.39 (of 63)
|
||||
G4 size >=26 accepted 13 >=1 9 label2 0 P1+ 0.69 recall 0.33 (of 37)
|
||||
|
||||
secondary: test + recover: 62 clusters, 37 labelled >=1
|
||||
all accepted 62 >=1 37 label2 8 P1+ 0.60 recall 1.00
|
||||
G2 accepted 28 >=1 23 label2 5 P1+ 0.82 recall 0.62
|
||||
G4 FIT >= 0.6 accepted 21 >=1 15 label2 0 P1+ 0.71 recall 0.41
|
||||
G4 AND G2 accepted 14 >=1 12 label2 0 P1+ 0.86 recall 0.32
|
||||
G4 size <=9 accepted 1 >=1 0 label2 0 P1+ 0.00 recall 0.00 (of 8)
|
||||
G4 size 10-25 accepted 8 >=1 5 label2 0 P1+ 0.62 recall 0.25 (of 30)
|
||||
G4 size >=26 accepted 12 >=1 10 label2 0 P1+ 0.83 recall 0.59 (of 24)
|
||||
|
||||
criterion 1 (P1+ >= 0.80): fail (0.48)
|
||||
criterion 2 (more >=1 than G2): fail (24 vs 36)
|
||||
criterion 3 (<=9 members P1+ >= 0.67 if >= 3 accepted): fail (13 accepted, P1+ 0.23)
|
||||
OVERALL: fail
|
||||
|
||||
accepted on the primary set, model situation vs label name:
|
||||
X003 size 39 label 0 fit 1.00 model: demand and confrontation | label:
|
||||
X019 size 45 label 1 fit 1.00 model: tension between unequal parties | label: a public quarrel with a shopkeeper, driver or official
|
||||
X067 size 23 label 0 fit 1.00 model: power struggle/conflict over resources | label:
|
||||
Y006 size 45 label 1 fit 1.00 model: manipulation and exploitation | label: schemes, cons and theft over money
|
||||
Y008 size 5 label 0 fit 1.00 model: power struggle/conflict | label:
|
||||
Y017 size 45 label 1 fit 1.00 model: coercion and manipulation for personal gain | label: an invitation to dine
|
||||
Y032 size 13 label 1 fit 1.00 model: confrontation over a personal issue | label: a run-in with the police
|
||||
Y040 size 6 label 1 fit 1.00 model: people seek social status or attention | label: who holds sway in a dining room or hotel
|
||||
Y044 size 5 label 0 fit 1.00 model: dinner party | label:
|
||||
Y047 size 18 label 2 fit 1.00 model: competing for status or position | label: working girls: jobs, wages and standing
|
||||
Y053 size 45 label 1 fit 1.00 model: confrontation | label: a brawl or showdown
|
||||
X035 size 45 label 0 fit 0.93 model: Tension between parties | label:
|
||||
X043 size 38 label 1 fit 0.93 model: dinner party/hosting | label: who holds sway in a dining room or hotel
|
||||
Y071 size 21 label 1 fit 0.93 model: Negotiation and deal-making | label: schemes, cons and theft over money
|
||||
Y074 size 11 label 1 fit 0.91 model: courtship and social status | label: working girls: jobs, wages and standing
|
||||
Y019 size 10 label 0 fit 0.90 model: seeking novelty or adventure | label:
|
||||
Y063 size 8 label 0 fit 0.88 model: power dynamics in social hierarchies | label:
|
||||
Y052 size 23 label 2 fit 0.87 model: dinner plans and dynamics | label: an invitation to dine
|
||||
X028 size 6 label 0 fit 0.83 model: power dynamics in social hierarchies | label:
|
||||
X012 size 10 label 0 fit 0.80 model: people seeking or offering help or support | label:
|
||||
Y001 size 23 label 0 fit 0.80 model: Money disputes and transactions | label:
|
||||
Y066 size 17 label 0 fit 0.80 model: power dynamics in luxury vs. poverty | label:
|
||||
Y070 size 5 label 0 fit 0.80 model: people pursuing their livelihood or interests | label:
|
||||
Y002 size 9 label 0 fit 0.78 model: manipulation and hidden agendas | label:
|
||||
X011 size 43 label 1 fit 0.73 model: NEGOTIATION OVER RESOURCES OR SERVICES | label: drinking in the saloon
|
||||
X020 size 24 label 0 fit 0.73 model: people hiding or avoiding attention | label:
|
||||
X033 size 45 label 0 fit 0.73 model: negotiation and payment | label:
|
||||
X036 size 47 label 1 fit 0.73 model: reveal death to family | label: illness, loss and a missing relative
|
||||
X048 size 20 label 1 fit 0.73 model: financial transaction or dispute | label: money owed and repayment
|
||||
X049 size 25 label 0 fit 0.73 model: coercion or manipulation | label:
|
||||
X055 size 21 label 2 fit 0.73 model: marital conflict | label: a spouse schemes against or confronts a spouse over infidelity
|
||||
X058 size 16 label 0 fit 0.73 model: welcoming and revealing history | label:
|
||||
Y011 size 15 label 0 fit 0.73 model: conflict between individuals seeking different outcomes | label:
|
||||
Y022 size 24 label 1 fit 0.73 model: con or scam | label: schemes, cons and theft over money
|
||||
Y062 size 45 label 1 fit 0.73 model: Confrontation over a personal relationship | label: a spouse schemes against or confronts a spouse over infidelity
|
||||
X039 size 11 label 1 fit 0.73 model: Workplace conflict and exploitation | label: working girls: jobs, wages and standing
|
||||
X024 size 7 label 0 fit 0.71 model: NEGOCIACTION | label:
|
||||
Y030 size 7 label 1 fit 0.71 model: dining and socializing | label: who holds sway in a dining room or hotel
|
||||
X053 size 16 label 0 fit 0.67 model: exploit and exploitation | label:
|
||||
X057 size 45 label 1 fit 0.67 model: NEGOTIATION AND POWER PLAY | label: schemes, cons and theft over money
|
||||
X059 size 19 label 1 fit 0.67 model: Wedding elopement and deception | label: a proposal or engagement, accepted or refused
|
||||
X063 size 6 label 0 fit 0.67 model: confrontation | label:
|
||||
Y048 size 12 label 0 fit 0.67 model: seeking advice or guidance | label:
|
||||
Y055 size 6 label 0 fit 0.67 model: confrontation | label:
|
||||
Y020 size 11 label 0 fit 0.64 model: seeking connections or companionship | label:
|
||||
Y029 size 8 label 0 fit 0.62 model: dinner party with hidden agendas | label:
|
||||
X041 size 17 label 1 fit 0.60 model: manipulation and control | label: money and marriage: wealthy suitors, inheritances
|
||||
X042 size 5 label 2 fit 0.60 model: argue over payment | label: a public quarrel with a shopkeeper, driver or official
|
||||
X045 size 25 label 1 fit 0.60 model: seeking/stuck in job/searching employment | label: struggling artists and money
|
||||
X073 size 45 label 0 fit 0.60 model: coercion and manipulation | label:
|
||||
@@ -0,0 +1,42 @@
|
||||
method acc P1+ P2 #2 distinct dup names cover multi success
|
||||
B 4 1.00 0.00 0 4 0 13% 0 -
|
||||
M1 4 1.00 0.00 0 4 0 13% 0 fail
|
||||
M2 3 1.00 1.00 3 3 0 5% 0 fail
|
||||
M3 16 0.69 0.00 0 10 1 45% 68 fail
|
||||
|
||||
per method, accepted clusters:
|
||||
|
||||
B:
|
||||
R001 size 13 label 1 an inheritance or fortune and what it obliges
|
||||
R006 size 36 label 1 a spouse schemes against or confronts a spouse over infidelity
|
||||
R007 size 38 label 1 schemes, cons and theft over money
|
||||
R012 size 22 label 1 who holds sway in a dining room or hotel
|
||||
|
||||
M1:
|
||||
R001 size 13 label 1 an inheritance or fortune and what it obliges
|
||||
R006 size 36 label 1 a spouse schemes against or confronts a spouse over infidelity
|
||||
R007 size 38 label 1 schemes, cons and theft over money
|
||||
R012 size 22 label 1 who holds sway in a dining room or hotel
|
||||
|
||||
M2:
|
||||
R011 size 14 label 2 money and marriage: wealthy suitors, inheritances
|
||||
R015 size 15 label 2 a spouse schemes against or confronts a spouse over infidelity
|
||||
R020 size 17 label 2 someone coerces someone with a threat
|
||||
|
||||
M3:
|
||||
R002 size 30 label 0 -
|
||||
R003 size 30 label 1 someone coerces someone with a threat
|
||||
R004 size 28 label 1 a run-in with the police
|
||||
R005 size 30 label 0 -
|
||||
R006 size 36 label 1 a spouse schemes against or confronts a spouse over infidelity
|
||||
R007 size 38 label 1 schemes, cons and theft over money
|
||||
R008 size 30 label 1 courtship
|
||||
R009 size 30 label 1 coercion over money: threats, bribes, demands for payment
|
||||
R010 size 37 label 1 courtship
|
||||
R013 size 30 label 0 -
|
||||
R014 size 30 label 1 drinking in the saloon
|
||||
R016 size 17 label 0 -
|
||||
R017 size 30 label 1 money owed and repayment
|
||||
R018 size 39 label 1 trying to impress or win someone's favour
|
||||
R019 size 30 label 1 who holds sway in a dining room or hotel
|
||||
R021 size 30 label 0 -
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Score G4 (model-judged FIT) against the blind labels (SELECTION.md, "G4").
|
||||
|
||||
python3 evaluate_judge.py qwen2.5:3b-instruct
|
||||
"""
|
||||
import hashlib, json, pathlib, statistics, sys
|
||||
import gate2 as g
|
||||
|
||||
MODEL = sys.argv[1]
|
||||
judged = json.loads(pathlib.Path(f"judge-{MODEL.replace(':', '_').replace('/', '_')}.json").read_text())
|
||||
G2 = json.loads(pathlib.Path('gate2-frozen.json').read_text())
|
||||
|
||||
def key(members):
|
||||
return hashlib.sha1(','.join(map(str, sorted(members))).encode()).hexdigest()[:16]
|
||||
|
||||
sources = [('dev', 'candidates-dev.json', 'labels-dev.json', False), ('test', 'candidates-test.json', 'labels-test.json', False),
|
||||
('recover', 'recover-map.json', 'labels-recover.json', True), ('validate', 'validate-map.json', 'labels-validate.json', True),
|
||||
('validate3', 'validate3-map.json', 'labels-validate3.json', True)]
|
||||
rows = []
|
||||
for name, cfile, lfile, is_map in sources:
|
||||
cand = json.loads(pathlib.Path(cfile).read_text())
|
||||
lab = json.loads(pathlib.Path(lfile).read_text())
|
||||
for cid, v in cand.items():
|
||||
members = v['members'] if is_map else v
|
||||
j = judged.get(key(members))
|
||||
if j is None: continue
|
||||
sig = g.signals(members)
|
||||
rows.append({'set': name, 'id': cid, 'label': lab[cid]['label'], 'name': lab[cid]['name'],
|
||||
'size': len(members), 'fit': j['fit'], 'parsed': j['parsed'], 'situation': j['situation'],
|
||||
'g2': g.g2_passes(sig, G2)})
|
||||
|
||||
def score(sub, accept):
|
||||
acc = [r for r in sub if accept(r)]
|
||||
n1 = sum(r['label'] >= 1 for r in acc)
|
||||
return {'accepted': len(acc), 'label1plus': n1, 'label2': sum(r['label'] == 2 for r in acc),
|
||||
'P1+': n1 / len(acc) if acc else 0.0,
|
||||
'recall1+': n1 / max(1, sum(r['label'] >= 1 for r in sub))}
|
||||
|
||||
def line(label, s):
|
||||
return (f" {label:22} accepted {s['accepted']:3} >=1 {s['label1plus']:3} label2 {s['label2']:2} "
|
||||
f"P1+ {s['P1+']:.2f} recall {s['recall1+']:.2f}")
|
||||
|
||||
dev = [r for r in rows if r['set'] == 'dev']
|
||||
best = None
|
||||
for theta in (0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0):
|
||||
s = score(dev, lambda r: r['fit'] >= theta)
|
||||
if s['accepted'] == 0 or s['P1+'] < 0.85: continue
|
||||
k = (s['label1plus'], s['label2'], theta)
|
||||
if best is None or k > best[0]: best = (k, theta)
|
||||
theta = best[1] if best else None
|
||||
print(f'{MODEL}: {len(rows)} labelled cluster rows; parse failures {sum(not r["parsed"] for r in rows)}')
|
||||
print(f'theta chosen on dev: {theta}' + ('' if theta is not None else ' (no theta reaches P1+ >= 0.85 on dev)'))
|
||||
for t in (0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0):
|
||||
print(line(f'dev FIT >= {t}', score(dev, lambda r: r['fit'] >= t)))
|
||||
|
||||
print('\nmean FIT by label (all rows):')
|
||||
for lab in (0, 1, 2):
|
||||
f = [r['fit'] for r in rows if r['label'] == lab]
|
||||
print(f' label {lab}: n={len(f):3} mean {statistics.mean(f):.2f} median {statistics.median(f):.2f}')
|
||||
|
||||
if theta is not None:
|
||||
g4 = lambda r: r['fit'] >= theta
|
||||
for title, names in (('PRIMARY: validate + validate3', ('validate', 'validate3')), ('secondary: test + recover', ('test', 'recover'))):
|
||||
sub = [r for r in rows if r['set'] in names]
|
||||
print(f'\n{title}: {len(sub)} clusters, {sum(r["label"] >= 1 for r in sub)} labelled >=1')
|
||||
print(line('all', score(sub, lambda r: True)))
|
||||
print(line('G2', score(sub, lambda r: r['g2'])))
|
||||
print(line(f'G4 FIT >= {theta}', score(sub, g4)))
|
||||
print(line('G4 AND G2', score(sub, lambda r: g4(r) and r['g2'])))
|
||||
for band, lo, hi in (('<=9', 0, 9), ('10-25', 10, 25), ('>=26', 26, 999)):
|
||||
b = [r for r in sub if lo <= r['size'] <= hi]
|
||||
print(line(f' G4 size {band}', score(b, g4)) + f' (of {len(b)})')
|
||||
|
||||
prim = [r for r in rows if r['set'] in ('validate', 'validate3')]
|
||||
s4, s2 = score(prim, g4), score(prim, lambda r: r['g2'])
|
||||
small = score([r for r in prim if r['size'] <= 9], g4)
|
||||
c1 = s4['P1+'] >= 0.80
|
||||
c2 = s4['label1plus'] > s2['label1plus']
|
||||
c3 = small['accepted'] < 3 or small['P1+'] >= 0.67
|
||||
print(f'\ncriterion 1 (P1+ >= 0.80): {"PASS" if c1 else "fail"} ({s4["P1+"]:.2f})')
|
||||
print(f'criterion 2 (more >=1 than G2): {"PASS" if c2 else "fail"} ({s4["label1plus"]} vs {s2["label1plus"]})')
|
||||
print(f'criterion 3 (<=9 members P1+ >= 0.67 if >= 3 accepted): {"PASS" if c3 else "fail"} '
|
||||
f'({small["accepted"]} accepted, P1+ {small["P1+"]:.2f})')
|
||||
print(f'OVERALL: {"PASS" if c1 and c2 and c3 else "fail"}')
|
||||
|
||||
print('\naccepted on the primary set, model situation vs label name:')
|
||||
for r in sorted(prim, key=lambda r: -r['fit']):
|
||||
if g4(r):
|
||||
print(f" {r['id']} size {r['size']:2} label {r['label']} fit {r['fit']:.2f} model: {r['situation'][:55]:55} | label: {r['name']}")
|
||||
pathlib.Path(f"judge-result-{MODEL.replace(':', '_')}.json").write_text(json.dumps(
|
||||
{'theta': theta, 'primary_G4': s4, 'primary_G2': s2, 'small': small, 'criteria': [c1, c2, c3]}, indent=1))
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Score the recall-recovery methods against the blind labels (SELECTION.md, "Recovering recall").
|
||||
|
||||
python3 evaluate_recover.py
|
||||
"""
|
||||
import collections, json, pathlib
|
||||
|
||||
mapping = json.loads(pathlib.Path('recover-map.json').read_text())
|
||||
labels = json.loads(pathlib.Path('labels-recover.json').read_text())
|
||||
assert all(labels[r]['label'] in (0, 1, 2) for r in mapping), 'labels-recover.json is incomplete'
|
||||
N_SCENES = 838
|
||||
|
||||
stats = {}
|
||||
for method in ('B', 'M1', 'M2', 'M3'):
|
||||
ids = [r for r, e in mapping.items() if method in e['methods']]
|
||||
lab = [labels[r]['label'] for r in ids]
|
||||
names = [labels[r]['name'].strip().lower() for r in ids if labels[r]['label'] >= 1]
|
||||
scenes = collections.Counter(i for r in ids for i in mapping[r]['members'])
|
||||
stats[method] = {
|
||||
'accepted': len(ids),
|
||||
'precision1plus': sum(l >= 1 for l in lab) / len(ids) if ids else 0.0,
|
||||
'precision2': sum(l == 2 for l in lab) / len(ids) if ids else 0.0,
|
||||
'label2': sum(l == 2 for l in lab),
|
||||
'distinct_situations': len(set(names)),
|
||||
'repeated_names': sum(c - 1 for c in collections.Counter(names).values() if c > 1),
|
||||
'coverage': len(scenes) / N_SCENES,
|
||||
'scenes_in_several': sum(v > 1 for v in scenes.values()),
|
||||
'ids': ids,
|
||||
}
|
||||
|
||||
b = stats['B']
|
||||
print(f"{'method':6} {'acc':>4} {'P1+':>5} {'P2':>5} {'#2':>3} {'distinct':>8} {'dup names':>9} {'cover':>6} {'multi':>5} success")
|
||||
for m, s in stats.items():
|
||||
ok = (m != 'B' and s['accepted'] >= 2 * b['accepted'] and s['precision1plus'] >= 0.80
|
||||
and s['distinct_situations'] >= 2 * b['distinct_situations'])
|
||||
print(f"{m:6} {s['accepted']:4} {s['precision1plus']:5.2f} {s['precision2']:5.2f} {s['label2']:3} "
|
||||
f"{s['distinct_situations']:8} {s['repeated_names']:9} {s['coverage']:6.0%} {s['scenes_in_several']:5} "
|
||||
f"{'-' if m == 'B' else ('PASS' if ok else 'fail')}")
|
||||
|
||||
print('\nper method, accepted clusters:')
|
||||
for m, s in stats.items():
|
||||
print(f'\n{m}:')
|
||||
for r in s['ids']:
|
||||
e = mapping[r]
|
||||
print(f" {r} size {len(e['members']):2} label {labels[r]['label']} {labels[r]['name'] or '-'}")
|
||||
pathlib.Path('recover-result.json').write_text(json.dumps(
|
||||
{m: {k: v for k, v in s.items() if k != 'ids'} for m, s in stats.items()}, indent=1))
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"S1": 0.6036626536817667,
|
||||
"Z1": 5,
|
||||
"MMIN": null,
|
||||
"W": 0.6
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,182 @@
|
||||
"""G2, the size-invariant gate: signals, the old gate for comparison, and k-means. No inference.
|
||||
|
||||
Import it from the probe directory. On first import it builds and caches pairwise similarities
|
||||
(sim.pkl) and the same-size random baseline for Z1 (null.pkl).
|
||||
|
||||
S1 mean summary cosine over cross-story member pairs
|
||||
Z1 S1 as a z-score against random member sets of the same size
|
||||
MMIN lowest member-level mean cosine to members of other stories
|
||||
W largest share among words whose sharing is significant (hypergeometric, Bonferroni 0.01)
|
||||
"""
|
||||
import collections, json, math, pathlib, pickle, random, re
|
||||
|
||||
HERE = pathlib.Path(__file__).resolve().parent
|
||||
MIN_SIZE, MIN_STORIES, MAX_DOMINANT = 5, 4, 0.40
|
||||
|
||||
chunks = json.loads((HERE / 'chunks.json').read_text(encoding='utf-8'))
|
||||
_raw = json.loads((HERE / 'summary_embeddings.json').read_text())
|
||||
_vecs = _raw['vectors'] if isinstance(_raw, dict) else _raw
|
||||
n = min(len(chunks), len(_vecs))
|
||||
|
||||
def norm(v):
|
||||
m = math.sqrt(sum(x * x for x in v)) or 1.0
|
||||
return [x / m for x in v]
|
||||
|
||||
V = [norm(v) for v in _vecs[:n]]
|
||||
dim = len(V[0])
|
||||
story = [chunks[i]['story'] for i in range(n)]
|
||||
text = {x['chunk']: x['summary'] for x in
|
||||
json.loads((HERE / 'summaries_clean.json').read_text(encoding='utf-8'))['summaries']}
|
||||
|
||||
def dot(a, b):
|
||||
s = 0.0
|
||||
for x, y in zip(a, b): s += x * y
|
||||
return s
|
||||
|
||||
_sim_path = HERE / 'sim.pkl'
|
||||
if _sim_path.exists():
|
||||
SIM = pickle.loads(_sim_path.read_bytes())
|
||||
else:
|
||||
SIM = [[0.0] * n for _ in range(n)]
|
||||
for a in range(n):
|
||||
va, row = V[a], SIM[a]
|
||||
for b in range(a, n):
|
||||
s = dot(va, V[b])
|
||||
row[b] = s
|
||||
SIM[b][a] = s
|
||||
_sim_path.write_bytes(pickle.dumps(SIM))
|
||||
|
||||
# Identical to signals.py.
|
||||
STOP = set("""a an the and or but of to in on at for with by from as into over under about after before
|
||||
while during than then that this these those it its his her hers him he she they them their theirs we our
|
||||
you your i me my is are was were be been being has have had do does did not no nor so such who whom whose
|
||||
which what when where why how all any each both either neither one two some other others another more most
|
||||
less least very just only also even yet still now up down out off away back again once own same too can
|
||||
could will would shall should may might must upon despite toward towards between among amid amidst
|
||||
someone someone's someone’s""".split())
|
||||
docs = [set(re.findall(r"[a-z][a-z'’]+", text[i].lower())) - STOP for i in range(n)]
|
||||
df = collections.Counter(w for d in docs for w in d)
|
||||
ALPHA = 0.01 / len(df)
|
||||
|
||||
def describe(m):
|
||||
counts = collections.Counter(story[i] for i in m)
|
||||
return len(m), len(counts), max(counts.values()) / len(m)
|
||||
|
||||
def eligible(m):
|
||||
size, nst, dom = describe(m)
|
||||
return size >= MIN_SIZE and nst >= MIN_STORIES and dom <= MAX_DOMINANT
|
||||
|
||||
def cohesion(m):
|
||||
"""(S1, MMIN)."""
|
||||
tot, cnt, lowest = 0.0, 0, None
|
||||
for a in m:
|
||||
s, c, row = 0.0, 0, SIM[a]
|
||||
for b in m:
|
||||
if story[a] != story[b]:
|
||||
s += row[b]; c += 1
|
||||
if c:
|
||||
tot += s; cnt += c
|
||||
mean = s / c
|
||||
lowest = mean if lowest is None else min(lowest, mean)
|
||||
return (tot / cnt if cnt else 0.0), (lowest if lowest is not None else 0.0)
|
||||
|
||||
# Same-size random baseline for Z1.
|
||||
_null_path = HERE / 'null.pkl'
|
||||
if _null_path.exists():
|
||||
NULL = pickle.loads(_null_path.read_bytes())
|
||||
else:
|
||||
rnd, NULL = random.Random(2026), {}
|
||||
for m in range(5, 151):
|
||||
samples, need = [], 300 if m <= 40 else 120
|
||||
while len(samples) < need:
|
||||
members = rnd.sample(range(n), m)
|
||||
if eligible(members):
|
||||
samples.append(cohesion(members)[0])
|
||||
mu = sum(samples) / len(samples)
|
||||
sd = math.sqrt(sum((x - mu) ** 2 for x in samples) / (len(samples) - 1))
|
||||
NULL[m] = (mu, sd)
|
||||
_null_path.write_bytes(pickle.dumps(NULL))
|
||||
|
||||
def _lchoose(a, b):
|
||||
return math.lgamma(a + 1) - math.lgamma(b + 1) - math.lgamma(a - b + 1)
|
||||
|
||||
def hyper_sf(c, m, k, total):
|
||||
"""P(X >= c) for X ~ Hypergeometric(total, k successes, m draws)."""
|
||||
top = min(m, k)
|
||||
if c > top: return 0.0
|
||||
logs = [_lchoose(k, x) + _lchoose(total - k, m - x) - _lchoose(total, m)
|
||||
for x in range(c, top + 1) if m - x <= total - k]
|
||||
if not logs: return 0.0
|
||||
mx = max(logs)
|
||||
return math.exp(mx) * sum(math.exp(l - mx) for l in logs)
|
||||
|
||||
def word_dominance(m):
|
||||
"""(W, word): largest member share among significantly shared words."""
|
||||
size = len(m)
|
||||
cw = collections.Counter(w for i in m for w in docs[i])
|
||||
best = (0.0, '')
|
||||
for w, c in cw.items():
|
||||
if c < 2: continue
|
||||
if hyper_sf(c, size, df[w], n) <= ALPHA and c / size > best[0]:
|
||||
best = (c / size, w)
|
||||
return best
|
||||
|
||||
def old_s6(m):
|
||||
"""The first gate's S6, identical to signals.py."""
|
||||
size = len(m)
|
||||
cw = collections.Counter(w for i in m for w in docs[i])
|
||||
best = None
|
||||
for w, c in cw.items():
|
||||
if c < 2: continue
|
||||
score = math.log((c + .5) / (size - c + .5)) - math.log((df[w] + .5) / (n - df[w] + .5))
|
||||
if best is None or score > best[0]: best = (score, c)
|
||||
return best[1] / size if best else 0.0
|
||||
|
||||
def signals(m):
|
||||
size, nst, dom = describe(m)
|
||||
s1, mmin = cohesion(m)
|
||||
mu, sd = NULL[min(max(size, 5), 150)]
|
||||
w, word = word_dominance(m)
|
||||
return {'size': size, 'stories': nst, 'dominant': dom, 'S1': s1, 'Z1': (s1 - mu) / sd,
|
||||
'MMIN': mmin, 'W': w, 'W_word': word, 'S6_old': old_s6(m)}
|
||||
|
||||
OLD_GATE = json.loads((HERE / 'rule-frozen.json').read_text())
|
||||
_OLD = {s: t for s, _, t in OLD_GATE['terms']}
|
||||
|
||||
def old_passes(sig):
|
||||
return sig['size'] >= MIN_SIZE and sig['stories'] >= MIN_STORIES and sig['dominant'] <= MAX_DOMINANT \
|
||||
and sig['S1'] >= _OLD['S1'] and sig['S6_old'] <= _OLD['S6']
|
||||
|
||||
def g2_passes(sig, gate):
|
||||
if not (sig['size'] >= MIN_SIZE and sig['stories'] >= MIN_STORIES and sig['dominant'] <= MAX_DOMINANT):
|
||||
return False
|
||||
if gate.get('S1') is not None and sig['S1'] < gate['S1']: return False
|
||||
if gate.get('Z1') is not None and sig['Z1'] < gate['Z1']: return False
|
||||
if gate.get('MMIN') is not None and sig['MMIN'] < gate['MMIN']: return False
|
||||
if gate.get('W') is not None and sig['W'] >= gate['W']: return False
|
||||
return True
|
||||
|
||||
def kmeans(k, seed, iters=15):
|
||||
"""Identical to measure.py's k-means over the summary vectors."""
|
||||
rnd = random.Random(seed)
|
||||
cent = [V[i] for i in rnd.sample(range(n), k)]
|
||||
assign = [-1] * n
|
||||
for _ in range(iters):
|
||||
moved = 0
|
||||
for i, v in enumerate(V):
|
||||
best, bs = 0, -2.0
|
||||
for j, c in enumerate(cent):
|
||||
s = dot(v, c)
|
||||
if s > bs: bs, best = s, j
|
||||
if assign[i] != best: assign[i] = best; moved += 1
|
||||
if moved == 0: break
|
||||
groups = collections.defaultdict(list)
|
||||
for i, j in enumerate(assign): groups[j].append(i)
|
||||
for j, mem in groups.items():
|
||||
cent[j] = norm([sum(V[i][d] for i in mem) / len(mem) for d in range(dim)])
|
||||
groups = collections.defaultdict(list)
|
||||
for i, j in enumerate(assign): groups[j].append(i)
|
||||
return [sorted(g) for g in groups.values()]
|
||||
|
||||
def centroid(m):
|
||||
return norm([sum(V[i][d] for i in m) / len(m) for d in range(dim)])
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"S1": 0.6175176123715982,
|
||||
"PCT": 0.9,
|
||||
"MMIN": 0.5295953845017977,
|
||||
"W": 0.6
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,40 @@
|
||||
"""G3: S1 floor, percentile against the same-size null, mandatory member floor (MMIN), optional W.
|
||||
|
||||
Builds on gate2 (similarities, word test, k-means). On first import it samples the same-size null
|
||||
distribution of S1 and caches the sorted samples in null-samples.pkl.
|
||||
"""
|
||||
import bisect, pathlib, pickle, random
|
||||
import gate2 as g
|
||||
|
||||
HERE = pathlib.Path(__file__).resolve().parent
|
||||
_path = HERE / 'null-samples.pkl'
|
||||
if _path.exists():
|
||||
NULLS = pickle.loads(_path.read_bytes())
|
||||
else:
|
||||
rnd, NULLS = random.Random(3033), {}
|
||||
for m in range(5, 151):
|
||||
need, samples = (1000 if m <= 60 else 200), []
|
||||
while len(samples) < need:
|
||||
members = rnd.sample(range(g.n), m)
|
||||
if g.eligible(members):
|
||||
samples.append(g.cohesion(members)[0])
|
||||
NULLS[m] = sorted(samples)
|
||||
_path.write_bytes(pickle.dumps(NULLS))
|
||||
|
||||
def percentile(s1, size):
|
||||
samples = NULLS[min(max(size, 5), 150)]
|
||||
return bisect.bisect_left(samples, s1) / len(samples)
|
||||
|
||||
def signals(m):
|
||||
sig = g.signals(m) # size, stories, dominant, S1, Z1, MMIN, W, W_word, S6_old
|
||||
sig['PCT'] = percentile(sig['S1'], sig['size'])
|
||||
return sig
|
||||
|
||||
def passes(sig, gate):
|
||||
if not (sig['size'] >= g.MIN_SIZE and sig['stories'] >= g.MIN_STORIES and sig['dominant'] <= g.MAX_DOMINANT):
|
||||
return False
|
||||
if sig['S1'] < gate['S1'] or sig['PCT'] < gate['PCT'] or sig['MMIN'] < gate['MMIN']:
|
||||
return False
|
||||
if gate.get('W') is not None and sig['W'] >= gate['W']:
|
||||
return False
|
||||
return True
|
||||
@@ -0,0 +1,138 @@
|
||||
"""A reading page for regrouped predicaments: does each group read as one predicament?
|
||||
|
||||
cd v2 && python3 ../groups_page.py predicament_embeddings.json predicaments_clean.json > groups-k60-s24.html
|
||||
|
||||
Clusters the embeddings at k = 60, seed 24 (the seed the first review used, kept apart from the
|
||||
measurement seeds 11-13), keeps the candidate groups (at least 5 scenes, 4 stories, no story over 40%),
|
||||
and lists them most cohesive first. Each scene shows its predicament sentence, the scene's original
|
||||
summary underneath, and its story. The reviewer marks each group: one predicament, a few mixed, or not
|
||||
a predicament, with an optional name and note. Marks are kept in the browser and saved as a JSON file.
|
||||
|
||||
Naming a kept group ("A ___ wants ___ from ___") is left for the full review page, once groups read well.
|
||||
No inference.
|
||||
"""
|
||||
import collections, html, json, pathlib, re, sys
|
||||
from topic_words import TOPIC_WORDS
|
||||
|
||||
emb_path, text_path = sys.argv[1], sys.argv[2]
|
||||
K, SEED = 60, 24
|
||||
sys.argv = ['measure.py', emb_path]
|
||||
exec(pathlib.Path(__file__).with_name('measure.py').read_text(encoding='utf-8').split("print(f'{path.name}")[0])
|
||||
# now defined: chunks, V, n, story, kmeans, describe, MIN_SIZE, MIN_STORIES, MAX_DOMINANT
|
||||
|
||||
pred = {x['chunk']: x['summary'] for x in json.loads(pathlib.Path(text_path).read_text(encoding='utf-8'))['summaries']}
|
||||
orig = {x['chunk']: x['summary'] for x in
|
||||
json.loads(pathlib.Path('summaries_clean.json').read_text(encoding='utf-8'))['summaries']}
|
||||
|
||||
|
||||
def cross_story_cohesion(group):
|
||||
tot = cnt = 0
|
||||
for a in group:
|
||||
for b in group:
|
||||
if a < b and story[a] != story[b]:
|
||||
tot += sum(x * y for x, y in zip(V[a], V[b])); cnt += 1
|
||||
return tot / cnt if cnt else 0.0
|
||||
|
||||
|
||||
groups = []
|
||||
for g in kmeans(K, SEED):
|
||||
size, nst, dom, _ = describe(g)
|
||||
if size < MIN_SIZE or nst < MIN_STORIES or dom > MAX_DOMINANT:
|
||||
continue
|
||||
topics = collections.Counter(w for i in g for w in set(re.findall(r"[a-zé]+", orig.get(i, '').lower())) & TOPIC_WORDS)
|
||||
word, hits = topics.most_common(1)[0] if topics else ('-', 0)
|
||||
groups.append({'members': g, 'stories': nst, 'cohesion': cross_story_cohesion(g), 'topic': (word, hits / size)})
|
||||
groups.sort(key=lambda x: -x['cohesion'])
|
||||
run = f'{pathlib.Path(emb_path).stem}-k{K}-s{SEED}'
|
||||
|
||||
sections = []
|
||||
for gi, grp in enumerate(groups, 1):
|
||||
rows = ''.join(
|
||||
f'<li><span class="p">{html.escape(pred.get(i, ""))}</span>'
|
||||
f'<span class="o">{html.escape(orig.get(i, ""))}</span>'
|
||||
f'<span class="t">{html.escape(chunks[i]["title"].title())}</span></li>'
|
||||
for i in grp['members'])
|
||||
word, share = grp['topic']
|
||||
sections.append(f'''<section id="g{gi}" data-g="{gi}">
|
||||
<h2>Group {gi}</h2>
|
||||
<p class="meta">{len(grp["members"])} scenes from {grp["stories"]} stories · cohesion {grp["cohesion"]:.3f} ·
|
||||
most common job, relationship or place word in the original summaries: <b>{html.escape(word)}</b>, {share:.0%} of scenes</p>
|
||||
<ol>{rows}</ol>
|
||||
<div class="verdict">
|
||||
<label><input type="radio" name="v{gi}" value="one"> One predicament</label>
|
||||
<label><input type="radio" name="v{gi}" value="mixed"> Two or three mixed together</label>
|
||||
<label><input type="radio" name="v{gi}" value="none"> Not a predicament</label>
|
||||
<input type="text" class="name" data-g="{gi}" placeholder="Short name, if it is one (optional)" maxlength="80">
|
||||
<input type="text" class="note" data-g="{gi}" placeholder="Note (optional)" maxlength="300">
|
||||
</div></section>''')
|
||||
|
||||
index = ''.join(f'<a href="#g{i}" data-g="{i}">{i}</a>' for i in range(1, len(groups) + 1))
|
||||
data = json.dumps({'run': run, 'groups': [{'group': i, 'chunks': grp['members']} for i, grp in enumerate(groups, 1)]})
|
||||
|
||||
print(f'''<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Predicament groups</title>
|
||||
<style>
|
||||
:root{{--bg:#f7f6f2;--card:#fff;--ink:#222;--dim:#666;--line:#ddd;--one:#dff0e3;--mixed:#fdf1d6;--none:#f6dede;--accent:#2f6f4f}}
|
||||
@media (prefers-color-scheme:dark){{:root{{--bg:#1b1b1d;--card:#252528;--ink:#e6e6e6;--dim:#9a9a9a;--line:#3a3a3e;--one:#233d2a;--mixed:#443a1c;--none:#472828;--accent:#7cc39c}}}}
|
||||
body{{background:var(--bg);color:var(--ink);font:15px/1.5 system-ui,sans-serif;margin:0;padding:24px 16px}}
|
||||
main{{max-width:900px;margin:auto}} h1{{margin:0 0 6px}} .dim,.meta{{color:var(--dim)}} .meta{{font-size:13px;margin:0 0 8px}}
|
||||
#bar{{position:sticky;top:0;background:var(--bg);border-bottom:1px solid var(--line);padding:8px 0;z-index:1}}
|
||||
#index{{display:flex;flex-wrap:wrap;gap:4px;margin-top:6px}}
|
||||
#index a{{min-width:26px;text-align:center;font-size:12px;border:1px solid var(--line);border-radius:4px;color:var(--ink);text-decoration:none;padding:1px 3px}}
|
||||
#index a.one{{background:var(--one)}} #index a.mixed{{background:var(--mixed)}} #index a.none{{background:var(--none)}}
|
||||
button{{font:inherit;padding:4px 12px;border-radius:6px;border:1px solid var(--accent);background:var(--accent);color:var(--bg);cursor:pointer}}
|
||||
section{{background:var(--card);border:1px solid var(--line);border-radius:8px;padding:12px 16px;margin:16px 0}}
|
||||
section.one{{border-left:6px solid #3a9a63}} section.mixed{{border-left:6px solid #c9962b}} section.none{{border-left:6px solid #c45050}}
|
||||
h2{{margin:0 0 2px;font-size:18px}} ol{{padding-left:22px;margin:6px 0}} li{{margin:0 0 8px}}
|
||||
.p{{display:block}} .o{{display:block;font-size:13px;color:var(--dim)}} .t{{display:block;font-size:12px;color:var(--dim);font-style:italic}}
|
||||
.verdict{{display:flex;flex-wrap:wrap;gap:6px 14px;align-items:center;border-top:1px solid var(--line);padding-top:8px}}
|
||||
.verdict input[type=text]{{flex:1 1 220px;min-width:0;font:inherit;padding:4px 6px;border:1px solid var(--line);border-radius:4px;background:var(--bg);color:var(--ink)}}
|
||||
</style>
|
||||
<main>
|
||||
<h1>Predicament groups</h1>
|
||||
<p class="dim">Every scene was re-described as its main person's predicament and the scenes were regrouped by
|
||||
those sentences. Each line below is one scene: its predicament, the scene's original summary under it, and
|
||||
its story. Read a group and mark whether most of its scenes are <b>one predicament</b> (the same kind of
|
||||
trouble, and wanting the same kind of way out), <b>two or three mixed together</b>, or <b>not a
|
||||
predicament</b> (held together by wording, a topic, or nothing). Most cohesive groups first; the first ten
|
||||
or so are enough to judge. Marks save in this browser as you go; "Save marks" downloads them for the program.</p>
|
||||
<div id="bar"><span id="tally"></span> <button id="save">Save marks</button><div id="index">{index}</div></div>
|
||||
{"".join(sections)}
|
||||
</main>
|
||||
<script>
|
||||
const DATA={data.replace("</", "<\\\\/")};
|
||||
const KEY='stp-groups:'+DATA.run;
|
||||
let marks={{}}; try{{marks=JSON.parse(localStorage.getItem(KEY)||'{{}}')}}catch(e){{}}
|
||||
function mark(g){{return marks[g]||(marks[g]={{verdict:'',name:'',note:''}})}}
|
||||
function paint(){{
|
||||
const c={{one:0,mixed:0,none:0}};
|
||||
for(const {{group:g}} of DATA.groups){{
|
||||
const m=marks[g]||{{}}, v=m.verdict||'';
|
||||
if(v) c[v]++;
|
||||
document.getElementById('g'+g).className=v;
|
||||
document.querySelector(`#index a[data-g="${{g}}"]`).className=v;
|
||||
}}
|
||||
const done=c.one+c.mixed+c.none;
|
||||
document.getElementById('tally').textContent=`Marked ${{done}} of ${{DATA.groups.length}}: one predicament ${{c.one}}, mixed ${{c.mixed}}, not ${{c.none}}`;
|
||||
}}
|
||||
function store(){{try{{localStorage.setItem(KEY,JSON.stringify(marks))}}catch(e){{}} paint()}}
|
||||
for(const [g,m] of Object.entries(marks)){{
|
||||
const r=document.querySelector(`input[name="v${{g}}"][value="${{m.verdict}}"]`); if(r) r.checked=true;
|
||||
const n=document.querySelector(`.name[data-g="${{g}}"]`); if(n) n.value=m.name||'';
|
||||
const t=document.querySelector(`.note[data-g="${{g}}"]`); if(t) t.value=m.note||'';
|
||||
}}
|
||||
document.addEventListener('change',e=>{{
|
||||
if(e.target.type==='radio'){{mark(e.target.name.slice(1)).verdict=e.target.value; store()}}
|
||||
}});
|
||||
document.addEventListener('input',e=>{{
|
||||
const g=e.target.dataset.g; if(!g) return;
|
||||
mark(g)[e.target.classList.contains('name')?'name':'note']=e.target.value; store();
|
||||
}});
|
||||
document.getElementById('save').onclick=()=>{{
|
||||
const out={{run:DATA.run,saved:new Date().toISOString(),groups:DATA.groups.map(x=>({{...x,...(marks[x.group]||{{verdict:'',name:'',note:''}})}}))}};
|
||||
const a=document.createElement('a');
|
||||
a.href=URL.createObjectURL(new Blob([JSON.stringify(out,null,1)],{{type:'application/json'}}));
|
||||
a.download=`groups-marks-${{DATA.run}}.json`; a.click(); URL.revokeObjectURL(a.href);
|
||||
}};
|
||||
paint();
|
||||
</script>''')
|
||||
@@ -0,0 +1,43 @@
|
||||
# Jacobs reference set: *The Lady of the Barge* (1902)
|
||||
|
||||
A hand-made comparison baseline for the story-to-pack pipeline, the Jacobs twin of the O. Henry
|
||||
reference in `../v2/claude/`. Every summary, predicament, group and option here was written by
|
||||
Claude reading the scenes. **No local inference was used.** Compare the local-model runs against it.
|
||||
Do not treat it as ground truth: it is one careful reading.
|
||||
|
||||
| File | What it is |
|
||||
| --- | --- |
|
||||
| `corpus/lady-of-the-barge.html` | Standard Ebooks single-page text (public domain) |
|
||||
| `split_se.py` | → `stories.json` (12 stories, split on `<h2>`) and `chunks.json` (157 scenes, same 320-word paragraph rule as `../chunk.py`) |
|
||||
| `dump.py` | `python3 dump.py A B` prints scenes A..B for reading |
|
||||
| `claude/batches/sNN.json` | per story: `{chunk, story, summary, predicament}`, the same schema as `../v2/claude/batches/` |
|
||||
| `claude/groups_src.py` | the groups, options and per-story protagonist standing |
|
||||
| `claude/build.py` | checks everything, then writes `claude/records.json` and `claude/groups.json` |
|
||||
| `claude/page.py` | → `claude/REFERENCE.html`, the readable page |
|
||||
|
||||
Rebuild: `python3 split_se.py && cd claude && python3 build.py && python3 page.py`.
|
||||
|
||||
## What is in it
|
||||
|
||||
- **157 predicaments**: one sentence each (median 19 words, max 24), written to the `redescribe.py`
|
||||
rules: no names, jobs, places or story-only objects. The `topic_words` leak check flags 23 of 157.
|
||||
All 23 are role words (rival, partner, guest, neighbour, suitor, newcomer), kept on purpose because
|
||||
the design's cast is roles.
|
||||
- **19 groups**, each a situation that recurs in **two or more stories**. Each scene sits in at most
|
||||
one group: 137 are assigned and 20 fit nothing that recurs.
|
||||
- **Options**: 3 to 5 per group, 79 in all. 61 come from what someone in a scene did (`scene:N`),
|
||||
10 are archetypes and 8 fill gaps. **8 scene options cite a scene outside their own group.** That
|
||||
is looser than DESIGN.md's "drawn from other scenes in the cluster", and `groups.json` marks each
|
||||
one `in_group: false`.
|
||||
- **Moves**: spine paths with a direction only (`+`, `-`, `risk`, `set`). There are no magnitudes,
|
||||
as the design requires. Skill labels proposed for this corpus: `nerve` and `cunning`.
|
||||
- **Stage** comes from protagonist standing, judged **per story, not per scene**. That is the
|
||||
coarsest judgement in the set, recorded in `STANDING` in `groups_src.py`.
|
||||
|
||||
## How this corpus differs from O. Henry
|
||||
|
||||
It is small, 12 stories against 97. It also mixes registers: comic waterfront and village yarns,
|
||||
plus four horror or crime stories (*The Monkey's Paw*, *The Well*, *In the Library*,
|
||||
*Captain Rogers*). So several groups straddle the two registers. G09 "Your scheme turns on you"
|
||||
holds both a comic fake-drowning and a murderer caught out. A tool that groups by register or
|
||||
mood, rather than by predicament, should split them.
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,86 @@
|
||||
[
|
||||
{
|
||||
"chunk": 0,
|
||||
"story": 0,
|
||||
"summary": "A skipper waiting up for his late-returning mate and brother-in-law has just lost a shouted argument with a neighbouring crew; the mate arrives and answers his rebuke with cheek.",
|
||||
"predicament": "Someone waiting up for a relative who comes in late wants to reproach him, and gets cheek back."
|
||||
},
|
||||
{
|
||||
"chunk": 1,
|
||||
"story": 0,
|
||||
"summary": "The mate tells the skipper he has invited his sweetheart on the voyage without asking; the skipper is appalled and asks what his wife will say.",
|
||||
"predicament": "A man learns a relative has promised a stranger his hospitality without asking, and fears what his spouse will say."
|
||||
},
|
||||
{
|
||||
"chunk": 2,
|
||||
"story": 0,
|
||||
"summary": "The skipper tries to refuse the young woman passage but cannot bring himself to say so, and ends up eating supper with the couple.",
|
||||
"predicament": "A man who wants to turn an unwelcome guest away cannot find the nerve to say it."
|
||||
},
|
||||
{
|
||||
"chunk": 3,
|
||||
"story": 0,
|
||||
"summary": "The girl balks at her cramped quarters but stays; the skipper warns that his wife will blame him, and the mate says his sister can mind her own business.",
|
||||
"predicament": "A man who has let something happen under his roof fears his spouse will hold him responsible for it."
|
||||
},
|
||||
{
|
||||
"chunk": 4,
|
||||
"story": 0,
|
||||
"summary": "The skipper insists on chaperoning the girl at meals himself, leaving the mate to steer and eat cold leftovers while she laughs at the skipper's stories.",
|
||||
"predicament": "A young man who brought the person he is courting along is pushed aside by an older man who takes charge of her."
|
||||
},
|
||||
{
|
||||
"chunk": 5,
|
||||
"story": 0,
|
||||
"summary": "Jealous, the mate hints at the skipper's wife, is mocked by the pair, is rude to the girl, and is sent away.",
|
||||
"predicament": "A jealous suitor tries to put his rival in his place, and only makes himself look childish."
|
||||
},
|
||||
{
|
||||
"chunk": 6,
|
||||
"story": 0,
|
||||
"summary": "The girl snubs the mate as 'only the mate'; he turns the tables by threatening to let the skipper explain her presence to his wife alone.",
|
||||
"predicament": "A spurned man realises he holds something over the rival who humiliated him, and decides to use it."
|
||||
},
|
||||
{
|
||||
"chunk": 7,
|
||||
"story": 0,
|
||||
"summary": "The mate refuses to back the skipper's story; the skipper hides the girl below as they arrive to find his wife waiting on the quay.",
|
||||
"predicament": "A man hiding an innocent but compromising secret is met by his spouse, while the one person who could clear him refuses to."
|
||||
},
|
||||
{
|
||||
"chunk": 8,
|
||||
"story": 0,
|
||||
"summary": "The mate pulls meaningful faces behind the skipper's back until the wife grows suspicious; the skipper invents a toothache and tries to steer her away.",
|
||||
"predicament": "A man tries to hurry his spouse away from a secret while someone signals behind his back that something is up."
|
||||
},
|
||||
{
|
||||
"chunk": 9,
|
||||
"story": 0,
|
||||
"summary": "The wife stamps her foot, the hidden girl appears, and the mate lets his brother-in-law take the blame while pretending to shield him.",
|
||||
"predicament": "A man caught with an innocent secret is disowned by the one person who could explain it."
|
||||
},
|
||||
{
|
||||
"chunk": 10,
|
||||
"story": 0,
|
||||
"summary": "The wife abuses the girl, knocks her purse into the water, and orders her off; the mate, now remorseful, takes responsibility and defends her.",
|
||||
"predicament": "A man whose spite has got an innocent person abused must decide whether to own up and defend her."
|
||||
},
|
||||
{
|
||||
"chunk": 11,
|
||||
"story": 0,
|
||||
"summary": "The mate dives repeatedly for the purse to win the girl back; she begs him to come out but refuses to make up.",
|
||||
"predicament": "A man tries to win back someone he wronged with a dangerous public show of devotion."
|
||||
},
|
||||
{
|
||||
"chunk": 12,
|
||||
"story": 0,
|
||||
"summary": "The mate fakes drowning; the skipper rescues him, sees him wink while the girl kisses him, and the wife boxes his ears.",
|
||||
"predicament": "A man pretends to be in danger to win sympathy, and the one who saved him sees through it."
|
||||
},
|
||||
{
|
||||
"chunk": 13,
|
||||
"story": 0,
|
||||
"summary": "The wife takes the girl home to breakfast, leaving both men soaked, while the skipper fends off the mate's apologies with a boathook.",
|
||||
"predicament": "Two men who quarrelled over a woman are both left out in the cold by the women they wronged."
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,15 @@
|
||||
[
|
||||
{"chunk":14,"story":1,"summary":"An old man losing at chess to his son grumbles at the weather and their remote house while waiting for a guest, who arrives: an old friend back from long service abroad.","predicament":"A man losing a friendly contest tries to distract from his mistake, and is caught at it by his family."},
|
||||
{"chunk":15,"story":1,"summary":"Warmed by whisky, the guest tells tales of distant lands; pressed by his host, he reluctantly produces a dried monkey's paw he calls magic.","predicament":"A guest who wants to leave a dangerous subject alone is coaxed by his hosts' curiosity into bringing it out."},
|
||||
{"chunk":16,"story":1,"summary":"The guest explains the paw grants three men three wishes each, that he has had his, and that the last owner wished for death; asked if he would wish again, he wavers.","predicament":"Someone offered a thing that grants wishes is warned by the man who used it that it brings sorrow."},
|
||||
{"chunk":17,"story":1,"summary":"The guest throws the paw on the fire; the host snatches it back and keeps it despite repeated warnings to wish sensibly or not at all.","predicament":"A man is offered a dangerous gift by someone who begs him to destroy it, and wants to keep it anyway."},
|
||||
{"chunk":18,"story":1,"summary":"Egged on by his joking son, the old man wishes for two hundred pounds to clear the house; the paw twists in his hand, but no money appears.","predicament":"A man half-believing in a promise of easy money must decide whether to test it and what to ask for."},
|
||||
{"chunk":19,"story":1,"summary":"An uneasy night passes; in the morning the family laugh off their fears, though the father recalls that the wishes come true in ways that look like coincidence.","predicament":"A family that tempted fate laughs off their fears in daylight while one of them cannot shake them."},
|
||||
{"chunk":20,"story":1,"summary":"The son leaves for work; later the mother, half-hoping for money, sees a well-dressed stranger hesitate several times at the gate before coming in.","predicament":"A family waiting on a hoped-for windfall sees a stranger approach who might bring it, or bad news."},
|
||||
{"chunk":21,"story":1,"summary":"The stranger, from the son's employers, tells the parents their only son has been killed in the machinery.","predicament":"Parents receive news that their only child has been killed, from someone who cannot bring himself to say it."},
|
||||
{"chunk":22,"story":1,"summary":"The firm denies liability but offers compensation of exactly two hundred pounds; the father collapses. They bury their son and sink into silent grief.","predicament":"A man learns the money he wished for has come as compensation for his child's death."},
|
||||
{"chunk":23,"story":1,"summary":"A week later the grieving mother remembers the paw's other two wishes and demands that her husband wish their son alive; he calls it madness.","predicament":"A grieving woman seizes on a desperate chance to undo a death, and her spouse is afraid of what it would bring back."},
|
||||
{"chunk":24,"story":1,"summary":"The father tells her their son was mutilated beyond recognition, but she insists; terrified, he fetches the paw and makes the second wish.","predicament":"A man pressed by his grieving spouse must decide whether to do something he believes is wicked and dangerous."},
|
||||
{"chunk":25,"story":1,"summary":"Nothing seems to happen; then a stealthy knock comes at the door, and the mother cries that it is their son while the father holds her back.","predicament":"A man who dreads what his wish may bring home must stop his spouse from opening the door to it."},
|
||||
{"chunk":26,"story":1,"summary":"The mother breaks free and struggles with the bolt; the father finds the paw and makes the third wish, and the door opens on an empty road.","predicament":"A man must use his last chance to undo what his spouse most longs for, before she lets it in."}
|
||||
]
|
||||
@@ -0,0 +1,16 @@
|
||||
[
|
||||
{"chunk":27,"story":2,"summary":"An old watchman recalls a shipmate, an aged miser worth six hundred pounds, who fell ill a month out on the voyage home.","predicament":"A man who has hoarded his savings all his life falls ill far from anywhere he could spend them."},
|
||||
{"chunk":28,"story":2,"summary":"The dying miser refuses to believe he is dying and leave his money; his shipmates circle hopefully, and one finds a money belt on him and guesses his plan to be buried with it.","predicament":"A dying miser, surrounded by people hoping to inherit, is determined that nobody will get his money."},
|
||||
{"chunk":29,"story":2,"summary":"The miser dies boasting that he had a boy throw his banknotes overboard; one sailor scares the boy into silence, and the narrator threatens to tell unless let in on the scheme.","predicament":"Someone who spots a colleague quietly scheming to take unclaimed money must decide whether to expose him or join in."},
|
||||
{"chunk":30,"story":2,"summary":"The schemer reveals he thinks the boy kept the money; the two agree to split it, and search the boy's bunk and pockets at night without success.","predicament":"Two partners convinced a weaker person is secretly holding a fortune want to find it before anyone else learns of it."},
|
||||
{"chunk":31,"story":2,"summary":"A night search is foiled when a ticklish shipmate wakes screaming; a week of prodding the boy finds nothing, so they corner him and demand the money outright.","predicament":"A schemer whose secret searches keep failing decides to confront the person he suspects directly."},
|
||||
{"chunk":32,"story":2,"summary":"The boy insists he threw the money overboard; they search him, offer him a third, then threaten to have him searched ashore and jailed.","predicament":"Someone suspected of hiding a windfall is pressed with bribes and threats by people who have no right to it."},
|
||||
{"chunk":33,"story":2,"summary":"When the sailor heads to tell the captain, the boy gives in: he kept the money but, frightened by the searches, hid it in an officer's mattress where he cannot now reach it.","predicament":"Someone forced to share a hidden windfall admits it is now stuck where neither partner can get at it."},
|
||||
{"chunk":34,"story":2,"summary":"Neither the man nor the boy can get aft unseen, the officer sleeps on the money every night, and the two partners blame each other.","predicament":"Distrustful partners stare at a fortune they cannot reach without getting caught, and blame each other for it."},
|
||||
{"chunk":35,"story":2,"summary":"The boy proposes the man fake an accident to get quartered aft; the man refuses and secretly flatters the officer, asking to buy his mattress as a keepsake.","predicament":"A man wants something a superior owns without revealing why, and tries flattery to get it."},
|
||||
{"chunk":36,"story":2,"summary":"The officer plays up the mattress's value and the sailor, afraid to seem eager, settles at two pounds, to be handed over ashore.","predicament":"A buyer who cannot reveal why he wants something is made to pay more by a seller who senses his eagerness."},
|
||||
{"chunk":37,"story":2,"summary":"The sailor now plans to cut the boy out and split with the narrator alone, but the suspicious boy shadows him all the way into port.","predicament":"A man who wants to cheat his partner out of a shared prize is watched too closely to slip away."},
|
||||
{"chunk":38,"story":2,"summary":"The officer delays the hand-over with a long speech until the boy arrives, sees the sack, and refuses to let them leave without his share.","predicament":"A man trying to slip away with a shared prize is held up until the partner he meant to cheat catches him."},
|
||||
{"chunk":39,"story":2,"summary":"The boy stays on deck while the men cut the mattress open below; it is empty, and when they come up the boy has vanished.","predicament":"A man who schemed to cheat a partner finds the prize gone and realises he may have been cheated himself."},
|
||||
{"chunk":40,"story":2,"summary":"The boy never returns, not even for his pay, and the officer smilingly asks the sailor how he slept.","predicament":"A schemer who was outwitted must stand and smile at the people who got the better of him."}
|
||||
]
|
||||
@@ -0,0 +1,16 @@
|
||||
[
|
||||
{"chunk":41,"story":3,"summary":"A rich man soon to marry sits with his idle, indebted cousin, who needles him about his wealth and his fiancée's goodness.","predicament":"A man about to marry is needled by a dependent relative who hints at something to his discredit."},
|
||||
{"chunk":42,"story":3,"summary":"The cousin hints that the fiancée would drop him if she knew what he really was, then says he needs fifteen hundred pounds within a fortnight or faces prison.","predicament":"A man in desperate debt makes veiled hints to the relative he hopes will pay it."},
|
||||
{"chunk":43,"story":3,"summary":"Refused, the cousin offers to sell compromising old letters, threatening to sell them to a rival suitor; the rich man seizes him and is stopped only by a servant entering.","predicament":"A man is blackmailed by a relative with old letters that could cost him the person he loves."},
|
||||
{"chunk":44,"story":3,"summary":"The cousin walks out into the park to let him think it over; the rich man follows, and later tells his mother the cousin has gone for good after a quarrel over money.","predicament":"A man who has done something terrible in anger must cover for a relative's sudden disappearance."},
|
||||
{"chunk":45,"story":3,"summary":"The man walks with his fiancée to a disused well in the park, her favourite spot; he urges her not to sit on its edge.","predicament":"A man with a guilty secret must keep calm while someone he loves insists on lingering right at the spot."},
|
||||
{"chunk":46,"story":3,"summary":"She asks about the missing cousin and defends him; the man bursts out that the cousin was a blackmailer, a liar and a cur.","predicament":"A man hiding a crime hears it discussed innocently and betrays his feelings with an outburst."},
|
||||
{"chunk":47,"story":3,"summary":"She is startled by a whisper she imagines saying 'Jem, help me out'; shaken, he tries to take her indoors, but she insists on staying.","predicament":"A guilty man tries to hurry someone away from the scene of his crime, and she insists on staying."},
|
||||
{"chunk":48,"story":3,"summary":"She drops her mother's diamond bracelet down the well and wants it drained; he looks at her in horror, then covers it by saying he feared she was falling.","predicament":"A man hiding something learns it may be discovered by an innocent accident, and must not show his fear."},
|
||||
{"chunk":49,"story":3,"summary":"He promises to recover the bracelet himself by morning and makes her swear to tell no one; his mother suggests filling the well in.","predicament":"A man must retrieve something from where his secret lies before anyone else goes looking."},
|
||||
{"chunk":50,"story":3,"summary":"At night he secretly fishes the well with a line and hook, and dredges up keys instead of the bracelet.","predicament":"A man secretly searching where his crime lies keeps pulling up reminders of it."},
|
||||
{"chunk":51,"story":3,"summary":"The hook snags; he gives up, spends a sleepless night, and at dawn meets his grooms, whom he has ordered to lower him into the well.","predicament":"A man who has failed to fix things quietly must now involve others while still keeping his secret."},
|
||||
{"chunk":52,"story":3,"summary":"His groom offers to go down instead, since the master looks ill; he refuses curtly and starts to test the air with a candle.","predicament":"A man hiding something is offered help he cannot accept without being found out."},
|
||||
{"chunk":53,"story":3,"summary":"The candle is lost; he stops the groom fetching another, strips, and sits a long time on the well's edge before going down.","predicament":"A man who must face the thing he did alone gathers his nerve while others watch and wonder."},
|
||||
{"chunk":54,"story":3,"summary":"Below, something drags at the rope; the grooms haul up the dead cousin's face with their master behind it, let go in horror, and both fall back into the water.","predicament":"A man who hid a crime is dragged down by it at the moment he tries to undo its consequences."}
|
||||
]
|
||||
@@ -0,0 +1,74 @@
|
||||
[
|
||||
{
|
||||
"chunk": 55,
|
||||
"story": 4,
|
||||
"summary": "A farmer's wife, her husband, and her retired-policeman uncle discuss a stolen brooch; the uncle has been hiding under their bed hoping to catch the thief, and the wife casts suspicion on a neighbour while accusing her husband of not caring.",
|
||||
"predicament": "A woman who has lost something valuable suspects the people around her, and resents her spouse for not caring as much."
|
||||
},
|
||||
{
|
||||
"chunk": 56,
|
||||
"story": 4,
|
||||
"summary": "The uncle declares everyone guilty until proven innocent; the wife recounts missing the brooch, accusing and sacking her servant, who cried on her box.",
|
||||
"predicament": "Someone who has lost something accuses the most defenceless person nearby, and punishes her before any proof."
|
||||
},
|
||||
{
|
||||
"chunk": 57,
|
||||
"story": 4,
|
||||
"summary": "The servant's box held nothing; the husband defends her and is snapped at, while the uncle, pencil and notebook out, pursues the investigation with great ceremony.",
|
||||
"predicament": "A man who thinks a household fuss is foolish keeps defending the accused and is scolded for taking her side."
|
||||
},
|
||||
{
|
||||
"chunk": 58,
|
||||
"story": 4,
|
||||
"summary": "The husband eats a lozenge from the evidence box and cannot stop laughing; the uncle establishes that no one could have entered the bedroom unheard.",
|
||||
"predicament": "A man who finds a family inquiry ridiculous cannot keep a straight face in front of its self-important leader."
|
||||
},
|
||||
{
|
||||
"chunk": 59,
|
||||
"story": 4,
|
||||
"summary": "Suspicion turns to a widow who visited that morning; the uncle finds muddy footprints on the stairs, which are in fact the husband's, and he stays guiltily quiet.",
|
||||
"predicament": "A man who sees an innocent neighbour blamed for evidence he himself left must decide whether to own up."
|
||||
},
|
||||
{
|
||||
"chunk": 60,
|
||||
"story": 4,
|
||||
"summary": "The husband slyly deflects the footprint evidence, then exchanges silent signals with his wife until the uncle demands to know what is funny.",
|
||||
"predicament": "A man who knows more than he says can barely hide his amusement from the self-important person running the inquiry."
|
||||
},
|
||||
{
|
||||
"chunk": 61,
|
||||
"story": 4,
|
||||
"summary": "The husband reveals he thinks the widow is after the uncle, not the brooch; the uncle is outraged, then proposes inviting her to tea to trap her into confessing.",
|
||||
"predicament": "A self-important man, teased that a neighbour fancies him, resolves to prove his point by trapping her."
|
||||
},
|
||||
{
|
||||
"chunk": 62,
|
||||
"story": 4,
|
||||
"summary": "The husband refuses to have a guest trapped in his house; the uncle secretly tells his niece he will search the widow's cottage without leave.",
|
||||
"predicament": "A man refuses to let his home be used to entrap a friend, so the schemer plans to go behind his back."
|
||||
},
|
||||
{
|
||||
"chunk": 63,
|
||||
"story": 4,
|
||||
"summary": "The husband, who resents the uncle living with them for his wife's hopes of an inheritance, plans to let the widow catch the uncle searching her house, and shadows him.",
|
||||
"predicament": "A man stuck with an unwelcome relative in his home sees a chance to have him disgraced and driven off."
|
||||
},
|
||||
{
|
||||
"chunk": 64,
|
||||
"story": 4,
|
||||
"summary": "The husband brings the widow home early; the uncle, trapped inside, hides in the larder, and the husband settles in for beer and begins praising him.",
|
||||
"predicament": "A man who has trapped his rival in an embarrassing hiding place decides to make the most of it."
|
||||
},
|
||||
{
|
||||
"chunk": 65,
|
||||
"story": 4,
|
||||
"summary": "Within the uncle's hearing, the husband tells the widow the uncle is shy and devoted to her, and has been asking whether she likes jewellery.",
|
||||
"predicament": "A trapped man must listen in silence while someone tells a neighbour a story about him that he cannot deny."
|
||||
},
|
||||
{
|
||||
"chunk": 66,
|
||||
"story": 4,
|
||||
"summary": "Asked for bread and cheese, the widow opens the larder, the uncle bursts out, and her friend takes his hiding there as a bashful suitor's courtship.",
|
||||
"predicament": "A man caught somewhere he should not be is handed an embarrassing explanation, and has no better one to offer."
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,15 @@
|
||||
[
|
||||
{"chunk":67,"story":5,"summary":"Late at night one co-owner of a house and a business sits hating his partner, who has discovered his embezzlement and ordered him to leave both by morning.","predicament":"A man found out in wrongdoing is ordered by his partner to give up everything and leave by morning."},
|
||||
{"chunk":68,"story":5,"summary":"The honest partner refuses to cover the theft quietly, offers the exile two hundred pounds, and mentions the servants are away until morning.","predicament":"A man offered a harsh but fair way out of his disgrace wants a quieter bargain the other will not give."},
|
||||
{"chunk":69,"story":5,"summary":"The exile demands the matter be hushed up, draws a sword from the wall, and stabs his partner when he refuses.","predicament":"A man facing ruin threatens the only person who can expose him, and must choose whether to go through with it."},
|
||||
{"chunk":70,"story":5,"summary":"Sickened, the killer turns out the lights, flees to the gate, and sees a constable coming up the road.","predicament":"A man who has just done something terrible in rage panics and must steady himself when a figure of the law appears."},
|
||||
{"chunk":71,"story":5,"summary":"He bids the constable good night, returns inside, and gathers his cash and valuables, reckoning he has eight hours' start.","predicament":"A man with a crime behind him and a few hours' start must decide what to take and where to run."},
|
||||
{"chunk":72,"story":5,"summary":"He plans his escape, weighing a crowded district against the seaside, and finds every choice may mean life or death.","predicament":"A fugitive planning his escape finds that every choice might be the one that gets him caught."},
|
||||
{"chunk":73,"story":5,"summary":"At one o'clock he hears creaks on the stairs, and fears the dead man is moving about the house.","predicament":"A man alone with what he has done hears noises in the dark and fears it has come back for him."},
|
||||
{"chunk":74,"story":5,"summary":"Finding the library door open, he locks someone inside, then realises it is a burglar he can blame for the killing.","predicament":"A guilty man realises a stranger's blunder gives him someone else to blame."},
|
||||
{"chunk":75,"story":5,"summary":"He fires through the door, raises the alarm, and the police break in and seize the terrified intruder.","predicament":"A man framing an innocent person must act the outraged victim in front of the law."},
|
||||
{"chunk":76,"story":5,"summary":"The intruder swears the body was there when he arrived; the sergeant uncovers it, and the killer feigns horror.","predicament":"A man must play the shocked innocent while the person he framed pleads the truth."},
|
||||
{"chunk":77,"story":5,"summary":"The killer names his friend and accuses the intruder, who accuses him back; the sergeant seems to side with the killer.","predicament":"Someone falsely accused points to the real culprit, who must hold his nerve and keep up his story."},
|
||||
{"chunk":78,"story":5,"summary":"The sergeant takes the pistol, handcuffs the killer without warning, and sends for a doctor, since the victim is not dead.","predicament":"A man who thought he had got away with it learns the one witness who could hang him is still alive."},
|
||||
{"chunk":79,"story":5,"summary":"The dying partner opens his eyes and speaks; the sergeant takes notes and leads the killer away.","predicament":"A man whose deceit has collapsed can only watch as the truth comes out and he is taken away."}
|
||||
]
|
||||
@@ -0,0 +1,86 @@
|
||||
[
|
||||
{
|
||||
"chunk": 80,
|
||||
"story": 6,
|
||||
"summary": "A ragged stranger swaggers into the best inn in a small town, bullies the waiter who tries to turn him out, and demands to see the landlord.",
|
||||
"predicament": "A penniless newcomer forces his way into a place he does not belong, counting on a secret to back him."
|
||||
},
|
||||
{
|
||||
"chunk": 81,
|
||||
"story": 6,
|
||||
"summary": "The stranger greets the landlord by another name, a wanted pirate's, and demands clothes, lodging and money in return for silence.",
|
||||
"predicament": "A man who has built a respectable new life is found by an old associate who threatens to expose his past."
|
||||
},
|
||||
{
|
||||
"chunk": 82,
|
||||
"story": 6,
|
||||
"summary": "The landlord gives him money; the blackmailer draws a pistol, declares he will take half of everything from now on, and settles in.",
|
||||
"predicament": "A man pays off a blackmailer and learns the price will be half of everything, for good."
|
||||
},
|
||||
{
|
||||
"chunk": 83,
|
||||
"story": 6,
|
||||
"summary": "The landlord says he has lived honestly for fifteen years and warns the man not to wake the devil in him; the blackmailer meets the landlord's stepdaughter and says he will stay.",
|
||||
"predicament": "A reformed man warns a blackmailer not to push him too far, while the blackmailer sets his sights on the man's family."
|
||||
},
|
||||
{
|
||||
"chunk": 84,
|
||||
"story": 6,
|
||||
"summary": "The blackmailer orders the best supper and room, and sleeps with his door barricaded and a pistol under his pillow.",
|
||||
"predicament": "A man who has seized power over another knows the danger he is in, and guards himself even as he gloats."
|
||||
},
|
||||
{
|
||||
"chunk": 85,
|
||||
"story": 6,
|
||||
"summary": "He pushes in at the family breakfast table, eyes the stepdaughter, hints at the landlord's past before her, and goes out to buy new clothes.",
|
||||
"predicament": "A household must make polite space for someone who holds them hostage."
|
||||
},
|
||||
{
|
||||
"chunk": 86,
|
||||
"story": 6,
|
||||
"summary": "Within a month the blackmailer runs the inn; the landlord, failing in health, backs him in sacking a loyal old servant, and his daughter demands to know who is master.",
|
||||
"predicament": "Someone watches a person she trusts give way at every turn to an intruder, and demands that he stand up for himself."
|
||||
},
|
||||
{
|
||||
"chunk": 87,
|
||||
"story": 6,
|
||||
"summary": "The landlord sides with the blackmailer, who threatens to ruin the girl and have the landlord hanged; the landlord pleads for peace.",
|
||||
"predicament": "A man who is being blackmailed hears his tormentor threaten someone he loves, and must choose between swallowing it and hitting back."
|
||||
},
|
||||
{
|
||||
"chunk": 88,
|
||||
"story": 6,
|
||||
"summary": "The inn decays under the blackmailer's rule; the landlord collapses with a fit, which upsets the blackmailer's plans.",
|
||||
"predicament": "A blackmailer's hold depends on his victim's life, and that victim suddenly collapses."
|
||||
},
|
||||
{
|
||||
"chunk": 89,
|
||||
"story": 6,
|
||||
"summary": "The landlord lies dying, anxious to please the blackmailer, while his daughter nurses him; the blackmailer resolves to get the hidden money before he dies.",
|
||||
"predicament": "A blackmailer whose victim is dying must grab what he can before his hold disappears."
|
||||
},
|
||||
{
|
||||
"chunk": 90,
|
||||
"story": 6,
|
||||
"summary": "The blackmailer threatens to inform; the dying man begs for time and promises to reveal his money that night if his daughter gets a share.",
|
||||
"predicament": "A dying man pressed by his tormentor bargains for time and for provision for someone he loves."
|
||||
},
|
||||
{
|
||||
"chunk": 91,
|
||||
"story": 6,
|
||||
"summary": "At night the blackmailer drugs the nurse and demands the money; the landlord extracts his promise about the daughter.",
|
||||
"predicament": "A man who thinks his helpless victim is at his mercy leans in close to hear the secret."
|
||||
},
|
||||
{
|
||||
"chunk": 92,
|
||||
"story": 6,
|
||||
"summary": "The 'dying' landlord seizes and strangles him, revealing he has been shamming weakness and planning to blame the killing on the blackmailer's own followers.",
|
||||
"predicament": "A man who pretended to be helpless strikes at the tormentor who threatened his family."
|
||||
},
|
||||
{
|
||||
"chunk": 93,
|
||||
"story": 6,
|
||||
"summary": "He stages the scene as a robbery and returns to bed as the drugged nurse unwittingly provides his alibi.",
|
||||
"predicament": "A man who has done a desperate thing arranges things so that suspicion falls elsewhere."
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
[
|
||||
{"chunk":94,"story":7,"summary":"An old villager chats with a travelling painter repainting the inn sign, which had been mistaken for a portrait of a king.","predicament":"A craftsman told his work looks like something else quietly fixes it before anyone else notices."},
|
||||
{"chunk":95,"story":7,"summary":"The painter shares his beer, and the old man begins the tale of a travelling circus that stopped in the village.","predicament":"An old man looking for a free drink offers a story in exchange."},
|
||||
{"chunk":96,"story":7,"summary":"News comes that the circus tiger has escaped nearby; a tramp comes running to the inn shouting that the tiger is after him, and the drinkers rush indoors.","predicament":"People who laughed at a distant danger panic when it seems to have arrived on their doorstep."},
|
||||
{"chunk":97,"story":7,"summary":"The landlord bars the door against the tramp; a man knocks the landlord down to get out to protect the families, and the tramp gets in.","predicament":"Someone must choose between keeping himself safe behind a barred door and letting in a stranger in danger."},
|
||||
{"chunk":98,"story":7,"summary":"The tramp, revived with free brandy, tells a vivid tale of fleeing the tiger, and the men are afraid to go home.","predicament":"A stranger with a thrilling tale of danger is rewarded by a frightened crowd without anyone checking it."},
|
||||
{"chunk":99,"story":7,"summary":"The men sleep at the inn; the tramp is gone by morning; the village lives in fear, though the local rogue laughs at the whole thing.","predicament":"A community living in fear of an unseen threat ignores the one doubter, because he has a bad name."},
|
||||
{"chunk":100,"story":7,"summary":"A villager's hens are taken; he carries the evidence to the constable in the next village, who shoves him away as drunk.","predicament":"A man who has suffered a loss goes to the authorities for help and is brushed off as a nuisance."},
|
||||
{"chunk":101,"story":7,"summary":"Pigs vanish night after night; the authorities do nothing, and neighbours squabble over whether to take animals indoors.","predicament":"A community suffering repeated losses cannot get help, and cannot agree how to protect itself."},
|
||||
{"chunk":102,"story":7,"summary":"Children claim sightings, and a villager staggers in describing how the tiger chased him up a tree.","predicament":"A man tells a dramatic story of narrow escape to an audience eager to believe it."},
|
||||
{"chunk":103,"story":7,"summary":"When one man questions the story, the rogue 'confirms' it, claiming he watched from another tree; the storyteller, caught out, goes home silent.","predicament":"A man caught in a boastful lie is 'backed up' by a rival, who now has him trapped by it."},
|
||||
{"chunk":104,"story":7,"summary":"The neighbouring town and the clergyman say there was no tiger; after more losses a former militiaman raises a band of seventeen to hunt it.","predicament":"People told their fears are nonsense by outsiders decide to take matters into their own hands."},
|
||||
{"chunk":105,"story":7,"summary":"The hunt searches the wood timidly and finds nothing; back at the inn, an old man reports the tiger has just gone into the rogue's cottage.","predicament":"A band that set out to face a danger returns relieved to have found nothing, and is then called to face it for real."},
|
||||
{"chunk":106,"story":7,"summary":"The armed men crowd into the rogue's cottage, pushing him ahead of them, despite his protests.","predicament":"A man with something to hide has an armed crowd force its way into his home."},
|
||||
{"chunk":107,"story":7,"summary":"His kitchen is full of pork and poultry; accused of theft, he brazenly claims to have gone into the meat trade.","predicament":"A man caught with the evidence of his thefts brazens it out and dares his accusers to prove anything."},
|
||||
{"chunk":108,"story":7,"summary":"He turns their own tiger sightings against them: those who swore they saw it would have to repeat it on oath.","predicament":"A man accused of wrongdoing turns his accusers' own exaggerations into his defence."},
|
||||
{"chunk":109,"story":7,"summary":"He claims to have receipts, offers to sell them the meat, and gets away with it; he and the tramp turn out to have known each other.","predicament":"People who know they were swindled cannot prove it, and must watch the swindler profit."}
|
||||
]
|
||||
@@ -0,0 +1,14 @@
|
||||
[
|
||||
{"chunk":110,"story":8,"summary":"A retired officer, dressed with great care, sets out on a hot afternoon and overtakes a friend who stares uneasily at his loud tie.","predicament":"A man nervously setting out to do something that matters to him is stopped by a friend with questions."},
|
||||
{"chunk":111,"story":8,"summary":"The officer confides he is going to propose to a widow; his friend, equally dressed up, says he is on the same errand, and the two race along the road.","predicament":"A man discovers a friend is after the same person he is, on the same day, and each races to get there first."},
|
||||
{"chunk":112,"story":8,"summary":"Followed by jeering boys, they stop and agree they cannot propose together, but neither will yield the day, each pleading an excuse.","predicament":"Two rivals agree someone must go first, and neither will be the one to wait."},
|
||||
{"chunk":113,"story":8,"summary":"Each claims precedence; the officer vows that if they go together he will match any proposal on the spot, and refuses to be put off.","predicament":"Two rivals for the same prize trade claims of priority, and each suspects the other of a trick."},
|
||||
{"chunk":114,"story":8,"summary":"The officer proposes they play one game of chess for the right to propose first; the friend agrees, on condition that if the winner is refused, the loser may not propose without his permission.","predicament":"Two rivals agree to settle who goes first by a game, and one slips in a condition the other does not examine."},
|
||||
{"chunk":115,"story":8,"summary":"The officer offers his rival a large brandy, which the rival sets aside; after a long game the rival checkmates him with a bishop the officer had forgotten.","predicament":"A man who tried to tip a contest in his favour loses it through his own oversight."},
|
||||
{"chunk":116,"story":8,"summary":"The winner tidies himself in the loser's house and goes off to propose; the loser waits up all night and next morning goes to hear the result.","predicament":"A man who lost the right to try first must wait while his rival takes his chance."},
|
||||
{"chunk":117,"story":8,"summary":"The rival was refused, but holds the officer to the condition and plans to keep proposing until the widow accepts him.","predicament":"A man finds the rival he lost to has been refused, but can still use the terms of their bargain to keep him out."},
|
||||
{"chunk":118,"story":8,"summary":"The rival says he will warn the widow off the officer, suggests the officer court her companion instead, and the officer storms out and sulks for weeks.","predicament":"A man bound by a promise he made watches a rival use it to keep him from what he wants."},
|
||||
{"chunk":119,"story":8,"summary":"Meeting the widow by chance, the officer confesses the chess game to explain his absence; she is indignant, then laughs.","predicament":"A man who has been avoiding someone he cares for must explain why, and the only honest answer is embarrassing."},
|
||||
{"chunk":120,"story":8,"summary":"She says she is angry with him for losing, and proposes a plan so that he can win at cards what he lost at chess, under the rival's eyes.","predicament":"A man bound by a rash promise finds the person he hoped to win is willing to help him get round it."},
|
||||
{"chunk":121,"story":8,"summary":"Playing cards, the widow asks the officer if he would like her hand; he takes it and kisses it, she 'meant the cards', then lets him keep it, and the rival is left fuming.","predicament":"Someone arranges to accept the suitor she prefers in a way his rival cannot object to."}
|
||||
]
|
||||
@@ -0,0 +1,15 @@
|
||||
[
|
||||
{"chunk":122,"story":9,"summary":"A man wakes up thirsty in a cramped, reeking bunk, with a stranger's face swearing at him, and cannot tell where he is.","predicament":"A man wakes up somewhere strange with no idea how he got there."},
|
||||
{"chunk":123,"story":9,"summary":"He realises he is on a ship, dressed in rags; he finds his friend among the sleepers and learns the ship's name.","predicament":"A man in an unknown place looks for his one companion and a first clue to how they came to be there."},
|
||||
{"chunk":124,"story":9,"summary":"Seasick and far from land, he goes aft and demands to be taken home; the officer calls him mad and orders him forward.","predicament":"A man who has always been obeyed tries to give orders somewhere nobody knows or believes who he is."},
|
||||
{"chunk":125,"story":9,"summary":"His friend tries to threaten legal consequences but is too sick to finish; the two bicker over which of them was drunk.","predicament":"Two people in trouble together try to put on a united front and end up blaming each other."},
|
||||
{"chunk":126,"story":9,"summary":"The captain says they signed on as cook and seaman, calls them drunk impostors, and threatens jail; they are put to work.","predicament":"Two people who claim to be respected professionals are treated as impostors and forced to do menial work."},
|
||||
{"chunk":127,"story":9,"summary":"The crew mocks their claims of being drugged and of sobriety; their airs make the officers dislike them all the more.","predicament":"People brought low insist on their old status, and are mocked for it by those around them."},
|
||||
{"chunk":128,"story":9,"summary":"The captain humiliates the cook; the cook obeys, tidies the cabin, and comes back with a secret smile and something in his pocket, telling his friend it won't be for long.","predicament":"A humiliated man pretends to submit while he quietly prepares his revenge."},
|
||||
{"chunk":129,"story":9,"summary":"The captain is struck with violent pains; the cook refuses to treat him, as he 'only shipped as cook', until he agrees to pay a professional fee.","predicament":"A man mocked for claiming skill is suddenly needed for it, and names his price."},
|
||||
{"chunk":130,"story":9,"summary":"The cook prescribes, collects his fee, scares the captain about making a will, and has the mate sent out for snuffling.","predicament":"A man who has gained the upper hand over those who humiliated him enjoys every moment of it."},
|
||||
{"chunk":131,"story":9,"summary":"The cook hints he dosed the food from the medicine chest; the mate falls ill too, and the cook demands to be treated as surgeon.","predicament":"A man who has secretly caused the trouble only he can cure makes the most of it."},
|
||||
{"chunk":132,"story":9,"summary":"The mate haggles over the fee; the second officer falls ill too; the sick captain scolds the sick mate for being ill at all.","predicament":"A man who swore he would never pay the person he scorned is forced by suffering to haggle with him."},
|
||||
{"chunk":133,"story":9,"summary":"The cook treats and weakens all three officers; the captain and mate pay the other man to draw up their wills; they recover slowly as they reach port.","predicament":"People who scorned two outsiders now depend on them completely, and pay for the privilege."},
|
||||
{"chunk":134,"story":9,"summary":"Ashore, the cook tells the captain he ate something that disagreed with him, and leaves with a wave.","predicament":"A man who got even with his tormentors leaves them knowing they were had."}
|
||||
]
|
||||
@@ -0,0 +1,15 @@
|
||||
[
|
||||
{"chunk":135,"story":10,"summary":"At a crowded family breakfast, a poor widow living with her sister's family gets a letter and reads it so slowly that the others nearly burst with impatience.","predicament":"Someone given news that might change their life cannot take it in, while the people around them itch to know."},
|
||||
{"chunk":136,"story":10,"summary":"The widow fears the letter is a hoax; her brother-in-law thinks so too, until her sister reads it and rejoices.","predicament":"Someone who has never had luck is told of a windfall and is afraid to believe it."},
|
||||
{"chunk":137,"story":10,"summary":"The widow has inherited two thousand pounds from a former employer; the brother-in-law grumbles that nobody ever left him anything, and spreads the news through the town.","predicament":"A man sees a relative come into money and resents that luck never comes his way."},
|
||||
{"chunk":138,"story":10,"summary":"Local men suddenly befriend the brother-in-law; his wife points out that two old dealers are courting the heiress for her money.","predicament":"A man flattered by new friends is told they are really after a relative's money."},
|
||||
{"chunk":139,"story":10,"summary":"The brother-in-law watches the two suitors flatter the widow and tries to undercut them by announcing her age, nearly giving away his wife's.","predicament":"A man trying to expose flattering suitors trips over his own family's sensibilities."},
|
||||
{"chunk":140,"story":10,"summary":"The suitors outbid each other with compliments; the wife's temper frays, and the visitors are shown out with the door shut hard.","predicament":"Two rivals courting the same person keep topping each other's compliments while the family grows fed up."},
|
||||
{"chunk":141,"story":10,"summary":"The family, wanting to keep the money, fail to warn the widow; the slow dealer, losing ground to his glib rival, hatches a plan and enlists a small-time dealer.","predicament":"A slow-talking man losing a contest to a glib rival looks for a trick instead of better words."},
|
||||
{"chunk":142,"story":10,"summary":"The dealer bribes an accomplice, who holds out for a bigger fee, to tell the rival in confidence that the widow has lost her fortune; the rival swallows it.","predicament":"A man hires someone to spread a false rumour that will drive off his rival, and the hireling drives a hard bargain."},
|
||||
{"chunk":143,"story":10,"summary":"With the rival gone, the dealer courts the widow alone, preaching the virtues of poverty, and loses seven games of draughts to her on purpose.","predicament":"A man courting for money pretends not to care about it and flatters the one he courts by letting her win."},
|
||||
{"chunk":144,"story":10,"summary":"He blows her kisses and fusses over a bumped head, invites himself back, and walks home sure the money is his.","predicament":"A schemer who thinks his deal is done walks away gloating too soon."},
|
||||
{"chunk":145,"story":10,"summary":"He spreads the rumour of her ruin through the town, then proposes publicly 'now that she is poor', to look noble.","predicament":"A man makes a public show of selfless devotion, sure that it will cost him nothing."},
|
||||
{"chunk":146,"story":10,"summary":"She accepts; the family, touched, apologise for misjudging him, then mention a letter that arrived.","predicament":"A schemer whose plan has worked is thanked for his goodness while the ground starts to shift under him."},
|
||||
{"chunk":147,"story":10,"summary":"The lawyers' letter says a later will was found and she has nothing; the dealer, now engaged to a poor woman, is left alone with her to court.","predicament":"A man who faked a disaster to win a prize learns the disaster is real, and he is bound to his promise."}
|
||||
]
|
||||
@@ -0,0 +1,56 @@
|
||||
[
|
||||
{
|
||||
"chunk": 148,
|
||||
"story": 11,
|
||||
"summary": "In an inn, men trade ghost stories; a sceptical old sailor mocks sailors' visions, then admits one uncanny experience on land.",
|
||||
"predicament": "A sceptic among believers is pressed to admit whether he has ever seen anything he could not explain."
|
||||
},
|
||||
{
|
||||
"chunk": 149,
|
||||
"story": 11,
|
||||
"summary": "As a young man back from abroad, the narrator found his relatives away, took lodgings at a dull inn, and set out on a long country walk.",
|
||||
"predicament": "A young man who arrived to find no one expecting him has to fill empty days in a strange place."
|
||||
},
|
||||
{
|
||||
"chunk": 150,
|
||||
"story": 11,
|
||||
"summary": "He loses his way in the marshes as fog and night come down, and at last finds a road and hears footsteps.",
|
||||
"predicament": "A traveller lost in the dark and the cold must find shelter before night closes in."
|
||||
},
|
||||
{
|
||||
"chunk": 151,
|
||||
"story": 11,
|
||||
"summary": "A countryman sets him on the road but warns him away from a lit house nearby, said to hide something monstrous; tired, he goes to it anyway.",
|
||||
"predicament": "A worn-out traveller must choose between a long walk on and a nearby shelter he has been warned against."
|
||||
},
|
||||
{
|
||||
"chunk": 152,
|
||||
"story": 11,
|
||||
"summary": "A grim old woman and a huge old man answer; after a furtive exchange the man insists he stay for dinner, and he feels trapped.",
|
||||
"predicament": "A stranger taken in by odd hosts who seem keen to keep him wonders whether he has walked into a trap."
|
||||
},
|
||||
{
|
||||
"chunk": 153,
|
||||
"story": 11,
|
||||
"summary": "The host says his son will dine with them in the dark because of his eyes; he puts out the candles, and a strange voice speaks.",
|
||||
"predicament": "A guest must sit down in the dark with someone he cannot see, on hosts' terms he does not understand."
|
||||
},
|
||||
{
|
||||
"chunk": 154,
|
||||
"story": 11,
|
||||
"summary": "The host leaves him alone with the unseen son, and the guest thinks he sees wild eyes shining at him across the table.",
|
||||
"predicament": "A guest left alone with a stranger he cannot see grows afraid, and must decide whether to trust his hosts."
|
||||
},
|
||||
{
|
||||
"chunk": 155,
|
||||
"story": 11,
|
||||
"summary": "The screen falls, and firelight shows the son's ruined face; the father lights the candles and explains his son was disfigured in a fire.",
|
||||
"predicament": "A frightened man sees what he feared, and learns it is someone who was hurt and ashamed to be seen."
|
||||
},
|
||||
{
|
||||
"chunk": 156,
|
||||
"story": 11,
|
||||
"summary": "The guest apologises warmly; they celebrate the son's birthday until midnight, and the father toasts the children his son saved.",
|
||||
"predicament": "A man who shrank from someone must make amends, and finds they deserve his respect."
|
||||
}
|
||||
]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user