Decision architecture — building up to the fork
Source path: knowledge-base/specs/visual-novel/decisions.md
# Decision architecture — building up to the fork
Status: **rough spec (direction).** Not an implementation contract; nothing here ships until scheduled.
Companion to the [visual-novel delivery](README.md) (§3 owns the commitment contract — *when* a fork may be
presented at all) and to [budgeted fate exploration](../fate-exploration/README.md) (which owns *finding*
divergent candidates). This document owns the part neither covers: **how the narrative is oriented so that a
fork arrives as a cliffhanger — a moment the reader has been made to care about — rather than a menu that
interrupts a scene.**
[Branching](branching.md) carries the fork into the Reading instrument, while [production](production.md)
carries its asset and cost consequences. [Systems](systems.md) owns everything *below* a fork — texture
and accumulating picks, and the gates they later open — which is why the gate here can afford to be strict.
[Flags](flags.md) owns what a commitment *is* as a record once taken: its nature, the vantage it was taken
from, and what it is later read against.
The distinction is the whole document:
> A choice is only a choice when it moves the world (§3 of the delivery spec).
> A choice only *lands* when the world has already been made to lean.
A fork that qualifies structurally can still be inert. The reader's experience of consequence is produced
before the choice screen, by the scenes that raise the question — and the instrument already carries typed
vocabulary for exactly that curve, so this is measurable rather than a matter of taste.
---
## 1. The shipped dramatic vocabulary
Two typed layers already describe rise and release, and neither was built for this — which is why they are
trustworthy here.
**Thread events** (`ThreadLogNodeType`, `src/types/domain/thread.ts`) classify what each scene did to an open
question:
| | Types | What it means for a fork |
|---|---|---|
| **RISE** | `setup`, `escalation` | the question is being loaded — a direction is planted, stakes deepen |
| **FALL** | `resistance`, `complication` | adverse pressure; the leader dips, a flip becomes thinkable |
| **TURN** | `twist` | the expected outcome is denied — the strongest cliff material there is |
| **WIDEN** | `opening` | a new possibility enters contention |
| **HOLD** | `pulse` | attention maintained, nothing expected |
| **NARROW / RESOLVE** | `closure`, `payoff` | the question **closes** — the one thing a cliffhanger must not do |
**Scene beats** (`BeatFn`, `src/types/domain/beat.ts`) classify how prose delivers each moment: `advance`,
`turn`, `reveal`, `shift`, `foreshadow` build; `resolve` releases; `breathe` paces. Propositions are further
classified `Anchor` / `Seed` / `Close` / `Texture` by forward and backward activation — a **`Seed` plants
forward and is harvested later**, which is a cliffhanger's semantic signature stated in the type system.
So "orient the narrative around the decision" is not a new mechanism. It is a **cadence constraint on the
arc terminal**, expressed in vocabulary the generator already emits and the record already stores.
---
## 2. When a fork is a cliffhanger — the deterministic gate
A fork qualifies as a cliffhanger when **all** of the following hold. Every test is a pure derivation over
stored state; none requires asking a model whether the moment is exciting.
1. **Withheld payoff.** On the thread the fork resolves, the terminal scene emits a RISE / TURN / WIDEN event
(`escalation`, `twist`, `opening`) and **no `payoff` or `closure`**. This is the mechanism: the question is
loaded and left open. A terminal scene that pays its own thread off has already answered the reader's
question, and the fork after it is administrative.
2. **A live question.** The thread's stance is genuinely unsettled — small `getStanceMargin().margin` and
high `normalizedEntropy` (`src/lib/core/forces/thread-market.ts`). A thread sitting at 92% has a foregone
conclusion; stopping the reader there manufactures suspense the world does not have.
3. **Typed divergence.** At least two candidate continuations differ in `WorldExpansion` deltas, per the
delivery spec's commitment contract, and ideally with **opposed signs** on the fork thread's stance delta —
the exploration spec's strongest orthogonality signal. Same-direction candidates are a pace change, not a
dilemma.
4. **Irreversibility.** At least one candidate carries a delta that cannot be undone by a later scene —
`systemDeltas` / `ownershipDeltas`, a thread crossing `isThreadClosed`, an entity leaving play. A choice
whose branches reconverge is a scenic detour, and the reader learns that within two forks.
5. **Legible in one sentence, from the reader's seat.** The question must be sayable as the reader's own
concern without exposing anything outside their information boundary. If naming the stakes requires
private material, the fork belongs to a different seat.
A fork failing (1) or (2) should not be *deleted* — it is still a legitimate branch point for exploration and
the flowchart. It simply is **not a stopping point for a reader**: the delivery passes through it on the
accepted continuation and never raises a choice screen. Stopping the reader is the scarce resource.
**The anti-metric rule holds** (as in the exploration spec): this gate decides *where to stop the reader*. It
must never be fed back into prompts as "write me a cliffhanger", and the generator must not be tuned to make
the gate pass more often. A story in which every scene ends on a `twist` is not tense, it is noise — and the
`BeatSampler`'s markov chain exists precisely because beat *distribution* is a property of a real corpus, not
a preference.
---
## 3. Cadence — where a cliffhanger is allowed to fall
The pacing unit already exists: `SCENES_PER_ARC` (4). An **Episode is an arc**, and the arc terminal is the
only place a delivery may end on an unresolved question. So the shape of a prepared Episode is:
```text
scene 1 breathe / inform — the situation, re-established for a returning reader
scene 2–3 advance / frame / shift — the question is loaded; pressure compounds
scene 4 turn / reveal / foreshadow, NO resolve — the cliff
├─ fork (if the §2 gate passes) → choice screen
└─ otherwise → accepted continuation, no interruption
```
Three consequences worth stating because each is a real constraint on generation, not a nicety:
- **Terminal-scene bias, not global bias.** The beat cadence override applies to the *last* scene of an arc
only. Biasing the whole domain toward `turn` produces exhausting prose and corrupts the sampler's
correspondence with its source corpus.
- **Openings pay off the previous cliff.** A resumed Episode must discharge the tension it left — the first
scene after a commitment shows the consequence (the delivery spec's after-commitment readout is the UI half;
this is the prose half). A cliff whose payoff never arrives is the medium's cardinal sin, and it is
detectable: a RISE event with no subsequent `payoff`/`closure` on that thread anywhere downstream.
- **Not every arc ends on a cliff.** `all_threads_resolved` and `payoff` terminals are how a route reaches an
ending. A structure that can never rest has no endings, and endings are what make replay meaningful.
---
## 4. The choice screen is a projection of a `Reading`
The fork material is already modelled. A **`Reading`** (`src/types/domain/reading.ts`) is an arc-anchored
cohort of priced `Future`s, each carrying `name`, `description`, `variables`, `priorLogit`, `reasoning`,
`considered` (the rejected option space), `breaks` (the falsifier) and `opens` (what it primes) — and the
`Reading` itself records `asOfEntryId` + `contextScope`, i.e. **the vantage in time it was taken from**.
That is a choice screen with provenance, and it maps cleanly onto the contract:
| Reader-facing element | Source | Rule |
|---|---|---|
| the option label | `Future.name` + `description`, rewritten in second person | never a raw id, never a probability |
| what it would set in motion | `opens` | shown as flavour, not as a promise of outcome |
| what was weighed | `considered` | behind an affordance, never dumped on the screen |
| the odds | softmax over `priorLogit` | **after commitment only** — before it, a prior is an instruction |
| what would have made this wrong | `breaks` | travels to the ending card, not the choice screen |
| the vantage | `asOfEntryId` / `contextScope` | the honest "as of scene N" — a hindsight-scoped cohort must say so |
Two cautions:
- **Vocabulary collision.** `Reading` is taken, and it means *this* — a priced cohort of futures. The reader's
session is a **Playthrough** with a **cursor**; never call it a reading session in code or copy
([Play vocabulary](../../knowledge/vocabulary/play.md) governs).
- **A hindsight-scoped cohort is not fork material.** If a `Reading` was generated with `contextScope: "full"`,
its options were drafted knowing what came after the fork. It is a fine analytical artifact and a poor
choice screen; forks should be prepared from `asof` cohorts cut at the fork entry.
---
## 5. Live posture — a decision window is a Scenario, not a menu
When a commitment spends Will rather than selecting a passage, the resolution path already exists and is
substantial: the **Scenario** table (`src/types/domain/scenario.ts`) runs a typed phase machine —
`read → write → play → resolution → graph → arc → scoring` — over seats with conviction economies,
concealment regimes (`house` / `stealth`), contested-thread settlement (`ResolveBias`) and a per-seat
projection boundary enforced at `projectForSeat`.
So the live fork is not a new mechanism either:
- A reader's commitment in live posture is a **play at the `play` phase**, priced by the conviction economy —
the cost *is* the weight of the decision, and it is already scarcity-modelled.
- Concealment is already enforced at the projection boundary rather than hidden in the UI, which is exactly
what a seat-shaped choice screen requires.
- The `resolution → graph → arc` steps are how the commitment becomes canon with provenance. The delivery
never resolves anything itself; it hands off and waits.
- Scenario's `Goal` (a seat's private target that "NEVER affects the score") is the honest model for reader
intent: a stated aim is a tracking aid, not a lever on truth.
The reader-facing consequence: a live fork must be **visibly heavier** than a traversal fork — it costs
conviction, it can be contested by other seats, and it may not resolve the way the reader chose. Presenting
the two with the same affordance would be the single most damaging simplification available here.
---
## 6. Failure modes this design is trying to prevent
- **The fake cliff.** Stakes announced at the arc terminal, then discharged in the first line of the next
scene. Detectable: a RISE event followed immediately by a `payoff` with no intervening material.
- **The false dilemma.** Options that read differently and land identically. Held by the delta gate — and it
is worth logging, because a generator that keeps producing convergent sisters is the signal that the
divergence instruction in exploration is not biting.
- **The truncation cliff.** Ending mid-sentence or mid-action to manufacture tension. The medium's version of
suspense is a *committed turn* — something happened and its consequence is unknown — not a withheld
predicate. Cheap, and readers punish it.
- **Choice fatigue.** A fork at every arc terminal is as bad as none: the reader stops reading the question
and starts optimising. The §2 gate is deliberately strict so that stopping is rare and means something.
- **Suspense theatre.** A near-certain thread dressed as a dilemma. Held by the entropy/margin test — and
this is where the instrument's honesty pays off: it *knows* the question is settled, so pretending
otherwise would be lying with its own numbers on file.
- **Priors as instruction.** Showing the odds first. Already a rule in the delivery spec; restated because it
is the one that will be argued for on "helpfulness" grounds.
---
## 7. The honest ledger
| Requirement | State | Where |
|---|---|---|
| Typed thread events (rise / fall / turn / widen / resolve) | **shipped** | `ThreadLogNodeType` |
| Typed beats + `Seed`/`Anchor`/`Close` proposition classification | **shipped** | `BeatFn`, `PropositionClassification` |
| Beat cadence sampled from a real corpus (markov + fn→mechanism) | **shipped** | `BeatSampler` |
| Stance margin, entropy, closure/abandonment derivations | **shipped** | `forces/thread-market.ts` |
| Priced future cohorts with `considered` / `breaks` / `opens` and an as-of vantage | **shipped** | `Reading`, `Future` |
| Scenario phase machine, conviction economy, seat projection boundary | **shipped** | `scenario.ts`, `projectForSeat` |
| Arc grain as the pacing unit | **shipped** | `SCENES_PER_ARC` |
| **Scene-exit gate for hanging/live Threads** | **shipped** (partial; divergence and irreversibility remain untested) | `src/lib/core/scripts/scene-exit.ts` |
| **Unpaid-cliff detector (rise/turn/widen with no same-scene payoff)** | **shipped** | `src/lib/core/scripts/scene-exit.ts` |
| **Script lenses, reader reveals, and knowledge asymmetry** | **shipped** (derived) | `src/lib/core/scripts/`, `ScriptMoment.reveals` |
| **Choice screen as a `Reading` projection, priors withheld until commitment** | **build** | reader surface ([reader.md](reader.md)) |
| **Fork-qualification readout for the Director (why this stopped / didn't)** | **shipped** (authoring surface) | `SceneScriptView`, `read-scene-script` |
---
## 8. Open questions
1. **Who authors the question text** — derived from the thread's own phrasing (honest, sometimes dry) or
written per fork by the composer (vivid, another paid call, and a place for drift to enter)?
2. **May a reader ever be stopped at a fork that fails the gate**, if they explicitly asked to be stopped at
every branch point (an explorer's mode)? My instinct: yes, but labelled as a branch point rather than
dressed as a dilemma.
3. **How many forks per Episode at most** — one (clean, matches the arc grain) or one *per open thread* that
qualifies (denser, risks the optimising posture)?
4. **Does an unpaid cliff block promotion** in the exploration ladder, or merely warn? Blocking is stricter
and could stall a branch whose payoff is genuinely two arcs out.
5. **Whether the terminal cadence bias is a domain setting or a per-Episode preparation option** — a domain
whose corpus rarely ends on turns should probably be allowed to say so.
Open on GitHubRaw Markdown source