inkhaven 1.4.8

Inkhaven — TUI literary work editor for Typst books
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
//! Reader Personas (RFC §3.6 / §8.17) — the distinct careful-reader perspectives
//! the author switches between. Five bundled personas ship; users add their own as
//! HJSON files. Loading is two-level (project > user > bundled), and the active
//! persona is per-project state. The persona's category **emphasis weights** scale
//! salience (`0.0` mutes a category) and feed the Slow prompt's voice section.
//!
//! The bundled personas are embedded here (zero external assets); user/project
//! personas parse from HJSON via [`parse_persona`].

use std::collections::HashMap;
use std::path::Path;

use serde::Deserialize;

use super::types::{Category, Persona, Stance};
use super::{Result, SocratesError};

/// The on-disk persona format (HJSON). `emphasis` keys are category ids.
#[derive(Debug, Deserialize)]
struct PersonaFile {
    id: String,
    name: String,
    #[serde(default)]
    description: String,
    #[serde(default)]
    voice_summary: String,
    #[serde(default)]
    voice_notes: String,
    #[serde(default)]
    emphasis: HashMap<String, f32>,
    /// Optional delivery stance (`question` | `praise` | `concern`). Absent →
    /// the Socratic default. 1.4.7 AUDIENCE-1.
    #[serde(default)]
    stance: Option<String>,
}

/// Parse a persona from an HJSON string. Unknown emphasis keys are ignored.
pub fn parse_persona(body: &str) -> Result<Persona> {
    let f: PersonaFile =
        serde_hjson::from_str(body).map_err(|e| SocratesError::Parse(e.to_string()))?;
    if f.id.trim().is_empty() {
        return Err(SocratesError::Parse("persona has no id".into()));
    }
    let emphasis = f
        .emphasis
        .into_iter()
        .filter_map(|(k, v)| Category::from_id(&k).map(|c| (c, v)))
        .collect();
    // Unknown stance strings fall back to the Socratic default rather than failing.
    let stance = f
        .stance
        .as_deref()
        .and_then(Stance::from_id)
        .unwrap_or_default();
    Ok(Persona {
        id: f.id,
        name: f.name,
        description: f.description,
        voice_summary: f.voice_summary,
        voice_notes: f.voice_notes,
        emphasis,
        stance,
    })
}

