Skip to main content

codewhale_core/
fragments.rs

1//! Bounded context-fragment system with hard caps (issue #5264).
2//!
3//! Every context injection goes through a typed fragment with a
4//! `matches_text` recognizer, collected in one `crates/core` module.
5//! Hard caps: per-fragment byte cap, 10K-token ceiling, injected-item count.
6//! Project-instruction import (#3978, #4079) is a typed fragment.
7
8use std::collections::hash_map::DefaultHasher;
9use std::hash::{Hash, Hasher};
10use std::path::{Path, PathBuf};
11
12// Caps
13pub const MAX_FRAGMENT_TOKENS: usize = 10_000;
14pub const MAX_FRAGMENT_BYTES: usize = MAX_FRAGMENT_TOKENS * 4; // 40_000
15pub const DEFAULT_FRAGMENT_MAX_BYTES: usize = 4 * 1024;
16pub const MAX_FRAGMENTS_PER_CONTEXT: usize = 16;
17pub const INSTRUCTIONS_FILE_MAX_BYTES: usize = 100 * 1024;
18pub const MAX_INSTRUCTION_FILES: usize = 32;
19
20/// Stable fragment identities. Markers are public contract.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
22pub enum FragmentId {
23    Workspace,
24    Permissions,
25    Route,
26    AgentTopology,
27    SkillsTools,
28    TokenBudget,
29    ProjectInstructions,
30    Constitution,
31}
32
33impl FragmentId {
34    #[must_use]
35    pub fn as_str(self) -> &'static str {
36        match self {
37            Self::Workspace => "workspace",
38            Self::Permissions => "permissions",
39            Self::Route => "route",
40            Self::AgentTopology => "agent_topology",
41            Self::SkillsTools => "skills_tools",
42            Self::TokenBudget => "token_budget",
43            Self::ProjectInstructions => "project_instructions",
44            Self::Constitution => "constitution",
45        }
46    }
47    #[must_use]
48    pub fn marker(self) -> &'static str {
49        match self {
50            Self::Workspace => "<!-- cw:ctx:workspace -->",
51            Self::Permissions => "<!-- cw:ctx:permissions -->",
52            Self::Route => "<!-- cw:ctx:route -->",
53            Self::AgentTopology => "<!-- cw:ctx:agent_topology -->",
54            Self::SkillsTools => "<!-- cw:ctx:skills_tools -->",
55            Self::TokenBudget => "<!-- cw:ctx:token_budget -->",
56            Self::ProjectInstructions => "<!-- cw:ctx:project_instructions -->",
57            Self::Constitution => "<!-- cw:ctx:constitution -->",
58        }
59    }
60    #[must_use]
61    pub fn role(self) -> FragmentRole {
62        match self {
63            Self::Workspace => FragmentRole::Workspace,
64            Self::Permissions => FragmentRole::Permissions,
65            Self::Route => FragmentRole::Route,
66            Self::AgentTopology => FragmentRole::AgentTopology,
67            Self::SkillsTools => FragmentRole::SkillsTools,
68            Self::TokenBudget => FragmentRole::TokenBudget,
69            Self::ProjectInstructions => FragmentRole::ProjectInstructions,
70            Self::Constitution => FragmentRole::Constitution,
71        }
72    }
73    #[must_use]
74    pub fn all() -> &'static [FragmentId] {
75        &[
76            Self::Workspace,
77            Self::Permissions,
78            Self::Route,
79            Self::AgentTopology,
80            Self::SkillsTools,
81            Self::TokenBudget,
82            Self::ProjectInstructions,
83            Self::Constitution,
84        ]
85    }
86}
87
88#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
89pub enum FragmentRole {
90    Workspace,
91    Permissions,
92    Route,
93    AgentTopology,
94    SkillsTools,
95    TokenBudget,
96    ProjectInstructions,
97    Constitution,
98}
99
100impl FragmentRole {
101    #[must_use]
102    pub fn as_str(self) -> &'static str {
103        match self {
104            Self::Workspace => "workspace",
105            Self::Permissions => "permissions",
106            Self::Route => "route",
107            Self::AgentTopology => "agent_topology",
108            Self::SkillsTools => "skills_tools",
109            Self::TokenBudget => "token_budget",
110            Self::ProjectInstructions => "project_instructions",
111            Self::Constitution => "constitution",
112        }
113    }
114}
115
116#[must_use]
117pub fn estimate_tokens(text: &str) -> usize {
118    text.len().div_ceil(4)
119}
120
121/// Typed fragment trait with `matches_text` recognizer.
122pub trait ContextFragment {
123    fn fragment_id(&self) -> FragmentId;
124    fn marker(&self) -> &'static str;
125    fn content(&self) -> &str;
126    fn matches_text(&self, haystack: &str) -> bool {
127        haystack.contains(self.marker())
128    }
129    fn tokens_est(&self) -> usize {
130        estimate_tokens(self.content())
131    }
132    fn max_bytes(&self) -> usize;
133    fn is_within_token_ceiling(&self) -> bool {
134        self.tokens_est() <= MAX_FRAGMENT_TOKENS
135    }
136    fn is_within_byte_ceiling(&self) -> bool {
137        self.content().len() <= MAX_FRAGMENT_BYTES
138    }
139}
140
141#[derive(Debug, Clone, PartialEq, Eq)]
142pub struct BoundedFragment {
143    pub id: FragmentId,
144    pub role: FragmentRole,
145    pub marker: &'static str,
146    pub max_bytes: usize,
147    pub content: String,
148    pub content_hash: u64,
149}
150
151impl BoundedFragment {
152    #[must_use]
153    pub fn new(id: FragmentId, raw: impl Into<String>) -> Self {
154        Self::with_max_bytes(id, raw, DEFAULT_FRAGMENT_MAX_BYTES)
155    }
156    #[must_use]
157    pub fn with_max_bytes(id: FragmentId, raw: impl Into<String>, max_bytes: usize) -> Self {
158        let clamped_max = max_bytes.min(MAX_FRAGMENT_BYTES);
159        let mut content = enforce_byte_cap(raw.into(), clamped_max);
160        if estimate_tokens(&content) > MAX_FRAGMENT_TOKENS {
161            content = enforce_byte_cap(content, MAX_FRAGMENT_BYTES);
162        }
163        let content_hash = hash_content(&content);
164        Self {
165            id,
166            role: id.role(),
167            marker: id.marker(),
168            max_bytes: clamped_max,
169            content,
170            content_hash,
171        }
172    }
173    #[must_use]
174    pub fn project_instructions(raw: impl Into<String>) -> Self {
175        Self::with_max_bytes(FragmentId::ProjectInstructions, raw, MAX_FRAGMENT_BYTES)
176    }
177    #[must_use]
178    pub fn constitution(raw: impl Into<String>) -> Self {
179        Self::with_max_bytes(FragmentId::Constitution, raw, MAX_FRAGMENT_BYTES)
180    }
181    #[must_use]
182    pub fn render_marked(&self) -> String {
183        format!("{}\n{}", self.marker, self.content.trim_end())
184    }
185}
186
187impl ContextFragment for BoundedFragment {
188    fn fragment_id(&self) -> FragmentId {
189        self.id
190    }
191    fn marker(&self) -> &'static str {
192        self.marker
193    }
194    fn content(&self) -> &str {
195        &self.content
196    }
197    fn max_bytes(&self) -> usize {
198        self.max_bytes
199    }
200}
201
202#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
203pub enum FragmentCapError {
204    #[error("fragment {id:?} exceeds 10K-token ceiling: {tokens} tokens ({bytes} bytes)")]
205    TokenCeiling {
206        id: FragmentId,
207        tokens: usize,
208        bytes: usize,
209    },
210    #[error("fragment {id:?} exceeds byte ceiling: {bytes} > {max} bytes")]
211    ByteCeiling {
212        id: FragmentId,
213        bytes: usize,
214        max: usize,
215    },
216    #[error("context has too many fragments: {count} > {max}")]
217    TooManyFragments { count: usize, max: usize },
218}
219
220pub fn validate_fragment(fragment: &BoundedFragment) -> Result<(), FragmentCapError> {
221    if fragment.content.len() > MAX_FRAGMENT_BYTES {
222        return Err(FragmentCapError::ByteCeiling {
223            id: fragment.id,
224            bytes: fragment.content.len(),
225            max: MAX_FRAGMENT_BYTES,
226        });
227    }
228    let tokens = estimate_tokens(&fragment.content);
229    if tokens > MAX_FRAGMENT_TOKENS {
230        return Err(FragmentCapError::TokenCeiling {
231            id: fragment.id,
232            bytes: fragment.content.len(),
233            tokens,
234        });
235    }
236    Ok(())
237}
238
239pub fn validate_fragment_set(fragments: &[BoundedFragment]) -> Result<(), FragmentCapError> {
240    if fragments.len() > MAX_FRAGMENTS_PER_CONTEXT {
241        return Err(FragmentCapError::TooManyFragments {
242            count: fragments.len(),
243            max: MAX_FRAGMENTS_PER_CONTEXT,
244        });
245    }
246    for f in fragments {
247        validate_fragment(f)?;
248    }
249    Ok(())
250}
251
252// Project-instruction import (#3978)
253pub const PROJECT_INSTRUCTION_CANDIDATES: &[&str] = &[
254    "AGENTS.md",
255    ".agents/AGENTS.md",
256    "CLAUDE.md",
257    ".claude/instructions.md",
258    ".codewhale/instructions.md",
259    ".deepseek/instructions.md",
260    ".cursorrules",
261    ".cursor/rules",
262    ".clinerules",
263    ".windsurf/rules",
264    ".gemini",
265    ".github/copilot-instructions.md",
266    ".github/muse-instructions.md",
267];
268
269/// Workspace instruction formats not already owned by Codewhale's canonical
270/// project-context loader. The TUI uses this subset to avoid injecting
271/// `AGENTS.md` / `CLAUDE.md` / `instructions.md` twice while still importing
272/// additional agent rule formats through the typed fragment boundary.
273pub const ADDITIONAL_PROJECT_INSTRUCTION_CANDIDATES: &[&str] = &[
274    ".agents/AGENTS.md",
275    ".cursorrules",
276    ".cursor/rules",
277    ".clinerules",
278    ".windsurf/rules",
279    ".gemini",
280    ".github/copilot-instructions.md",
281    ".github/muse-instructions.md",
282];
283
284fn is_symlink(p: &Path) -> bool {
285    std::fs::symlink_metadata(p)
286        .map(|m| m.file_type().is_symlink())
287        .unwrap_or(false)
288}
289fn read_capped(p: &Path) -> Option<String> {
290    let meta = std::fs::metadata(p).ok()?;
291    if !meta.is_file() {
292        return None;
293    }
294    if meta.len() > INSTRUCTIONS_FILE_MAX_BYTES as u64 {
295        let mut file = std::fs::File::open(p).ok()?;
296        let mut buf = vec![0u8; INSTRUCTIONS_FILE_MAX_BYTES];
297        use std::io::Read as _;
298        let n = file.read(&mut buf).ok()?;
299        buf.truncate(n);
300        let mut text = String::from_utf8_lossy(&buf).into_owned();
301        let mut end = INSTRUCTIONS_FILE_MAX_BYTES.min(text.len());
302        while end > 0 && !text.is_char_boundary(end) {
303            end -= 1;
304        }
305        text.truncate(end);
306        let omitted = meta
307            .len()
308            .saturating_sub(INSTRUCTIONS_FILE_MAX_BYTES as u64);
309        text.push_str(&format!("\n[…truncated: {omitted} bytes omitted]"));
310        return Some(text);
311    }
312    let raw = std::fs::read_to_string(p).ok()?;
313    let trimmed = raw.trim();
314    if trimmed.is_empty() {
315        None
316    } else {
317        Some(trimmed.to_string())
318    }
319}
320fn collect_candidate_files(workspace: &Path, candidates: &[&str]) -> Vec<PathBuf> {
321    let mut files = Vec::new();
322    for candidate in candidates {
323        let path = workspace.join(candidate);
324        if path.is_dir() {
325            let mut dir_files = Vec::new();
326            if let Ok(entries) = std::fs::read_dir(&path) {
327                for e in entries.flatten() {
328                    let p = e.path();
329                    if p.is_file() && p.extension().is_some_and(|e| e == "md") && !is_symlink(&p) {
330                        dir_files.push(p);
331                    }
332                }
333            }
334            if let Ok(entries) = std::fs::read_dir(&path) {
335                for e in entries.flatten() {
336                    let p = e.path();
337                    if p.is_dir()
338                        && !is_symlink(&p)
339                        && let Ok(sub) = std::fs::read_dir(&p)
340                    {
341                        for se in sub.flatten() {
342                            let sp = se.path();
343                            if sp.is_file()
344                                && sp.extension().is_some_and(|e| e == "md")
345                                && !is_symlink(&sp)
346                            {
347                                dir_files.push(sp);
348                            }
349                        }
350                    }
351                }
352            }
353            dir_files.sort();
354            let remaining = MAX_INSTRUCTION_FILES.saturating_sub(files.len());
355            dir_files.truncate(remaining);
356            files.extend(dir_files);
357        } else if path.is_file() && !is_symlink(&path) {
358            files.push(path);
359        }
360        if files.len() >= MAX_INSTRUCTION_FILES {
361            break;
362        }
363    }
364    files.truncate(MAX_INSTRUCTION_FILES);
365    files.sort();
366    files.dedup();
367    files
368}
369
370fn load_project_instruction_fragment_from_candidates(
371    workspace: &Path,
372    candidates: &[&str],
373) -> Option<BoundedFragment> {
374    let files = collect_candidate_files(workspace, candidates);
375    if files.is_empty() {
376        return None;
377    }
378    let mut sections = Vec::new();
379    for path in files {
380        if let Some(content) = read_capped(&path) {
381            let rel = path
382                .strip_prefix(workspace)
383                .unwrap_or(&path)
384                .display()
385                .to_string();
386            sections.push(format!(
387                "<project_instructions source=\"{rel}\">\n{content}\n</project_instructions>"
388            ));
389        }
390    }
391    if sections.is_empty() {
392        return None;
393    }
394    let merged = sections.join("\n\n");
395    let fragment = BoundedFragment::project_instructions(merged);
396    debug_assert!(validate_fragment(&fragment).is_ok());
397    Some(fragment)
398}
399
400pub fn load_project_instruction_fragment(workspace: &Path) -> Option<BoundedFragment> {
401    load_project_instruction_fragment_from_candidates(workspace, PROJECT_INSTRUCTION_CANDIDATES)
402}
403
404/// Load only instruction formats that the canonical TUI project-context path
405/// does not already render. This prevents duplicate authority while retaining
406/// the broader compatibility import added by the bounded fragment system.
407pub fn load_additional_project_instruction_fragment(workspace: &Path) -> Option<BoundedFragment> {
408    load_project_instruction_fragment_from_candidates(
409        workspace,
410        ADDITIONAL_PROJECT_INSTRUCTION_CANDIDATES,
411    )
412}
413
414pub fn project_instructions_from_sources(
415    sources: impl IntoIterator<Item = (String, String)>,
416) -> Option<BoundedFragment> {
417    let mut sections = Vec::new();
418    for (name, content) in sources {
419        let trimmed = content.trim();
420        if trimmed.is_empty() {
421            continue;
422        }
423        let body = if trimmed.len() > INSTRUCTIONS_FILE_MAX_BYTES {
424            let mut end = INSTRUCTIONS_FILE_MAX_BYTES;
425            while end > 0 && !trimmed.is_char_boundary(end) {
426                end -= 1;
427            }
428            let omitted = trimmed.len() - end;
429            format!("{}\n[…truncated: {omitted} bytes omitted]", &trimmed[..end])
430        } else {
431            trimmed.to_string()
432        };
433        sections.push(format!(
434            "<project_instructions source=\"{name}\">\n{body}\n</project_instructions>"
435        ));
436        if sections.len() >= MAX_INSTRUCTION_FILES {
437            break;
438        }
439    }
440    if sections.is_empty() {
441        return None;
442    }
443    Some(BoundedFragment::project_instructions(sections.join("\n\n")))
444}
445
446#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
447pub struct FragmentBudgetSnapshot {
448    pub fragment_ids: Vec<String>,
449    pub fragment_markers: Vec<String>,
450    pub max_fragment_bytes: usize,
451    pub max_fragment_tokens: usize,
452    pub default_fragment_max_bytes: usize,
453    pub max_fragments_per_context: usize,
454    pub instructions_file_max_bytes: usize,
455    pub max_instruction_files: usize,
456    pub project_instruction_candidates: Vec<String>,
457}
458
459#[must_use]
460pub fn fragment_budget_snapshot() -> FragmentBudgetSnapshot {
461    FragmentBudgetSnapshot {
462        fragment_ids: FragmentId::all()
463            .iter()
464            .map(|id| id.as_str().to_string())
465            .collect(),
466        fragment_markers: FragmentId::all()
467            .iter()
468            .map(|id| id.marker().to_string())
469            .collect(),
470        max_fragment_bytes: MAX_FRAGMENT_BYTES,
471        max_fragment_tokens: MAX_FRAGMENT_TOKENS,
472        default_fragment_max_bytes: DEFAULT_FRAGMENT_MAX_BYTES,
473        max_fragments_per_context: MAX_FRAGMENTS_PER_CONTEXT,
474        instructions_file_max_bytes: INSTRUCTIONS_FILE_MAX_BYTES,
475        max_instruction_files: MAX_INSTRUCTION_FILES,
476        project_instruction_candidates: PROJECT_INSTRUCTION_CANDIDATES
477            .iter()
478            .map(|s| s.to_string())
479            .collect(),
480    }
481}
482
483fn hash_content(content: &str) -> u64 {
484    let mut hasher = DefaultHasher::new();
485    content.hash(&mut hasher);
486    hasher.finish()
487}
488fn enforce_byte_cap(raw: String, max_bytes: usize) -> String {
489    if max_bytes == 0 {
490        return String::new();
491    }
492    if raw.len() <= max_bytes {
493        return raw;
494    }
495    let omitted = raw.len().saturating_sub(max_bytes);
496    let marker = format!("\n[…truncated: {omitted} bytes omitted]");
497    if marker.len() >= max_bytes {
498        return marker.chars().take(max_bytes).collect();
499    }
500    let keep = max_bytes.saturating_sub(marker.len());
501    let mut end = keep;
502    while end > 0 && !raw.is_char_boundary(end) {
503        end -= 1;
504    }
505    let mut out = raw[..end].to_string();
506    out.push_str(&marker);
507    out
508}
509
510#[cfg(test)]
511mod tests {
512    use super::*;
513    use std::fs;
514    use tempfile::tempdir;
515
516    #[test]
517    fn fragment_has_matches_text_recognizer() {
518        let fragment = BoundedFragment::new(FragmentId::Workspace, "repo: /tmp/demo");
519        let rendered = fragment.render_marked();
520        assert!(fragment.matches_text(&rendered));
521        assert!(!fragment.matches_text("no marker here"));
522        assert_eq!(FragmentId::Workspace.marker(), "<!-- cw:ctx:workspace -->");
523        assert_eq!(
524            FragmentId::ProjectInstructions.marker(),
525            "<!-- cw:ctx:project_instructions -->"
526        );
527        assert_eq!(
528            FragmentId::Constitution.marker(),
529            "<!-- cw:ctx:constitution -->"
530        );
531    }
532    #[test]
533    fn all_fragment_types_go_through_bounded_module() {
534        for id in FragmentId::all() {
535            let fragment = BoundedFragment::new(*id, "hello");
536            assert_eq!(fragment.marker, id.marker());
537            assert_eq!(fragment.id, *id);
538            validate_fragment(&fragment).expect("small fragment must pass caps");
539            assert!(fragment.is_within_token_ceiling());
540            assert!(fragment.is_within_byte_ceiling());
541        }
542    }
543    #[test]
544    fn per_fragment_byte_cap_truncates_with_marker() {
545        let oversized = "x".repeat(DEFAULT_FRAGMENT_MAX_BYTES + 64);
546        let fragment = BoundedFragment::new(FragmentId::AgentTopology, oversized);
547        assert!(fragment.content.len() <= DEFAULT_FRAGMENT_MAX_BYTES);
548        assert!(fragment.content.contains("[…truncated:"));
549        validate_fragment(&fragment).expect("truncated fragment must pass caps");
550    }
551    #[test]
552    fn ten_k_token_ceiling_is_enforced() {
553        let huge = "a".repeat(MAX_FRAGMENT_BYTES + 1_000);
554        let fragment = BoundedFragment::project_instructions(huge);
555        assert!(fragment.content.len() <= MAX_FRAGMENT_BYTES);
556        assert!(estimate_tokens(&fragment.content) <= MAX_FRAGMENT_TOKENS);
557        validate_fragment(&fragment).expect("capped fragment must satisfy token ceiling");
558        let also_huge = "b".repeat(MAX_FRAGMENT_BYTES + 5000);
559        let fragment = BoundedFragment::with_max_bytes(FragmentId::Workspace, also_huge, 100_000);
560        assert!(fragment.max_bytes <= MAX_FRAGMENT_BYTES);
561        assert!(fragment.content.len() <= MAX_FRAGMENT_BYTES);
562        assert!(fragment.is_within_token_ceiling());
563    }
564    #[test]
565    fn injected_item_count_cap_is_enforced() {
566        let fragments: Vec<BoundedFragment> = (0..MAX_FRAGMENTS_PER_CONTEXT)
567            .map(|i| BoundedFragment::new(FragmentId::Workspace, format!("item {i}")))
568            .collect();
569        validate_fragment_set(&fragments).expect("exactly MAX_FRAGMENTS must pass");
570        let mut too_many = fragments.clone();
571        too_many.push(BoundedFragment::new(FragmentId::Route, "one too many"));
572        let err = validate_fragment_set(&too_many).expect_err("one over cap must fail");
573        assert!(matches!(err, FragmentCapError::TooManyFragments { .. }));
574    }
575    #[test]
576    fn project_instruction_import_is_a_typed_fragment() {
577        let dir = tempdir().expect("tempdir");
578        let ws = dir.path();
579        fs::write(ws.join(".cursorrules"), "cursor: always use tabs").expect("write cursor");
580        fs::write(ws.join(".clinerules"), "cline: prefer functional style").expect("write cline");
581        fs::create_dir_all(ws.join(".windsurf").join("rules")).expect("mkdir windsurf");
582        fs::write(
583            ws.join(".windsurf").join("rules").join("extra.md"),
584            "# windsurf extra",
585        )
586        .expect("write windsurf");
587        fs::create_dir_all(ws.join(".github")).expect("mkdir github");
588        fs::write(
589            ws.join(".github").join("copilot-instructions.md"),
590            "# copilot says hello",
591        )
592        .expect("write copilot");
593        let fragment =
594            load_project_instruction_fragment(ws).expect("must find imported instructions");
595        assert_eq!(fragment.id, FragmentId::ProjectInstructions);
596        assert!(fragment.matches_text(&fragment.render_marked()));
597        assert!(
598            fragment.content.contains(".cursorrules") || fragment.content.contains(".clinerules")
599        );
600        validate_fragment(&fragment).expect("project-instructions fragment must satisfy caps");
601        let from_sources = project_instructions_from_sources(vec![
602            ("AGENTS.md".to_string(), "# AGENTS\nbe helpful".to_string()),
603            (
604                ".cursorrules".to_string(),
605                "cursor: do the thing".to_string(),
606            ),
607        ])
608        .expect("sources");
609        assert_eq!(from_sources.id, FragmentId::ProjectInstructions);
610        assert!(from_sources.content.contains("AGENTS.md"));
611        assert!(from_sources.content.contains(".cursorrules"));
612        validate_fragment(&from_sources).expect("explicit sources must also satisfy caps");
613    }
614    #[test]
615    fn additional_project_instruction_import_does_not_duplicate_canonical_authority() {
616        let dir = tempdir().expect("tempdir");
617        let ws = dir.path();
618        fs::write(ws.join("AGENTS.md"), "canonical authority marker").expect("write agents");
619
620        assert!(
621            load_additional_project_instruction_fragment(ws).is_none(),
622            "AGENTS.md is already owned by the canonical project-context loader"
623        );
624
625        fs::write(ws.join(".cursorrules"), "additional cursor marker").expect("write cursor rules");
626        let additional = load_additional_project_instruction_fragment(ws)
627            .expect("additional rules must produce a typed fragment");
628        assert!(additional.content.contains("additional cursor marker"));
629        assert!(!additional.content.contains("canonical authority marker"));
630
631        let complete = load_project_instruction_fragment(ws)
632            .expect("complete importer must retain every supported source");
633        assert!(complete.content.contains("canonical authority marker"));
634        assert!(complete.content.contains("additional cursor marker"));
635    }
636    #[test]
637    fn fragment_budget_snapshot_is_stable() {
638        let snap = fragment_budget_snapshot();
639        assert_eq!(snap.max_fragment_tokens, 10_000);
640        assert_eq!(snap.max_fragment_bytes, 40_000);
641        assert_eq!(snap.max_fragments_per_context, 16);
642        assert_eq!(snap.default_fragment_max_bytes, 4 * 1024);
643        assert!(
644            snap.fragment_ids
645                .contains(&"project_instructions".to_string())
646        );
647        assert!(snap.fragment_ids.contains(&"constitution".to_string()));
648        assert!(
649            snap.project_instruction_candidates
650                .contains(&".cursorrules".to_string())
651        );
652        assert!(
653            snap.project_instruction_candidates
654                .contains(&".github/copilot-instructions.md".to_string())
655        );
656        assert!(
657            snap.fragment_markers
658                .contains(&"<!-- cw:ctx:project_instructions -->".to_string())
659        );
660    }
661}