Skip to content

Latest commit

 

History

History
111 lines (56 loc) · 40.9 KB

DEVLOG.md

File metadata and controls

111 lines (56 loc) · 40.9 KB

Dev Log

2026-09-15

A short day that closed out the 09-13 planner mystery and then found a second thing hiding behind the same kind of silence. The four degraded plans were not the null completions they had been read as: the tactical stage hardcoded max_tokens=2000 while llm.planner_max_tokens sat unread in config at 1000, and the response was being cut off mid-string by the ceiling. "Failed to parse plan JSON" logs identically for a truncated response and a malformed one, which is why the search went the wrong way for a whole session, so the fix is as much about telling them apart as about the number: looks_truncated() scans brace depth and string state to classify the failure, the planner now asks the api backend for finish_reason the way the writer has since the segment loop landed (with the structural check as the fallback on the CLI backends, which expose no metadata), and a truncated plan retries at double the budget while a malformed one retries unchanged. Retrying a budget failure at the same budget reproduces it exactly, which a live tick demonstrated by truncating at 3,062 characters and then at 2,695. Tick 0 now warns on a degraded plan too, having been the one tick that never did and the one where it costs most. The same run turned up a writer bug: one empty section was abandoning the entire sectioned write and discarding every section already paid for, so an empty section now retries once and the fallback prints rather than only logging. Verified live on a fresh project, with a non-degraded plan, five tool actions, four entities created, and a 2,220-word scene at 55/55 markers.

The rest of the day was written up rather than coded. Both fixes went into CLAUDE.md by hand, and then the name-bank gap got a full document: every character in every novel is named from the science-fiction syllable bank, because it is the only person-name bank that exists. _normalize_genre() classifies into scifi/fantasy/modern and self.banks holds one entry, so all three resolve to it and an unmatched genre reaches it through the default branch, which covers "historical adventure" and most of the masters corpus. The 2026-09-15 run is recorded as the baseline: an 1871 Royal Navy survey brig crewed by Novaine Starkath and Joren Prenoth.

Decisions & notes: The planner defect is the old hardcoded beat_max_tokens=1000 in a second place, so the pattern worth watching is a generation knob living in config while the call site carries its own number; adding fields to the tactical prompt now means raising the knob with them. Two things were reframed in the docs on the grounds that they only bite once you have lost the time to them: over the token ceiling the tick does not fail, it completes successfully on a plan with no POV character and no actions, and the sectioned-write fallback is not pure insurance but is expensive exactly when it fires. The name-bank gap is load-bearing rather than cosmetic because of the Phase 1 pattern itself: the LLM may not invent a name, so it cannot quietly correct a wrong-sounding one, and whatever the bank emits is what the reader sees on every page. It also cuts directly against what the DSL work is for, since a chapter can match the corpus on paragraph mode, length, dialogue turns and chapter length and still announce itself in the first proper noun. The fix is authored data rather than code and was deliberately not started, because it needs two decisions the code cannot settle: whether historical and contemporary names should be drawn from real lists instead of coined from syllables (coined English names read as neither invented nor real, which makes that a second generation mode rather than another data file), and whether banks need periodizing. CLAUDE.md had described the banks as "by culture/era", which is what led to assuming genre routing worked; corrected.

2026-09-13