/// The five bundled personas, each calibrated (per the RFC character sheets) to
/// produce a meaningfully different reading.
pub fn bundled() -> Vec<Persona> {
    use Category::*;
    let p = |id: &str, name: &str, summary: &str, notes: &str, emph: &[(Category, f32)]| Persona {
        id: id.into(),
        name: name.into(),
        description: summary.into(),
        voice_summary: summary.into(),
        voice_notes: notes.into(),
        emphasis: emph.iter().copied().collect(),
        stance: Stance::Question,
    };
    vec![
        p(
            "inner-socrates",
            "Inner Socrates",
            "Every question opens what the prose has closed.",
            "You are a classical interrogator. Brief, direct, never reassuring, never prescriptive. \
             You ask what the prose presupposes, what alternatives it closed off, what it leaves out. \
             You respect the author enough to question rather than approve.",
            &[(AssumptionSurfacing, 1.2), (FramingInterrogation, 1.1), (SignificanceProbing, 1.1)],
        ),
        p(
            "careful-editor",
            "The Careful Editor",
            "Notice what the prose is doing — to itself and to the reader's attention.",
            "You read with precision and a gentle, questioning attention to clarity and momentum. \
             You notice when the prose is doing work invisibly. You acknowledge craft while asking \
             about it — a little warmer than Socrates, never less exact.",
            &[(TensionDetection, 1.2), (ImplicitComparison, 1.1), (ModalClaims, 1.3)],
        ),
        p(
            "skeptical-reader",
            "The Skeptical Reader",
            "What's not being said is often louder than what is.",
            "You are probing and slightly adversarial, but fair. You are attentive to omissions and \
             unexamined framings; you refuse the prose's framing at face value and press on what it \
             leaves out — without arguing, without prescribing.",
            &[(AssumptionSurfacing, 1.3), (FramingInterrogation, 1.3), (DramatizationGap, 1.2)],
        ),
        p(
            "first-time-reader",
            "The First-Time Reader",
            "Pretend you've read nothing of this book before this scene.",
            "You are curious and occasionally confused. You are attentive to what the prose assumes \
             about prior knowledge, treating each scene as if encountered for the first time, without \
             context the author has but a new reader does not.",
            &[(AssumptionSurfacing, 1.1), (FramingInterrogation, 0.9), (ImplicitComparison, 0.7)],
        ),
        p(
            "slow-reader",
            "The Slow Reader",
            "The rhythm of prose is doing something. What?",
            "You read at the level of sentence and paragraph, attentive to rhythm, texture, and \
             recurrence — repeated words, patterns of cadence, scenes that echo earlier scenes. You \
             notice how the prose moves rather than only what it claims.",
            &[(StructuralPatterns, 1.3), (SentenceLengthAnomalies, 1.2), (TemporalDensity, 1.3), (ImplicitComparison, 1.2)],
        ),
        // ── Nonfiction personas (1.4.6 AUDIENCE-1) ──
        // All four mute DramatizationGap + TemporalDensity (narrative/scene-time,
        // meaningless in expository prose) and UnattributedDialogue (no dialogue
        // runs). Absent categories default to 1.0, so the mutes are explicit 0.0.
        p(
            "skeptical-practitioner",
            "The Skeptical Practitioner",
            "Every procedure is a reproduction attempt; what did you leave out?",
            "You are a practising engineer who has been burned by incomplete documentation. \
             You read technical prose as a reproduction attempt: every procedure must be \
             complete, every claim must be testable, every assumption must be surfaced. \
             You ask about what is omitted — the error case not mentioned, the prerequisite \
             silently assumed, the configuration value not explained. You never correct; \
             you ask the author what they decided to leave out and why.",
            &[
                (AssumptionSurfacing, 1.4),
                (FramingInterrogation, 1.3),
                (SignificanceProbing, 1.2),
                (ImplicitComparison, 1.1),
                (ModalClaims, 1.2),
                (DramatizationGap, 0.0),
                (TemporalDensity, 0.0),
                (UnattributedDialogue, 0.0),
            ],
        ),
        p(
            "domain-newcomer",
            "The Domain Newcomer",
            "Every undefined term is a door that won't open for me.",
            "You are a careful, motivated reader encountering this subject for the first \
             time. You have no prior knowledge except what the prose has built. Every \
             undefined term is a door that won't open for you. Every concept introduced by \
             example without definition is a guess you must make. You ask about what the \
             author assumed you already know — not to criticise, but to find the place where \
             you, the newcomer, would stop following.",
            &[
                (AssumptionSurfacing, 1.5),
                (SignificanceProbing, 1.1),
                (FramingInterrogation, 1.0),
                (StructuralPatterns, 1.1),
                (ModalClaims, 0.9),
                (TensionDetection, 0.4),
                (DramatizationGap, 0.0),
                (TemporalDensity, 0.0),
                (UnattributedDialogue, 0.0),
            ],
        ),
        p(
            "expert-reviewer",
            "The Expert Reviewer",
            "Does the evidence support the claim, and is the scope stated?",
            "You are a peer reviewer for an academic or technical publication. You are a \
             domain expert who reads for logical rigour: the evidence must support the \
             claim, the scope must be stated, the limitations must be acknowledged. You \
             ask about assertions made without support, comparisons made without stated \
             criteria, causal language applied to correlational evidence, and conclusions \
             that exceed what the evidence establishes. You never suggest how to fix — \
             you ask the question the author must answer before publication.",
            &[
                (AssumptionSurfacing, 1.3),
                (FramingInterrogation, 1.5),
                (SignificanceProbing, 1.3),
                (ImplicitComparison, 1.4),
                (ModalClaims, 1.4),
                (HedgedUncertainty, 1.2),
                (TensionDetection, 0.2),
                (DramatizationGap, 0.0),
                (TemporalDensity, 0.0),
                (UnattributedDialogue, 0.0),
            ],
        ),
        p(
            "end-user",
            "The End User",
            "What do I do next, and how will I know when I'm done?",
            "You are a user following this documentation to complete a task. You are not \
             reading to learn the theory — you need to know what to do, in what order, \
             and what success looks like. You ask where the next step is, what to do when \
             the stated outcome does not happen, whether this step's output is the next \
             step's input, and whether you will know when you are done. You never critique \
             the writing — you ask whether you could follow it.",
            &[
                (SignificanceProbing, 1.5),
                (AssumptionSurfacing, 1.3),
                (StructuralPatterns, 1.3),
                (FramingInterrogation, 1.1),
                (ImplicitComparison, 0.5),
                (TensionDetection, 0.0),
                (DramatizationGap, 0.0),
                (TemporalDensity, 0.0),
                (UnattributedDialogue, 0.0),
            ],
        ),
        // ── Ideas personas (1.4.7 AUDIENCE-1.1) — utopia / philosophy / theology ──
        p(
            "philosophical-reader",
            "The Dialectician",
            "An argument is only as sound as the premise it won't name.",
            "You read philosophical prose for the structure of its argument. You are not an \
             empiricist demanding data — you attend to logic: the premise asserted without \
             being stated, the term that quietly shifts meaning between sentences, the \
             counterexample the author did not address, the conclusion that is valid but \
             rests on an unsound step. You never correct and never rewrite; you ask the \
             question that the argument must answer to stand.",
            &[
                (AssumptionSurfacing, 1.5),
                (FramingInterrogation, 1.4),
                (ImplicitComparison, 1.3),
                (TensionDetection, 1.2),
                (SignificanceProbing, 1.1),
                (ModalClaims, 1.1),
                (DramatizationGap, 0.0),
                (TemporalDensity, 0.0),
                (UnattributedDialogue, 0.0),
            ],
        ),
        p(
            "theological-reader",
            "The Theological Reader",
            "Within the tradition, does it cohere — and does the claim know its own scope?",
            "You read theological prose with care and respect for its own terms. You do NOT \
             demand empirical evidence — the claims rest on revelation, scripture, and \
             tradition, and you take that ground seriously. You attend instead to internal \
             coherence (does this sit with what was said earlier and with the tradition it \
             invokes), to fidelity (is the source represented faithfully), and to the scope \
             of each claim (what is offered as revealed, what as reasoned, what as analogy). \
             You never correct and never prescribe; you ask the question that clarifies, not \
             the one that disputes the faith.",
            &[
                (FramingInterrogation, 1.4),
                (AssumptionSurfacing, 1.3),
                (TensionDetection, 1.2),
                (SignificanceProbing, 1.2),
                (ImplicitComparison, 1.0),
                // Attenuated empirical fast-categories: theology asserts with
                // conviction by nature — don't read that as overclaiming/hedging.
                (ModalClaims, 0.7),
                (HedgedUncertainty, 0.8),
                (DramatizationGap, 0.0),
                (TemporalDensity, 0.0),
                (UnattributedDialogue, 0.0),
            ],
        ),
        p(
            "utopian-architect",
            "The Utopian Architect",
            "The society is an argument. What does it assume, and what does it cost?",
            "You read utopian and dystopian fiction as both a story and a designed argument \
             about how people could live. The narrative still matters — you read it as \
             fiction — but you also press on the society it imagines: what does this world \
             assume about human nature, what alternative arrangement does it quietly \
             foreclose, and what cost does the ideal elide or the dystopia exaggerate. You \
             never correct and never rewrite; you ask what the imagined order presupposes.",
            &[
                // Argument weighting — heavy on what the society assumes / forecloses…
                (AssumptionSurfacing, 1.5),
                (ImplicitComparison, 1.4),
                (SignificanceProbing, 1.3),
                (FramingInterrogation, 1.2),
                (TensionDetection, 1.1),
                // …but the narrative categories stay at DEFAULT (1.0): this is the
                // hybrid invariant — a utopia is still fiction, so it is NOT muted.
            ],
        ),
        // ── Verdict personas (1.4.7 AUDIENCE-1) — the two adversaries ──
        // These deliberately break the Socratic "questions only, never praise /
        // prescribe" spine (see `Stance`): the Defender speaks ONLY praise, the
        // Prosecutor ONLY concern. Emphasis is left empty (every category live —
        // they may praise or charge any dimension); the stance drives the verdict.
        Persona {
            stance: Stance::Praise,
            ..p(
                "defender",
                "The Defender",
                "Counsel for the defense — only what works, and why to protect it.",
                "You are counsel for the defense for this passage. You read for what works and \
                 speak only of that — the strength, the effect the prose achieves, the choice \
                 that earns its place and should be protected. You raise no concerns and \
                 propose no changes; you make the case for the writing as it stands. Be \
                 specific and grounded — praise an actual move in the text, never a generic \
                 compliment.",
                &[],
            )
        },
        Persona {
            stance: Stance::Concern,
            ..p(
                "prosecutor",
                "The Prosecutor",
                "The prosecution — only what fails, stated as the charge.",
                "You are the prosecution. You read for what fails and name only that — the weak \
                 claim, the lazy line, the unearned beat, the soft generalization, the image \
                 that overstates. You offer no praise and no remedy; you state the charge and \
                 let it stand. Be specific and grounded — point at an actual phrase or move, \
                 never a vague dissatisfaction.",
                &[],
            )
        },
    ]
}

