@@ -32,6 +32,14 @@ const MAX_CHUNK_LINES: usize = 1200;
3232/// How many times a chunk may be halved when its prompt overruns the window.
3333const MAX_SPLIT_DEPTH : u32 = 4 ;
3434
35+ /// Ceiling on either optional prompt section. Past this the model's attention is
36+ /// the binding constraint rather than the window.
37+ const MAX_SECTION_BYTES : usize = 12_000 ;
38+ /// Floor for an optional section, below which it is not worth carrying.
39+ const MIN_SECTION_BYTES : usize = 400 ;
40+ /// Ticket text is a fixed brief, not something that grows with the diff.
41+ const MAX_REQUIREMENTS_BYTES : usize = 2000 ;
42+
3543/// Triage only pays for itself once a diff spans enough files that skipping
3644/// some saves more than the extra inference pass costs.
3745const TRIAGE_MIN_FILES : usize = 6 ;
@@ -63,6 +71,9 @@ pub struct AnalysisStats {
6371 pub units_cached : usize ,
6472 /// Units whose output could not be parsed even after repair.
6573 pub units_unparseable : usize ,
74+ /// Units skipped because no amount of splitting fit them in the window.
75+ /// Distinct from `units_unparseable`: the model was never asked.
76+ pub units_too_large : usize ,
6677 pub files_skipped_by_triage : usize ,
6778 pub suppressed : usize ,
6879 pub below_confidence : usize ,
@@ -312,25 +323,55 @@ impl ReviewAnalyzer {
312323 ( available / TOKENS_PER_DIFF_LINE ) . clamp ( MIN_CHUNK_LINES , MAX_CHUNK_LINES )
313324 }
314325
315- fn build_prompt ( & self , diff : & str , context : & str , rulebooks : & [ & Rulebook ] ) -> Prompt {
326+ fn build_prompt (
327+ & self ,
328+ diff : & str ,
329+ context : & str ,
330+ rulebooks : & [ & Rulebook ] ,
331+ max_new_tokens : usize ,
332+ depth : u32 ,
333+ ) -> Prompt {
334+ let budget = self . section_budget_bytes ( max_new_tokens, depth) ;
316335 review_prompt ( & ReviewPromptInput {
317336 diff,
318337 context,
319338 languages : self . languages . as_deref ( ) ,
320339 requirements : self . requirements . as_deref ( ) ,
321340 rulebooks,
322- max_context_bytes : self . context_budget_bytes ( ) ,
323- max_requirements_bytes : 2000 ,
324- max_rules_bytes : self . context_budget_bytes ( ) ,
341+ max_context_bytes : budget ,
342+ max_requirements_bytes : budget . min ( MAX_REQUIREMENTS_BYTES ) ,
343+ max_rules_bytes : budget ,
325344 } )
326345 }
327346
328- /// Byte budget for the RAG/context section, scaled to the window rather
329- /// than pinned at the 2 KB a 4K window demanded.
330- fn context_budget_bytes ( & self ) -> usize {
331- let ctx = self . backend . context_tokens ( ) ;
332- // Roughly a sixth of the window, three bytes per token.
333- ( ( ctx / 6 ) * 3 ) . clamp ( 1500 , 12_000 )
347+ /// Byte budget for each of the prompt's optional sections — the symbol
348+ /// context and the project rules — at recursion `depth`.
349+ ///
350+ /// Two properties, both learned the hard way.
351+ ///
352+ /// **The diff keeps at least half the window.** These sections used to be
353+ /// sized from the window alone, so a large `.diffmind/rules/` could claim
354+ /// 12 KB and the context another 12 KB regardless of what was left for the
355+ /// thing actually under review.
356+ ///
357+ /// **The budget shrinks with depth.** When a prompt overruns, the analyzer
358+ /// halves the *diff* and retries — which cannot help when the fixed sections
359+ /// are what overran. A big enough rule set therefore failed identically at
360+ /// every recursion level and gave up at the depth cap, having never had a
361+ /// chance. Shrinking here is what makes the retry mean something.
362+ /// [`crate::prompt`] drops whole rule sets that no longer fit rather than
363+ /// truncating one mid-sentence, so this degrades to "fewer rules", then to
364+ /// "no rules" — never to half a rule that reads like a complete one.
365+ fn section_budget_bytes ( & self , max_new_tokens : usize , depth : u32 ) -> usize {
366+ let available = self
367+ . backend
368+ . context_tokens ( )
369+ . saturating_sub ( max_new_tokens + SYSTEM_PROMPT_TOKENS ) ;
370+ // Half of what is left, shared by the two sections, at ~3 bytes a token.
371+ let share = ( ( available / 4 ) * 3 ) . min ( MAX_SECTION_BYTES ) ;
372+ // The floor never exceeds the share, or a window too small to afford it
373+ // would be pushed further over by the very thing meant to protect it.
374+ ( share >> depth. min ( 16 ) ) . max ( MIN_SECTION_BYTES . min ( share) )
334375 }
335376
336377 /// True when the prompt fits the window with room for the response.
@@ -458,6 +499,16 @@ impl ReviewAnalyzer {
458499 }
459500 stats. units_unparseable += 1 ;
460501 }
502+ // Counted and skipped, not fatal. A hunk nobody can fit in the
503+ // window is a fact about that hunk; failing the run over it
504+ // would throw away every other unit's findings — including the
505+ // deterministic ones, which never needed a model at all.
506+ Err ( EngineError :: UnitTooLarge ( why) ) => {
507+ if self . debug {
508+ eprintln ! ( "[debug] unit {} ({}) skipped: {why}" , i + 1 , unit. file( ) ) ;
509+ }
510+ stats. units_too_large += 1 ;
511+ }
461512 Err ( e) => return Err ( e) ,
462513 }
463514 }
@@ -573,15 +624,15 @@ impl ReviewAnalyzer {
573624 }
574625
575626 let context = context_for ( chunk) ;
576- let prompt = self . build_prompt ( chunk, & context, rulebooks) ;
627+ let prompt = self . build_prompt ( chunk, & context, rulebooks, max_tokens as usize , depth ) ;
577628
578629 if !self . prompt_fits ( & prompt, max_tokens as usize ) {
579630 if depth >= MAX_SPLIT_DEPTH {
580- return Err ( EngineError :: ForwardError (
581- "a single diff hunk is too large for the model's context window even after \
582- splitting; review that file on its own"
583- . into ( ) ,
584- ) ) ;
631+ return Err ( EngineError :: UnitTooLarge ( format ! (
632+ "still {} bytes of diff after {MAX_SPLIT_DEPTH} splits, with every \
633+ optional section already at its minimum" ,
634+ chunk . len ( )
635+ ) ) ) ;
585636 }
586637 // Halve and recurse rather than truncate: silently dropping half a
587638 // hunk means silently not reviewing it. Each half re-derives its own
0 commit comments