diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c0ac3c..c2004b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ Notable changes to JevGate. Versions follow [Semantic Versioning](https://semver ## [Unreleased] - File organization: a long application file (400 lines or more) whose outline, recheck and kind of file raised no finding is asked about its candidate parts, one request per part with the part's source: whether it does a job of its own that a reader would look for apart from the rest, and what it is within the file. A part of 100 lines or more whose answer reaches 0.65 and whose role leans to a job of its own is a consider naming its members. The kind of file cleared long single-type files wholesale: of 50 such files labeled from the code, 15 were worth splitting (a URL scraper inside lobsters' `Story`, a diff engine inside a renderer, a JSON parser inside a protocol module), and asking each part found 4 of them, at the part the labeler named, and no file to keep. The parts are the outline's groups without the links that merged every method of a large class into one group and without links through a helper most members call, with links for neighbours and for names sharing a distinctive word. On the corpus, 11 considers were added and nothing else changed: 9 right and 2 wrong (a demo page's placeholder table, a class's public API), 5 of 7 right outside the files used for tuning and 1 of 1 on the held-out projects; on 9 projects never used before (click, rich, zod, hono, viper, ripgrep, sinatra, jsoup, guzzle), 2 of 2 (Guzzle's `WWW-Authenticate` parser inside `DigestAuth`, ripgrep's `--hyperlink-format` language). File-organization considers were 63% right before. Benchmarks, examples and `scripts` and `docs` directories are not asked (5 of 5 such findings were wrong), nor is a part holding `main`. About $0.08 on the corpus. JevGate's own large files stay clear: their parts read as one job, and what decided them by hand, which other files use a part, is missing for Rust functions passed by name. +- A request whose answer is already cached fits the provider limit, whatever the token calibration says. The calibration (`.jevgate/token-budget.json`) is replaced after each run by the bytes per token of that run's fresh requests, and a request near the limit, such as a recheck that sends a whole file, was sent in one run and not the next: the file's finding changed with no change to its code. On the corpus, 0.23.1 under three calibrations (the last run's, a snapshot's and a stricter one) differed in one consider and five undecided units, and under the stricter one asked 42 thousand new tokens of questions; with this, only three units whose recheck the provider refused as too long still differ, and nothing new is asked. `--refresh` skips the cache, so it plans by the estimate alone. ## [0.23.1] - 2026-09-27 diff --git a/src/evaluate.rs b/src/evaluate.rs index 74d88c8..994b4dd 100644 --- a/src/evaluate.rs +++ b/src/evaluate.rs @@ -3,7 +3,7 @@ use super::{ options::CheckArgs, schema::{self, FileResult, Report, Status}, storage::Store, - token_budget::TokenBudget, + token_budget::{Limits, TokenBudget}, transport::Evaluator, }; use crate::config::ConfigContext; @@ -129,13 +129,16 @@ fn empty_report(args: &CheckArgs, current: &SnapshotContext<'_>, files: Vec {} Ok(Scheduled::Purpose(request)) => { match cached_purpose(input, args, root, &request, &mut report.files[owner]) { @@ -263,12 +266,17 @@ impl Session<'_> { ) { let mut purpose = Vec::new(); let mut views = BTreeMap::new(); + let root = &self.context.root; + let answered = |request: &serde_json::Value| { + crate::requests::answered(root, self.args, request).is_some() + }; + let limits = Limits::new(&self.budget, &answered); for (owner, file) in report.files.iter_mut().enumerate() { if file.status != Status::Pending { continue; } file.judgments.clear(); - match schedule(&inputs[owner], self.args, &self.budget, file) { + match schedule(&inputs[owner], self.args, limits, file) { Ok(Scheduled::None) => file.cached = false, Ok(Scheduled::Purpose(request)) => { file.cached = true; @@ -551,7 +559,7 @@ fn apply_classification(file: &mut FileResult, class: crate::file_kind::Classifi fn schedule( input: &Input, args: &CheckArgs, - budget: &TokenBudget, + budget: Limits<'_>, file: &mut FileResult, ) -> Result { if file.status != Status::Pending { diff --git a/src/file_kind.rs b/src/file_kind.rs index 03ba15c..96eabfd 100644 --- a/src/file_kind.rs +++ b/src/file_kind.rs @@ -9,7 +9,7 @@ use crate::{ options::CheckArgs, policy, schema::{FileResult, SourceRange, Status}, - token_budget::TokenBudget, + token_budget::Limits, }; use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; @@ -165,7 +165,7 @@ pub fn language(path: &Path) -> &'static str { } } -pub(crate) fn plan(input: &Input, args: &CheckArgs, budget: &TokenBudget) -> Result { +pub(crate) fn plan(input: &Input, args: &CheckArgs, budget: Limits<'_>) -> Result { let format = crate::docs::format::Format::of(&input.result.path).language(); if input.result.role == crate::inventory::INSTRUCTIONS { return Ok(Plan::Ready(document( @@ -640,8 +640,8 @@ fn extension(path: &Path) -> String { #[cfg(test)] mod tests { use super::*; - use crate::schema::Status; use crate::tests::{Project, args, run}; + use crate::{schema::Status, token_budget::TokenBudget}; const MIXED: &str = "fn production(value: &str) -> String {\n value.trim().to_string()\n}\n\n#[cfg(test)]\nmod tests {\n use super::production;\n\n fn helper(value: &str) -> String {\n production(value)\n }\n\n #[test]\n fn checks_production() {\n assert_eq!(helper(\" a \"), \"a\");\n }\n}\n"; @@ -657,7 +657,7 @@ mod tests { let input = crate::inventory::collect(options, &project.context(), &[]) .unwrap() .remove(0); - match plan(&input, options, &TokenBudget::default()).unwrap() { + match plan(&input, options, TokenBudget::default().uncached()).unwrap() { Plan::Ready(view) => view, _ => panic!("expected a gate view"), } @@ -736,7 +736,7 @@ mod tests { .unwrap() .remove(0); assert!(matches!( - plan(&input, &args(), &TokenBudget::default()).unwrap(), + plan(&input, &args(), TokenBudget::default().uncached()).unwrap(), Plan::Purpose(..) )); // Outside a test path, `main` is the program's entry. diff --git a/src/token_budget.rs b/src/token_budget.rs index d853ced..e1dfb8b 100644 --- a/src/token_budget.rs +++ b/src/token_budget.rs @@ -23,6 +23,43 @@ const MAX_BYTES_PER_TOKEN: f64 = 6.0; /// through that the provider refused as beyond its context. const STRUCTURED_BYTES_PER_TOKEN: f64 = 2.0; +/// The budget as a run applies it to one request: the calibrated estimate, +/// or the answer cache when it already holds that request's answer. The +/// calibration follows the fresh requests of the last run, so a request near +/// the limit fit in one run and not the next: two runs of one release on a +/// pinned project differed in a file's recheck, and so in its finding. A +/// request answered once fits from then on. +#[derive(Clone, Copy)] +pub struct Limits<'a> { + budget: &'a TokenBudget, + answered: &'a dyn Fn(&Value) -> bool, +} + +impl<'a> Limits<'a> { + pub fn new(budget: &'a TokenBudget, answered: &'a dyn Fn(&Value) -> bool) -> Self { + Self { budget, answered } + } + + pub fn fits(&self, request: &Value) -> bool { + self.budget.fits(request) || (self.answered)(request) + } + + pub fn fits_structured(&self, request: &Value) -> bool { + self.budget.fits_structured(request) || (self.answered)(request) + } +} + +#[cfg(test)] +impl TokenBudget { + /// The estimate alone, for plans made without an answer cache. + pub fn uncached(&self) -> Limits<'_> { + fn never(_: &Value) -> bool { + false + } + Limits::new(self, &never) + } +} + /// The bytes-per-token ratio, calibrated from observed `usage.input_tokens` and /// saved in `.jevgate/`. #[derive(Clone, Copy, Debug, serde::Serialize, serde::Deserialize, PartialEq)] diff --git a/src/units/access.rs b/src/units/access.rs index 4f26244..9c8e73c 100644 --- a/src/units/access.rs +++ b/src/units/access.rs @@ -11,7 +11,7 @@ use crate::{ inventory::Input, options::CheckArgs, schema::Pass, - token_budget::TokenBudget, + token_budget::Limits, }; use serde_json::{Value, json}; use std::{ @@ -46,7 +46,7 @@ fn project(path: &Path) -> PathBuf { pub(super) fn plan( files: &[(usize, &Input)], args: &CheckArgs, - budget: &TokenBudget, + budget: Limits<'_>, plans: &mut BTreeMap, requests: &mut Vec, ) { diff --git a/src/units/evidence.rs b/src/units/evidence.rs index 99a969d..2e9c36e 100644 --- a/src/units/evidence.rs +++ b/src/units/evidence.rs @@ -1,7 +1,7 @@ //! Request building shared by every planner: one file's facts, the request //! envelope, packing, and stable identities. use super::{Asked, PACK_ITEMS, Questions}; -use crate::{schema::Location, token_budget::TokenBudget}; +use crate::{schema::Location, token_budget::Limits}; use serde_json::{Value, json}; use sha2::{Digest, Sha256}; use std::{collections::BTreeMap, path::Path}; @@ -19,7 +19,7 @@ pub(super) struct FileContext<'a> { pub source: &'a str, pub source_hash: &'a str, pub model: &'a str, - pub budget: &'a TokenBudget, + pub budget: Limits<'a>, /// What a web framework makes of the file, such as a Next.js route /// handler or Server Actions module, sent beside its path. pub framework: Option, diff --git a/src/units/handlers/mod.rs b/src/units/handlers/mod.rs index a5958c2..a607e3a 100644 --- a/src/units/handlers/mod.rs +++ b/src/units/handlers/mod.rs @@ -20,7 +20,7 @@ use crate::{ catalog::SENSITIVE_DATA, options::CheckArgs, schema::Pass, - token_budget::TokenBudget, + token_budget::Limits, }; use classes::error_classes; use implemented::implemented; @@ -39,7 +39,7 @@ pub(super) fn plan( scope: &Scope<'_>, evidence: &Evidence<'_>, args: &CheckArgs, - budget: &TokenBudget, + budget: Limits<'_>, result: &mut Plan, ) { let handlers = error_handlers(scope, evidence.links); diff --git a/src/units/plan/file.rs b/src/units/plan/file.rs index 9cd0498..99d4614 100644 --- a/src/units/plan/file.rs +++ b/src/units/plan/file.rs @@ -11,7 +11,7 @@ use crate::{ file_kind::View, inventory::Input, options::CheckArgs, - token_budget::TokenBudget, + token_budget::Limits, units::{ FileContext, FilePlan, Planned, comments, duplicates, functions, hardcoded, laws, outline, spacetimedb, test_units, @@ -29,7 +29,7 @@ pub(super) fn plan_file( shared: &Shared<'_>, owner: usize, args: &CheckArgs, - budget: &TokenBudget, + budget: Limits<'_>, requests: &mut Vec, ) -> FilePlan { let input = &scope.inputs[owner]; @@ -134,7 +134,7 @@ fn file_context<'a>( input: &'a Input, owner: usize, args: &'a CheckArgs, - budget: &'a TokenBudget, + budget: Limits<'a>, ) -> FileContext<'a> { FileContext { owner, diff --git a/src/units/plan/mod.rs b/src/units/plan/mod.rs index 6d5d86f..2986009 100644 --- a/src/units/plan/mod.rs +++ b/src/units/plan/mod.rs @@ -24,7 +24,7 @@ use crate::{ file_kind::View, inventory::Input, options::CheckArgs, - token_budget::TokenBudget, + token_budget::{Limits, TokenBudget}, }; use std::{ @@ -89,6 +89,9 @@ pub fn plan( budget: &TokenBudget, root: &std::path::Path, ) -> Plan { + let answered = + |request: &serde_json::Value| crate::requests::answered(root, args, request).is_some(); + let budget = Limits::new(budget, &answered); let mut result = Plan::default(); let scope = parsed_scope(inputs, views, &mut result.skipped); let mut shared = Shared::new(&scope, args); @@ -134,7 +137,7 @@ pub fn plan( } /// Each GitHub Actions workflow file's jobs. -fn plan_workflows(scope: &Scope<'_>, args: &CheckArgs, budget: &TokenBudget, result: &mut Plan) { +fn plan_workflows(scope: &Scope<'_>, args: &CheckArgs, budget: Limits<'_>, result: &mut Plan) { for &owner in &scope.configuration { let input = &scope.inputs[owner]; if input.result.role != crate::inventory::WORKFLOW { @@ -164,7 +167,7 @@ fn plan_document( input: &Input, owner: usize, args: &CheckArgs, - budget: &TokenBudget, + budget: Limits<'_>, drift: &drift::Shared<'_>, requests: &mut Vec, ) -> FilePlan { diff --git a/src/units/tests/mod.rs b/src/units/tests/mod.rs index f4c4b93..9755cca 100644 --- a/src/units/tests/mod.rs +++ b/src/units/tests/mod.rs @@ -33,7 +33,7 @@ fn planned(project: &Project, options: &CheckArgs) -> (Vec, Plan) { .iter() .enumerate() .filter_map( - |(i, input)| match crate::file_kind::plan(input, options, &budget) { + |(i, input)| match crate::file_kind::plan(input, options, budget.uncached()) { Ok(crate::file_kind::Plan::Ready(view)) => Some((i, view)), _ => None, }, diff --git a/src/units/tests/organization.rs b/src/units/tests/organization.rs index 5d9e9f7..b2589f6 100644 --- a/src/units/tests/organization.rs +++ b/src/units/tests/organization.rs @@ -134,6 +134,33 @@ fn an_outline_too_long_for_a_recheck_is_decided_by_its_kind_alone() { assert!(kind.request()["state"]["file"]["source"].is_null()); } +#[test] +fn a_request_answered_once_fits_whatever_the_calibration() { + // About 79 KB: the recheck sends it whole, which the estimate fits at the + // default 3.0 bytes per token and not at 2.0. + let padding = format!("// {}\n", "x".repeat(100)).repeat(700); + let (project, options) = organized("lib.rs", &format!("{}{padding}", two_concerns())); + let first = run(&project, &options, &mut scripted(3)); + assert_eq!(first.stages["recheck"].successful_requests, 1); + let context = project.context(); + let (inputs, mut report) = crate::tests::snapshot(&project, &options); + let store = crate::storage::Store::open(&project.0).unwrap(); + let mut mock = Mock::default(); + let mut session = crate::tests::session(&options, &context, &store, &mut mock); + session.budget = TokenBudget { + bytes_per_token: 2.0, + }; + session.evaluate(&inputs, &mut report).unwrap(); + assert_eq!(mock.calls, 0, "every request comes from the cache"); + assert_eq!(report.stages["recheck"].cache_hits, 1); + let status = |report: &Report| { + report.files[0].dimensions["file_organization"] + .status + .clone() + }; + assert_eq!(status(&report), status(&first)); +} + #[test] fn outlines_carry_member_and_file_sizes() { let (project, options) = rule_project(&two_concerns(), catalog::FILE_ORGANIZATION); diff --git a/src/units/tests/pipeline.rs b/src/units/tests/pipeline.rs index f3c2c82..0857284 100644 --- a/src/units/tests/pipeline.rs +++ b/src/units/tests/pipeline.rs @@ -115,7 +115,7 @@ fn packing_and_cache_identity_do_not_depend_on_token_calibration() { let budget = TokenBudget { bytes_per_token }; let views = BTreeMap::from([( 0, - match crate::file_kind::plan(&inputs[0], &options, &budget).unwrap() { + match crate::file_kind::plan(&inputs[0], &options, budget.uncached()).unwrap() { crate::file_kind::Plan::Ready(view) => view, _ => unreachable!(), },