/// All available personas for a project: bundled, overlaid by user-level
/// (`~/.config/inkhaven/personas/`), overlaid by project-level
/// (`<project>/books/intent/01-personas/`). Later levels win by `id`.
pub fn load_all(project: &Path) -> Vec<Persona> {
    let mut by_id: indexmap_lite::OrderMap = indexmap_lite::OrderMap::new();
    for p in bundled() {
        by_id.insert(p.id.clone(), p);
    }
    for dir in [super::user_config::personas_dir(), Some(project_personas_dir(project))] {
        let Some(dir) = dir else { continue };
        for p in load_dir(&dir) {
            by_id.insert(p.id.clone(), p);
        }
    }
    by_id.into_values()
}

/// Project-level persona directory.
pub fn project_personas_dir(project: &Path) -> std::path::PathBuf {
    project.join("books").join("intent").join("01-personas")
}

/// Parse every `*.hjson` persona file in a directory (skips unreadable / invalid).
fn load_dir(dir: &Path) -> Vec<Persona> {
    let Ok(entries) = std::fs::read_dir(dir) else {
        return Vec::new();
    };
    let mut out = Vec::new();
    for e in entries.flatten() {
        let path = e.path();
        if path.extension().and_then(|x| x.to_str()) != Some("hjson") {
            continue;
        }
        if let Ok(body) = std::fs::read_to_string(&path) {
            if let Ok(p) = parse_persona(&body) {
                out.push(p);
            }
        }
    }
    out
}

