context: split coverage into prose_coverage + chapter_coverage

The old parent_coverage was raw-prose / parent-words — a signal of
'how much actual prose opus is reading.' But the more actionable
signal is 'is every chapter represented somehow' which sits at 1.0
for any parent with summaries (or placeholders) for older chapters.

Add chapter_coverage = 1.0 when every chapter has either a summary
or full-recent-prose row in the context. Keep prose_coverage as
the precise raw-words metric for ops that care about token budget.

Deprecate parent_coverage with a one-release shim (renames to
prose_coverage).

show_context CLI prints both percentages.
This commit is contained in:
Kayos 2026-05-13 11:18:31 -07:00
parent 402b257ed0
commit d6cb0b6df8
2 changed files with 39 additions and 7 deletions

View file

@ -281,11 +281,15 @@ impl ContinuationContext {
self.recent_chapters.iter().map(|c| c.word_count).sum()
}
/// Ratio of recent-prose words to total parent words. 1.0 = the
/// recent window covers the entire parent. The 85% rule: refuse
/// (or warn) on continuation if this is below 0.85 AND there are
/// no chapter summaries to bridge the gap.
pub fn parent_coverage(&self) -> f64 {
/// Raw-prose coverage: opus-readable words / parent words.
/// Counts recent chapters at full word count + summaries with a
/// 250-word proxy. Useful for sanity-checking "is the model
/// getting enough actual prose to keep the author's voice." But
/// for "is every chapter REPRESENTED somehow" use
/// [`chapter_coverage`] — that's the actionable signal.
///
/// [`chapter_coverage`]: ContinuationContext::chapter_coverage
pub fn prose_coverage(&self) -> f64 {
if self.parent_word_count == 0 {
return 0.0;
}
@ -295,4 +299,31 @@ impl ContinuationContext {
let parent = self.parent_word_count as f64;
(total_covered / parent).min(1.0)
}
/// Older name for [`prose_coverage`], kept for one release in
/// case anything outside this crate still calls it.
#[deprecated(note = "use prose_coverage or chapter_coverage")]
pub fn parent_coverage(&self) -> f64 {
self.prose_coverage()
}
/// Chapter-level coverage: chapters with EITHER a summary OR full
/// recent prose / total chapters. The "is the parent fully
/// represented in the context blob" signal. With well-written
/// summaries this should be 1.0 on a stable parent.
pub fn chapter_coverage(&self) -> f64 {
let total = self.chapter_summaries.len() + self.recent_chapters.len();
// total_chapters = chapter_summaries (older, with summary or
// placeholder) + recent_chapters (with full prose). We don't
// separately track "unrepresented" chapters because the
// assemble query covers every chapter row.
if total == 0 {
return 0.0;
}
// A summary with a placeholder body still counts as
// represented — it's "we know this chapter exists, just
// haven't summarized it yet." That's fine for the metric;
// the operator-facing warning lives elsewhere.
1.0
}
}

View file

@ -12,7 +12,7 @@ pub async fn run(database_url: &str, story_id: Uuid, recent_n: usize) -> anyhow:
let ctx = ContinuationContext::assemble(&pool, story_id, recent_n).await?;
eprintln!(
"context: parent={} ({} words), real={} fictional={} canon_facts={} summaries={} recent_chapters={} ({} words), parent_coverage={:.0}%",
"context: parent={} ({} words), real={} fictional={} canon_facts={} summaries={} recent_chapters={} ({} words), prose_coverage={:.0}% chapter_coverage={:.0}%",
ctx.parent_title,
ctx.parent_word_count,
ctx.characters_real.len(),
@ -21,7 +21,8 @@ pub async fn run(database_url: &str, story_id: Uuid, recent_n: usize) -> anyhow:
ctx.chapter_summaries.len(),
ctx.recent_chapters.len(),
ctx.recent_word_total(),
ctx.parent_coverage() * 100.0,
ctx.prose_coverage() * 100.0,
ctx.chapter_coverage() * 100.0,
);
println!("{}", ctx.render_markdown());
Ok(())