A ten-commit day that began by finishing yesterday's narrow fix and ended somewhere entirely different, having found that most of what was measured this week was measured through a broken pipeline. It opened properly: yesterday's rewrite suppression was replaced by selective paragraph revision, so the tension pass and the paragraph plan now work together instead of one destroying the other. Only the tension-carrying paragraphs are rewritten and everything else returns byte-identical, so structure, length and marker compliance survive by construction. Which paragraphs carry the heat is read off the corpus rather than guessed (ACTION rises 1.33x from the calm band to the high band, against LORE's 0.56x), TRANSITION is excluded as noise despite the highest ratio of all, and the selection spreads across the scene so a revision adjusts the arc rather than rewriting one passage into a different register. An hour later the same feature needed a second pass, because the parser kept only the parts of a response beginning with a marker: a paragraph the model split across a blank line lost everything after the first chunk, which is silently deleting prose to prevent a split. Structure is now repaired rather than requested, with a length guard bounded against the paragraph being replaced rather than against the plan (the writer already lands under its plan, so the other way round rejects faithful revisions).

The middle of the day was an audit and its consequences. Sectioned writing and selective revision had been built quickly on top of a context assembly that took months, and they had silently dropped most of it: the section prompt carried 12 of the writer prompt's 19 fields, revision 10, and four of the omissions were contracts rather than decoration, so turning on subblock_generation also turned off grounded identity, plot beats, arc pressure and recent context. The first live sectioned scene inventing three characters instead of using the protagonist had been filed at the time as first-tick noise. A plan document went in first, then a prompt contract test written against the code as it stood and failing exactly as predicted, then one shared context spine both prompts render. Merging the single-shot and section templates turned out to be the wrong step and was dropped: the single-shot task block carries whole-scene shape requirements, and handing those to a call writing paragraphs 21 to 30 asks a middle section to open, turn and resolve alone. What was genuinely shared and genuinely missing is per-call craft discipline, and sections had none of it, not a thinner version. Prompts were then reordered static-before-volatile, which took the per-call counts out of plan_rules and produced roughly 1,000 tokens of identical prefix across a four-section scene.

Then the re-measure did not measure what it was meant to. Checking the naming prediction turned up 4/4 plans in both batches carrying "Planning failed, continuing with minimal plan": zero characters, locations or factions created in either project, every structural number from the day produced by a pipeline whose planner never once succeeded. The provider was returning null content, yesterday's crash fix had turned that into an empty plan without retrying, and the tick went on to print "Tick completed successfully". That pulled the thread on the deeper problem: a project started with no characters at all, and what had been keeping scenes readable was a fallback that parsed a protagonist's name out of the foundation's free text. Eight scenes across two runs were written about a person who existed only as a string, with no id, no memory record and an empty approved-name pool, which is exactly why those runs invented Irelle Jornane and Veraene Mornuth. The protagonist is now minted in Python at project creation and the fallback raises instead of substituting. The first version of that still concealed a regex, split the archetype on its first comma, and turned "Captain Ahab" into a first name of "Captain", so the day's last commit gave StoryFoundation a real protagonist_name field, made the wizard ask for the name and the archetype separately, and stopped anything mining prose for a name. Suite 900 to 950.

Decisions & notes: The recurring shape of the day is worth naming, because the last commit names it: structured information stored as prose, with the structure recovered later by guessing, which works while the input is well formed and fails silently when it is not. That is the section prompt losing its naming contract, the name regex, and the planner degrading quietly, all the same fault. Accordingly the corrections are all about making failure loud: the contract test is what prevents prompt drift rather than the template count (merging would have been a readability choice wearing a safety argument), a degraded plan is now announced in the tick output where an operator is looking, and graceful degradation was noted as hiding mistakes as well as accidents after a signature change surfaced as a WARNING rather than a failure. tick() and run() had drifted apart exactly as CLAUDE.md warns, with run() missing both faction tools; a test now parses both registration blocks and asserts they match. The judged comparison in the prompt architecture plan's section 5 stands only as a measurement of prose written from stub plans, and step 5 has to be re-run now that planning works. Two things deliberately left unvalidated: whether a plan-constrained revision moves tension as far as a free one (the keep-only-if-closer rule makes that safe but not necessarily useful), and whether corpus-drawn examples should carry their Victorian register, recorded as a decision to make rather than discover. Prompt caching is still off by choice, since it needs cache_control breakpoints from llm-backends; the ordering work makes that a backend edit rather than a prompt rewrite. One process note: 5ce14ea landed with a collection error because the suite and the commit ran in the same command without gating one on the other.

2026-09-12

A day driven entirely by reading live runs rather than by tests, and each thing read produced a fix. It opened with an auditability gap: the first live sectioned-writing run worked (three sections, 8/8 markers, every section within 12% of its word target) but the scene it produced left no record of its own structure, since skeleton_compliance was computed, printed and discarded and the skeleton was never written down at all. Scene.metadata now carries the structural record (the plan, marker compliance, whether the scene was sectioned, where the seams fell, segments used, and whether it concluded naturally or was trimmed), which matters specifically because the section bounds say exactly where to look in the prose for a voice seam. Next came a calibration scene at 2,121 words against a 3,150 target: expected_words() had been sizing plans from per-mode means while the prompt advertised the p25 to p75 range, and on these right-skewed distributions the range midpoint sits 20 to 30% below the mean, so a writer obeying the prompt undershot by construction. Agreeing the two was not enough, because one range per mode is wrong regardless (masters' dialogue paragraphs run 6 words at the first decile to 94 at the ninth, and the calibration scene duly produced 43 metronomic ~20-word dialogue paragraphs), so each block now draws its own target from the measured decile ladder, deterministic per plan and rescaled to the mean-based total. The re-run went from 67% to 84% of target with paragraph spread 4 to 167. A live multi-tick run then lost tick 2, and its retry, to an AttributeError raised from inside _parse_plan_response when the tactical stage got None back from the backend; that now takes the same _empty_plan() path an unparseable response already took.

The last and most consequential read was of a four-scene run, where the step 7.6 tension rewrite turned out to be silently demolishing the paragraph plan. It fires after the writer has applied the skeleton and stripped its markers, and knows nothing about the plan, so it rewrote the prose whole: it hit two of four scenes and roughly halved their paragraph counts (64 to 36, 57 to 28) while eating about a fifth of the words. A scene written to a paragraph plan is no longer offered a free prose rewrite. Suite 889 to 900 across the four commits.

Decisions & notes: Two compliance metrics were found lying on the same day, and both were fixed to describe the artifact that actually reached disk. The marker check only verified that every plan item appeared, so a re-run was recorded compliant at 60/60 while running to 65 paragraphs with five items split; it now requires both halves, every plan item written and nothing written that was not one. Worse, skeleton_compliance described the pre-rewrite draft, reporting 57 paragraphs for a scene saved with 28, which is precisely the number a validation run would have cited as proof the plan was followed; it is now recounted at commit time against the saved text and flags rewritten_after_planning on a mismatch. Suppressing the rewrite is the narrow fix and is acknowledged as such: making the revision plan-aware is the better one and was deliberately left out, the justification being that the skeleton is the stronger structural claim and that arc-pressure's own finding is that events set the tension floor, not prose. That fix also revises the run's headline number: the pooled 65% of word target splits into 79% for the two scenes the rewrite never touched and 51% for the two it did, so the sizing work is in better shape than the pooled figure suggested. The remaining gap there is model compliance rather than arithmetic, with the writer still landing ~14% under each block's stated target and splitting long blocks.

2026-09-11

The previous day's Gate A re-run against the production sampler had left one failing check at the production default (DIALOGUE share 0.510 against the masters' 0.565), and this morning was spent diagnosing it and acting on the answer. The diagnosis is that it is a length effect, not a sampler defect: dialogue share climbs with plan size (0.365 at 400 words, 0.509 at 1400, 0.540 at 3150), because the grammar measures the chapter opener per chapter, and the opener is SETTING/ACTION/LORE with dialogue only reaching its base rate about four blocks in. Applying that opener to every scene is right, since scene maps to chapter 1:1; applying it to a 1400-word chapter is not. Measured over the same 659 aligned chapters, a masters chapter averages 3,493 words (median 3,165, p10 1,595), while the shipped targets are 400/800/1400/2200: the house default sits below the corpus p10 and the house maximum sits below the per-chapter average of all 21 books. So generation.scene_length_preset landed, mirroring coherence.curve_preset exactly (including its precedence rule, since the generated config.yaml writes the shipped targets into every project and so presence cannot mean customization), with a masters preset at 1,600/2,150/3,150/4,450 under which the sampler scores 24/24 at scene scale.

That immediately raised the question the preset cannot answer on its own, which is that a single request will not reliably return a 3,150-word chapter. Hence Slice 5, sectioned writing: the scene is now written across several plan-addressed calls, each owning a contiguous range of the skeleton's [n] blocks, so a 3,150-word plan becomes roughly five calls of 500 to 800 words, a target a model actually delivers. partition_skeleton splits the plan (a trailing stub merges into its predecessor, because a one-block final call would have to open, sustain and land at once), section_word_target sizes each call from its own mode mix, and every call sees the whole brief plus all prose so far but only its slice of the plan, with position instructions so exactly one section lands the ending. The plan rules are now shared with the single-shot prompt rather than duplicated. Suite 867 to 889 across the two commits.

Decisions & notes: Both flags are default off, and deliberately so for different reasons. The length preset is unchanged by default because adopting it roughly doubles prose per tick, which is a cost and pacing call rather than a technical one. Slice 5 is off because its risks (seam quality, voice continuity across section boundaries, cost per scene) are all unvalidated and need the measured A/B the landing sketch specifies, now carried as its own roadmap item; _write_in_sections returns None on any failure so the caller writes single-shot exactly as before, meaning the flag can cost quality but never a scene. Slice 5's original scoping was per-sub-block generation to test a richness hypothesis, which the Slice 4 production runs already answered "not needed for gpt-5.5"; the motivation shipped here is length instead, so the unit is a section, with subblock_section_blocks: 1 recovering the literal per-block mode. The honest limit on the whole day's work: all of it measures the plan. Whether prose written from a masters-length plan resembles the corpus is untested, and the only judged evidence remains Slice 4's four scenes per arm at the house default.

2026-09-10

A single long day spent on the block-grammar line, opening with the overdue rewrite of CLAUDE.md, which had drifted two feature waves behind the code because the automated sweeps target README/ROADMAP/docs and leave the file that actually steers agent sessions alone. It was rebuilt against the source: the tick loop renumbered to the real # Step N markers with every missing step restored (construction-pressure detector, the sacred finale's ownership of the beat ask and its re-roll/end-marker/quarantine/expiry steps, judged extractor resolutions and judged loop closure, thread attribution), beat verification corrected from step 12 to 11.5, three new sections (threads, scene skeletons plus the write-until-concluded loop, export), the config knob groups checked against DEFAULT_CONFIG, and the inline shipped-list cut back to a pointer at ROADMAP so it cannot rot the same way again.

The craft fix came next: skeleton-guided scenes were packing five or six speech turns into one DIALOGUE paragraph, the top item from the Slice 4 reading notes and the suspected cause of the skeleton arm's low excursion-return rate. Measuring first showed the packing clause was a symptom. The nd1 sidecars carry labels but no word counts, so block_grammar_tables.py now aligns each book's labels back to the extracted markdown (all 21 align, accepting only an offset that reproduces every unit's paragraph count exactly) and emits paragraph_words. Paragraph length is a property of the mode: DIALOGUE 22 words at the median against LORE's 85. That made the old flat divisor arithmetically impossible to satisfy, since 16 dialogue paragraphs at masters' lengths are worth 663 words and a 1400-word target could only be reached by overfilling them. generate_skeleton now fills a word budget from measured per-mode lengths, each item renders with its own word range, and consecutive DIALOGUE items are one turn each. A reporting bug fell out alongside it: judge_scenes.py had hardcoded the masters' words-per-paragraph at 90, which was gate_c's shared budget convention rather than a corpus statistic, so the Slice 4 table had reported a 70% overshoot as a near-hit.

The afternoon closed gap 1 of the masters study by inducing the scene layer, and the answer was that Section 7's argument for a hidden layer compared against the wrong null. The excursion-return excess (0.355 observed against a first-order 0.238) is reproduced almost exactly by a plain second-order chain at 0.353, cross-validated by book across four folds. No fitted HMM reaches the observed rate or beats second-order at any state count, though three induced states do read as scene types and land close to constants already shipping. Incidentally, the shipped sampler's scene layer earns none of the return rate; its one decisive advantage is the chapter boundary. That prompted a same-day Gate A re-run against the production sampler rather than the PoC's own: 25/25 at chapter scale, 23/24 at the 1400-word production default, where the single failure is a length effect (the grammar's chapter opener distribution is applied to every scene, so orienting material gets paid for every ~23 blocks instead of once across ~58).

Decisions & notes: The L2 negative result is narrow and was explicitly scoped in docs after being questioned: it says a hidden scene state is invisible to the writer given two blocks of context, not that the DSL does not need scenes. What is retired is inducing L2 from block labels; block labels carry nothing of a scene in the authorial sense (same place, cast, purpose), so a plan can be scene-shaped by every statistic in the document while the prose wanders in intent. The commitment measurement (carrier set-piece length in words) does not favor the scene layer either, but the hybrid's skeletons averaged 42 blocks against the corpus's 58.4, so it is confounded and recorded as a reason to run the experiment, not as a result; the Gate A re-run must carry a commitment measure at matched lengths. Two methodological notes worth keeping: Baum-Welch collapses from about half of random starts on this data and a four-restart pass produced a spurious pattern that vanished at six, so the default is ten restarts with a test pinning it; and both Gate A scales are reported rather than one, since the chapter figure is the like-for-like gate against a per-chapter corpus while the scene figure is what production actually emits. The skeleton flag stays default-off, and the predicted lift in return rate is not validated: it needs a re-run, and the PoC harness's writer prompt still carries the old flat rules. Suite 844 to 867.

2026-07-28

Adopted llm-backends 0.2.0 and, in the process, fixed a silent regression it introduced: the new version stopped sending a system prompt unless one is explicitly requested, which quietly dropped the fiction persona on the api backend (the singleton send_prompt path has no per-call role parameter, so nothing was passing the role through). The fix is a new novel_agent/tools/llm_setup.py that owns the persona policy in one place: initialize_llm_with_persona() passes FICTION_ROLE on the api backend and nothing on the CLI backends (which are repo-aware agents that manage their own role). All six call sites switched to it, with the wiring pinned by tests/unit/test_llm_setup.py. The same commit finished aligning the codebase with the package as source of truth for model naming: config defaults now read llm_backends.DEFAULT_API_MODEL instead of hardcoding gpt-5.5, the interactive claude-cli default and docs moved off the legacy claude-4.5 alias to the canonical claude-sonnet-4-5, and env docs moved to ANTHROPIC_API_KEY (with CLAUDE_API_KEY still a deprecated fallback). Requirements pin bumped to @v0.2.0; 844 tests pass.

Decisions & notes: The persona lives in a single policy module rather than being re-derived at each call site, so the api-vs-CLI role distinction (persona on api, none on CLI agents) can't drift out of sync again. This closes the migration to the shared llm-backends package as the model-naming source of truth.

2026-07-15

Just past midnight, the block grammar proof of concept gained its decisive third stage: gate_c.py, the pre-registered A/B harness. It pits the baseline arm (premise plus word budget, today's single-shot shape) against the skeleton arm (a Gate-A sampled skeleton rendered with Gate-B's marker protocol), same writer, temperature, and budget, both re-annotated with the corpus judge protocol and scored against the masters' bands on interiority self-transition (ceiling 0.30) and shading rate (floor 0.10), plus a directional distance check. Deliberately short debug runs flushed out three real bugs before any expensive verdict run: a spurious directional FAIL from comparing 1 event against 5 (the scorecard is now tri-state, returning INCONCLUSIVE below 20 interiority transitions, and --rescore pools multiple chunk-run directories into one verdict), the OpenRouter client burning its whole backoff schedule retrying a non-retryable 402 credits-exhausted response (now fails fast on 4xx except 408/429), and the writer stochastically omitting the per-paragraph [n] markers on a short chapter, silently zeroing marker alignment (now re-asked once with an explicit reminder, verified live, with a positional fallback recorded in flags only when the paragraph count matches the plan). Write-stop-resume also landed: --pause-after-calls N stops cleanly after N billed calls under a shared writer-and-judge budget, and since cache hits are free and uncounted, re-running the same command resumes and pays only for the missing work. Eleven no-network tests cover the resume accounting and marker handling; verdict chunk 1 is in the repo and INCONCLUSIVE as expected at two chapters.

Decisions & notes: The chunked-verdict workflow (small paid runs pooled later via --rescore) is the cost-control mechanism: no single run has to be big enough to reach significance on its own. The pooled Gate C verdict waits on the remaining seed chunks (37/41/43).

2026-07-14

The day ran from housekeeping to the most consequential experimental work of the block DSL effort so far. Morning brought GitHub Sponsors setup (funding config plus a README badge) and a cross-pollination analysis between StoryDaemon and its sibling project NovelWriter: which learnings should flow in each direction (the larger opportunity being StoryDaemon into NovelWriter), plus a scoped proposal to extract a shared LLM-backend library with LLM-Remote-Runner as the model source of truth, including a migration plan, risks, and open questions.

The evening was the block grammar push. First, the full-corpus sequel to the six-master pilot: 38,495 judged paragraphs across 21 masterworks, mined for the block-ordering rules that will guide the block/sub-block DSL. It measures the 7x7 block transition matrix, run lengths (geometric at the block level), chapter opener and closer grammar, positional stationarity, tension-band conditioning, and secondary-shading pairs, and it comes with a script that regenerates every table from the annotation sidecars with no LLM calls. Then the findings went straight into a proof of concept with staged gates. Gate A (statistical, zero LLM): a hierarchical skeleton sampler reproduces the masters' block statistics, 25 of 25 checks across four seeds. Gate B (round-trip): skeletons rendered to prose in one single-shot DeepSeek call per chapter, re-annotated with the exact corpus judge protocol, then scored skeleton-in versus labels-out. Result: 4 of 4 chapters hit paragraph-count compliance, 0.834 marker-aligned label agreement, and the confusions sit exactly on the mode pairs the shading data says blend.

Decisions & notes: The study's excursion-and-return test (observed A-to-B-to-A return rate 0.355 versus a 0.238 first-order prediction) is the statistical evidence for a hidden scene layer, and it anchors the proposed L0-L4 layered design. Gate B's key negative finding: the naive "write exactly N paragraphs" prompt failed 0 of 4 (dialogue split per speech turn, long plans compressed with an invented ending); per-paragraph markers stripped before judging plus an explicit no-compression rule fixed both. The Slice 4 consequence is that scene skeletons need per-item accountability in the writer prompt, not just a count.

2026-07-12

A dense Phase 3 day that turned the latest validation runs into shipped fixes and opened the thread-interleaving build. The centerpiece was killing scene truncation: the grant-rate run found the writer's flat 3000-token ceiling was cutting 8 of 16 scenes off mid-sentence, a standing page-quality defect that had also cost a judged-closure grant and an end marker on run 5. The new invariant is that no scene commits without a detected ending (natural, concluded-on-request, or trimmed to the last complete sentence and flagged). It arrived in four layers: word targets coordinated with token ceilings so the model is finally told the length constraint it could never see; finish_reason plumbed through the api backend with per-provider normalization; a pure completion heuristic for backends that expose no metadata; and a sequential write-until-concluded loop that carries the whole scene so far with a firm conclude-and-stop instruction, capped at scene_max_segments. The segment plumbing was built deliberately so the block DSL's per-block writing mode (Slice 5) can reuse it.

On top of that came a six-fix hardening batch driven by evidence from the runs: loop_resolved was un-gated as an authorable contract check now that its grant rate (77.8%) cleared the bar, with finale slots exempted and a refused claim routing to keep-pending/rolling-horizon as real enforcement; llm.timeout was made to actually govern the api backend per request on every provider (it had been inert, letting a hung server hang up to 22 minutes, now failing in ~4.6s); beat-level dedup landed to kill the 9200-verbatim-character finding, using max(plain, sorted-token) difflib ratio because plain ratio could not separate duplicates from legitimate neighbors; the loop-dedup threshold was tuned to 0.75 against two documented near-misses; finale-refusal reasons are now persisted and printed; and the END marker is guaranteed on sacred-finale scenes with an import-time assert locking it to the completion heuristic's recognizer.

The last feature commit added a construction-pressure detector (Slice T4a, instrument-only: it computes when thread construction would fire and records it in metrics, with no effect on planning yet) and curve presets (coherence.curve_preset, default "house" byte-identical to the shipped curve, plus opt-in thriller-register, wind-down, and domestic-arc presets traced to the masters study's decile tables), routing every curve reader including the sacred finale through one resolver. The accompanying spec revision, informed by the masters corpus, reframed the B-thread as constructed for structural/POV variety and convergence payoff rather than as a calm reservoir, demoted the demand-gap trigger to an opt-in experiment, and derived thread_min_run from the masters' block proportions.

Decisions & notes: The relief cut is now treated as an authored, budgeted device (masters used it only ~3 times across 149 chapters), not a routine tension valve, which is why construction pressure ships as instrument-only first. Curve presets deliberately route the finale's target and cap through the same resolver so a thriller preset's hot climax is not re-rolled toward calm. The un-gating of loop_resolved is the first real loop-closure enforcement to ship, the pressure the log has been pointing at for weeks.

2026-07-11

Recorded descent run 4, the sharpest diagnosis yet of why the finale keeps overshooting its calm target. This run existed to observe the new slot-aligned batch cap, but the cap never fired: a beat's char_in_prose C003 postcondition failed seven consecutive ticks, pinned the queue, and forced the finale to re-execute a wedged rising-phase beat (8 vs target 4). The revealing part is that the beat's event was on the page repeatedly, the check failed only on who, because the writer is shown the raw ID (C003) rather than the character's name while the story minted nine new named security characters. So a satisfied beat was unverifiable by construction: a grounded-identity gap on the contract surface, not the writer defying the plan. Also logged: the staleness threshold missed the finale slot by arithmetic (2.8 vs step 3) for the second run running, and the first loop closure ever observed across the whole program (79 opened, 1 closed).

Decisions & notes: The char_in_prose-by-ID gap is the actionable takeaway: contract checkers that read prose need the character's grounded name, not the canonical ID, to match what the writer was actually given. Loop closure remains essentially nonexistent, continuing to point at loop-aging as the next real pressure to build.

2026-07-10

A large single-day burst, driven end to end by a sequence of plot-first "descent" validation runs (aiming the arc at a calm resolution and watching where it breaks). Three runs' worth of findings turned into concrete fixes. From the contracts smoke run: a stochastic malformed-JSON response was poisoning the beat horizon by dropping into a line fallback that turned raw JSON source lines into persistent beat descriptions (now retries once and rejects code-fragment "beats"), and LLM contract authoring had never actually fired because the prompt's authoritative shape block omitted postconditions, so a schema-obedient model always omitted them. From the descent run proper, three more defects: the beat-generation token budget was hardcoded at 1000 and truncated every contracts-plus-arc-guidance batch (new generation.beat_max_tokens, default 2000), two checkers that can never pass today (char_at_location, loop_resolved) were steering the prompt toward wedging beats on impossible conditions (gated out of authoring, still registered for evaluation), and contract verification was moved to tick step 11.5 so state-reading checks see the post-scene world. Two further backstops followed from the clean re-run: TENSION_FLOOR_CAP = 7 clamps any tension_at_least floor above the scorer's practical ceiling (an unsatisfiable floor had pinned the whole resolution window), and a beat that has gone stale off the curve no longer suppresses the schedule's guidance, so a stale peak can't hijack the finale. Finally, slot alignment: batches now cap to the remaining story runway (min(count, L - T)) so the last authored beat lands on the final tick and inherits the curve endpoint, with an explicit no-new-threats final-scene clause — fixing the arbiter run's denouement-scheduled-past-the-end bug.

Alongside the fixes, two larger threads. First, an empirical block decomposition study: paragraph-level structural annotation of six public-domain masters (Buchan, Eddison, Conrad, Austen, Haggard, Dunsany, ~970 paragraphs) against the generated novels, cross-validated across models (kappa 0.75, 8/8 blind probes). The headline conclusions matured over the day: block proportions are not the gap (generated prose sits inside the six-master distribution spread, and bulk distribution is confirmed useless as a gauge), the gap is rhythm and commitment — masters sustain long carrier set-pieces (565–1618 word runs vs 204–346) and exit interiority instead of looping it (master self-transition 14% vs generated 40%). The load-bearing DSL constraint that falls out is the interiority-exit rule. Second, a new thread-interleaving design: since the fork experiment proved the tension overshoot lives in the assigned event, not the writer prompt (pruning 80% of the writer prompt moved mean tension by 0.0), the tension curve is reframed as a scene-selection policy over a portfolio of threads — calm pages come from cutting away with a cliffhanger, not from becalming a hot event. The falsified prompt-surgery roadmap item was retired in favor of this.

Also shipped: OpenRouter support in the api backend (own OpenAI-compatible client singleton, OPENROUTER_API_KEY/OPENROUTER_MODEL, first test coverage for multi_provider_llm, live-validated on an 8-tick run). ROADMAP/CLAUDE.md/EMERGENT_COHERENCE_PLAN synced with the whole burst.

Decisions & notes: The two never-satisfiable checkers stay registered in the evaluation engine but are gated out of authoring — the vocabulary the prompt can draw from is deliberately narrower than what the engine can score, so beats can't be authored against conditions nothing in the pipeline can yet make true. The "4.5x-closer" proportions claim from the first pass was explicitly retired once the corpus grew to six masters — a good example of an early headline dissolving under more data. The wedged-beat problem is left deliberately un-auto-fixed (give-up/skip rules risk drifting off-outline); the elimination chain across the descent runs now points squarely at thread interleaving plus resolution sequencing as the real fix, not more prompt or rewrite pressure.

2026-07-09

Attacked the one failure the June validation runs kept showing: arc-pressure tracks rising tension targets but cannot de-escalate for a resolution, because the planner keeps choosing tense events no matter what number it is aimed at. The fix is the arc-phase planner mandate (Phase 3): the target tension curve is now also read as a story phase (rising, peak, falling, resolution), and the planner gets a firm event-level mandate per phase (escalate, confront, resolve) instead of only a numeric target. The mandate covers both the Stage 1 strategic path and plot-first's plan_for_beat (injected into the Stage 3 tactical prompt, suppressed when the beat carries its own tension_target). The bounded tension rewrite is now skipped when the target drop is large enough that different events, not different prose, are needed (rewrite_futile), and arc_phase is recorded in the per-tick coherence rubric. Alongside this, a new design doc (docs/BLOCKS_CONTRACTS_LANDING_SKETCH.md) reconciles the 2025 blocks/contracts architecture proposal with what has actually shipped, cutting the remaining work into slices: contracts riding on plot beats as the MVP, then precondition pressure, bounded contract repair, scene skeletons, and a measured per-sub-block generation experiment.

The sketch's first slices did not stay on paper long. The same day, beat generation itself was taught the curve: each beat in a generated batch is scheduled at its future tick, given an interpolated tension target and a phase directive in the generation prompt, and authored targets are reconciled to the schedule after parsing (clamp to a plus or minus 2 band, fill when missing). On top of that, contracts Slice 1 landed: PlotBeat now carries pre/postconditions authored at beat-generation time from the closed checker vocabulary, sanitized in both generation paths (unknown checks and phantom refs dropped, tension conditions reconciled against the beat's own target), evaluated during beat verification (a pass upgrades confidence, a failure routes to the existing keep-pending and horizon-revision paths), and measured in the coherence rubric. The stale contracts.json store and ContractManager are retired. A validation re-run of the June descent scenario confirmed the mandate works where it matters: the final scene dropped from 8/10 to 6/10 against a target of 4, with the planner demonstrably choosing aftermath events instead of confrontations (docs/progress_report_20260709.md).

Decisions & notes: The mandate is gated by coherence.arc_phase_mandate (default True; False restores exact pre-mandate behavior for A/B runs), but the rubric records arc_phase ungated so on/off runs stay comparable. The mandate's known gap at commit time (plot beat generation not receiving the phase) was closed the same day by the beat-generation bridge. Contracts stay behind generation.use_contracts (default False) until a plot-first smoke run exercises them live. On contracts, the sanitizer enforces consistency with the beat's tension rather than prohibiting contracts from authoring tension conditions. Two validation findings worth carrying forward: the rising phase ran hotter than the June control (possibly the escalate mandate amplifying the genre's hot bias), and the resolution mandate's close-open-loops clause did not bite (8 loops opened, 0 closed), which is direct evidence for the planned loop-aging pressure.

2026-07-01

Ran a full documentation audit against the current code and fixed the drift. The README's CLI Commands section was missing the shipped novel metrics and novel titles commands, so those got documented. spec.md had accumulated several inaccuracies: it referenced a location.update tool that doesn't exist (corrected to the real location.generate, in both the tools list and the example plan), and its Tech Stack table and API note still described the system as "Codex CLI (GPT-5)" with "API support can be added later" (replaced with the reality of the shipped multi-backend LLM support: Codex / api / claude-cli / gemini-cli, plus hosted-llm). Finally, beats_and_loops.md had stale source line-number citations that rot as code moves; those were swapped for more durable file+function references.

Decisions & notes: Docs-only sweep, no production code touched. Preferring file+function references over line numbers in docs is a deliberate anti-rot choice worth applying elsewhere.

2026-06-20

Scoped test-coverage pass over two pure, self-contained modules to push them to full coverage with no production-code changes. utils/file_ops.py went from 61% to 100% — covering load_schema (success, missing-file, invalid-JSON paths), save_prompt_to_file (content handling, name sanitization, custom subfolder/extension, IOError wrapping), write_json's Unicode preservation (ensure_ascii=False), and the reachable error-wrapping branches in open_file/write_file/read_json. memory/plot_outline.py went from 75% to 100% via a new tests/unit/test_plot_outline.py that exercises every PlotOutlineManager method plus PlotBeat/PlotOutline to_dict/from_dict round-trips. The suite grew from 261 to 299 passing.

Decisions & notes: Tests-only by design — no production code was touched. This is a first targeted pass at the two simplest modules; the picks were deliberately "pure and self-contained" to make 100% coverage cheap and safe.