/// Look up one persona by id from the loaded set (falls back to the default).
pub fn by_id(project: &Path, id: &str) -> Persona {
    load_all(project).into_iter().find(|p| p.id == id).unwrap_or_else(Persona::default_inner_socrates)
}

/// The active persona for a project: the one the store records as active; else
/// the project config's `inner_socrates_default_persona` (AUDIENCE-1, e.g. a
/// technical book defaulting to `skeptical-practitioner`); else the bundled
/// `inner-socrates`. An explicit `set_active_persona` always wins over config.
pub fn active(project: &Path) -> Persona {
    let id = super::storage::InnerSocratesStore::open_for_project(project)
        .ok()
        .and_then(|s| s.active_persona_id().ok().flatten());
    match id {
        Some(id) => by_id(project, &id),
        None => match config_default_persona(project) {
            Some(id) => by_id(project, &id),
            None => Persona::default_inner_socrates(),
        },
    }
}

/// The `inner_socrates_default_persona` config value for a project, trimmed and
/// non-empty, if set. Best-effort — a missing / unreadable config yields `None`.
/// Public so the TUI overview can flag when the active persona came from config.
pub fn config_default_persona(project: &Path) -> Option<String> {
    crate::config::Config::load_layered(
        &crate::project::ProjectLayout::new(project).config_path(),
    )
    .ok()
    .and_then(|c| c.inner_socrates_default_persona)
    .map(|s| s.trim().to_string())
    .filter(|s| !s.is_empty())
}

/// A tiny insertion-ordered map (avoids a new dependency on `indexmap`).
mod indexmap_lite {
    use super::Persona;

    pub struct OrderMap {
        order: Vec<String>,
        map: std::collections::HashMap<String, Persona>,
    }
    impl OrderMap {
        pub fn new() -> Self {
            Self { order: Vec::new(), map: std::collections::HashMap::new() }
        }
        pub fn insert(&mut self, k: String, v: Persona) {
            if !self.map.contains_key(&k) {
                self.order.push(k.clone());
            }
            self.map.insert(k, v);
        }
        pub fn into_values(mut self) -> Vec<Persona> {
            self.order.iter().filter_map(|k| self.map.remove(k)).collect()
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn fourteen_distinct_bundled_personas() {
        let ps = bundled();
        // 5 fiction (1.3.x) + 4 nonfiction (1.4.6 AUDIENCE-1)
        // + 3 ideas (1.4.7 AUDIENCE-1.1) + 2 verdict adversaries (1.4.7).
        assert_eq!(ps.len(), 14);
        let ids: std::collections::BTreeSet<_> = ps.iter().map(|p| p.id.as_str()).collect();
        assert_eq!(ids.len(), 14);
        assert!(ids.contains("inner-socrates"));
        // Personas weight different categories (they read differently).
        let socr = ps.iter().find(|p| p.id == "inner-socrates").unwrap();
        let slow = ps.iter().find(|p| p.id == "slow-reader").unwrap();
        assert!(socr.emphasis_for(Category::AssumptionSurfacing) > 1.0);
        assert!(slow.emphasis_for(Category::StructuralPatterns) > 1.0);
        assert!(socr.emphasis_for(Category::StructuralPatterns) < slow.emphasis_for(Category::StructuralPatterns));
    }

    #[test]
    fn nonfiction_personas_mute_narrative_categories() {
        let ps = bundled();
        // The four nonfiction personas exist…
        for id in ["skeptical-practitioner", "domain-newcomer", "expert-reviewer", "end-user"] {
            let p = ps.iter().find(|p| p.id == id)
                .unwrap_or_else(|| panic!("missing nonfiction persona `{id}`"));
            // …and each mutes the fiction-only categories.
            assert!(p.mutes(Category::DramatizationGap), "{id} should mute DramatizationGap");
            assert!(p.mutes(Category::TemporalDensity), "{id} should mute TemporalDensity");
            assert!(p.mutes(Category::UnattributedDialogue), "{id} should mute UnattributedDialogue");
        }
    }

    #[test]
    fn verdict_personas_carry_their_stance() {
        use super::super::types::Stance;
        let ps = bundled();
        let get = |id: &str| ps.iter().find(|p| p.id == id)
            .unwrap_or_else(|| panic!("missing verdict persona `{id}`")).clone();

        let def = get("defender");
        assert_eq!(def.stance, Stance::Praise);
        assert!(def.stance.is_verdict());

        let pros = get("prosecutor");
        assert_eq!(pros.stance, Stance::Concern);
        assert!(pros.stance.is_verdict());

        // Every OTHER bundled persona keeps the Socratic question stance.
        for p in ps.iter().filter(|p| p.id != "defender" && p.id != "prosecutor") {
            assert_eq!(p.stance, Stance::Question, "{} must stay Socratic", p.id);
            assert!(!p.stance.is_verdict());
        }
    }

    #[test]
    fn stance_parses_from_hjson_with_aliases_and_default() {
        use super::super::types::Stance;
        // Explicit stance + alias forms.
        let praise = parse_persona(r#"{ id: "x" name: "X" stance: "praise" }"#).unwrap();
        assert_eq!(praise.stance, Stance::Praise);
        let pros = parse_persona(r#"{ id: "y" name: "Y" stance: "prosecution" }"#).unwrap();
        assert_eq!(pros.stance, Stance::Concern);
        // Absent → Socratic default; unknown → falls back to default, not an error.
        let none = parse_persona(r#"{ id: "z" name: "Z" }"#).unwrap();
        assert_eq!(none.stance, Stance::Question);
        let bogus = parse_persona(r#"{ id: "w" name: "W" stance: "shouting" }"#).unwrap();
        assert_eq!(bogus.stance, Stance::Question);
    }

    #[test]
    fn ideas_personas_calibrate_correctly() {
        let ps = bundled();
        let get = |id: &str| ps.iter().find(|p| p.id == id)
            .unwrap_or_else(|| panic!("missing ideas persona `{id}`")).clone();

        // philosophical-reader: argument-structure reader, narrative muted.
        let phil = get("philosophical-reader");
        assert_eq!(phil.emphasis_for(Category::AssumptionSurfacing), 1.5);
        assert!(phil.emphasis_for(Category::ImplicitComparison) > 1.0); // counterarguments
        assert!(phil.mutes(Category::DramatizationGap));
        assert!(phil.mutes(Category::TemporalDensity));

        // theological-reader: non-empiricist — the empirical fast categories are
        // ATTENUATED (not hammered), narrative muted, coherence/framing lead.
        let theo = get("theological-reader");
        assert_eq!(theo.emphasis_for(Category::FramingInterrogation), 1.4);
        assert!(theo.emphasis_for(Category::ModalClaims) < 1.0, "modal must be attenuated, not boosted");
        assert!(theo.emphasis_for(Category::HedgedUncertainty) < 1.0);
        assert!(theo.mutes(Category::DramatizationGap));

        // utopian-architect: HYBRID — argument-weighted but narrative stays LIVE.
        let uto = get("utopian-architect");
        assert_eq!(uto.emphasis_for(Category::AssumptionSurfacing), 1.5);
        assert!(uto.emphasis_for(Category::ImplicitComparison) > 1.0); // foreclosed alternatives
        // The hybrid invariant: a utopia is still fiction — narrative NOT muted.
        assert!(!uto.mutes(Category::DramatizationGap), "utopian-architect must keep narrative live");
        assert!(!uto.mutes(Category::TemporalDensity));
        assert!(!uto.mutes(Category::UnattributedDialogue));
        assert_eq!(uto.emphasis_for(Category::DramatizationGap), 1.0); // default, untouched
    }

    #[test]
    fn nonfiction_persona_emphasis_matches_character_sheets() {
        let ps = bundled();
        let get = |id: &str| ps.iter().find(|p| p.id == id).unwrap().clone();
        // skeptical-practitioner leans hardest on surfacing what's omitted.
        let prac = get("skeptical-practitioner");
        assert_eq!(prac.emphasis_for(Category::AssumptionSurfacing), 1.4);
        assert_eq!(prac.emphasis_for(Category::FramingInterrogation), 1.3);
        // domain-newcomer: assumptions about the reader are paramount.
        let newc = get("domain-newcomer");
        assert_eq!(newc.emphasis_for(Category::AssumptionSurfacing), 1.5);
        assert!(newc.emphasis_for(Category::TensionDetection) < 1.0); // attenuated, not muted
        // expert-reviewer: scope precision dominates.
        let rev = get("expert-reviewer");
        assert_eq!(rev.emphasis_for(Category::FramingInterrogation), 1.5);
        assert_eq!(rev.emphasis_for(Category::ImplicitComparison), 1.4);
        // end-user: "what does this step accomplish?" leads.
        let usr = get("end-user");
        assert_eq!(usr.emphasis_for(Category::SignificanceProbing), 1.5);
        assert!(usr.mutes(Category::TensionDetection)); // task-followers don't read for tension
    }

    #[test]
    fn parses_a_persona_file() {
        let body = r#"{
            id: "my-grandmother"
            name: "My Skeptical Grandmother"
            voice_summary: "She has read everything and believes none of it."
            emphasis: {
                framing_interrogation: 1.5
                hedged_uncertainty: 0.0
                not_a_real_category: 2.0
            }
        }"#;
        let p = parse_persona(body).unwrap();
        assert_eq!(p.id, "my-grandmother");
        assert_eq!(p.emphasis_for(Category::FramingInterrogation), 1.5);
        assert!(p.mutes(Category::HedgedUncertainty));
        // Unknown emphasis keys are dropped.
        assert_eq!(p.emphasis.len(), 2);
    }

    #[test]
    fn rejects_persona_without_id() {
        assert!(parse_persona(r#"{ name: "Nameless" }"#).is_err());
    }

    // ── AUDIENCE-1: config-default persona resolution ──

    #[test]
    fn active_falls_back_to_config_default_then_inner_socrates() {
        // No config, no DB row → bundled inner-socrates.
        let bare = tempfile::tempdir().unwrap();
        assert_eq!(active(bare.path()).id, "inner-socrates");

        // Config default set, no explicit DB row → the config default persona.
        let cfg_dir = tempfile::tempdir().unwrap();
        std::fs::write(
            cfg_dir.path().join("inkhaven.hjson"),
            "{\n  inner_socrates_default_persona: skeptical-practitioner\n}\n",
        )
        .unwrap();
        assert_eq!(active(cfg_dir.path()).id, "skeptical-practitioner");
        assert_eq!(
            config_default_persona(cfg_dir.path()).as_deref(),
            Some("skeptical-practitioner")
        );
    }

    #[test]
    fn explicit_active_persona_beats_config_default() {
        let dir = tempfile::tempdir().unwrap();
        std::fs::write(
            dir.path().join("inkhaven.hjson"),
            "{\n  inner_socrates_default_persona: skeptical-practitioner\n}\n",
        )
        .unwrap();
        // An explicit `persona set` writes the DB singleton…
        super::super::storage::InnerSocratesStore::open_for_project(dir.path())
            .unwrap()
            .set_active_persona("expert-reviewer")
            .unwrap();
        // …and wins over the config default.
        assert_eq!(active(dir.path()).id, "expert-reviewer");
    }
}