Repository navigation
fix(browser): count the link table's opening line against the extraction cap #6973
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| `max_content_chars` now bounds the link table's opening line along with its entries, so the extraction stays inside the ceiling an operator set rather than overshooting it by that line's length. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This bullet (" Generated by Claude Code |
||
| The budget summed the entries and stopped there, but the rendered block also opens with a line naming the marker form and the base origin, and that line reaches the model with the entries — 50,093 characters against a 50,000 cap on the Rust Wikipedia article, the 93 being that line for a 24-character origin. | ||
| The test could not have caught it: it re-derived the table's cost in its own port and asserted against that same derivation, so the budget and the assertion agreed by construction whatever the renderer did. | ||
| Every ported test in this module now asserts that the template still contains the rule it models, since a port is only evidence about the script while the two agree — and nothing else would have noticed the script and its port drifting apart. | ||
| It now asserts against what `render_page_body` actually produces, which is the string that reaches the model (#6624, #6973) (@nevgenov) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1545,9 +1545,13 @@ const EXTRACT_CONTENT_JS_TEMPLATE: &str = r#"(() => { | |
| } | ||
| return kept; | ||
| } | ||
| // What the table costs once rendered, matching how the tool layer prints an entry. | ||
| // What the table costs once rendered, matching how the tool layer prints it. | ||
| // The line that opens the table reaches the model with the entries, so it is part of the cost: counting entries alone put the payload over the operator's ceiling by the length of that line, 93 characters on a page served from a typical origin. | ||
| // Mirrors `render_page_body` — change one and the other has to follow. | ||
| function tableCost(kept) { | ||
| let n = 0; | ||
| if (!kept.length) return 0; | ||
| let n = '\n\nLinks (click with browser_click, e.g. ⟨1⟩)\n'.length; | ||
| if (origin) n += ('; paths are relative to ' + origin).length; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The header text now lives in three independent copies: this JS literal, the equivalent literal in Generated by Claude Code |
||
| for (const e of kept) n += ('⟨' + e.id + '⟩ ' + e.url + '\n').length; | ||
| return n; | ||
| } | ||
|
|
@@ -1892,6 +1896,13 @@ mod tests { | |
| /// Ported from the JS the script runs, because the traversal itself needs a live DOM. | ||
| #[test] | ||
| fn test_nested_lists_keep_their_own_bullets() { | ||
| // The port models the script; this checks the script still does what is modelled. | ||
| // Reverting the fold to the two-filter form would leave the port validating itself and passing. | ||
| assert!( | ||
| EXTRACT_CONTENT_JS_TEMPLATE.contains("emit((opened ? ' ' : '- ') + own, true)"), | ||
| "the item's own text must be emitted in runs, so a sub-list keeps its place between them" | ||
| ); | ||
|
|
||
| /// A list item as the page writes it: runs of its own text and sub-lists, in source order. | ||
| enum Part { | ||
| Text(&'static str), | ||
|
|
@@ -2001,6 +2012,16 @@ mod tests { | |
| /// Anchoring on the first article's ancestors instead of searching the tree misses a grid that sits beside a featured card rather than under it. | ||
| #[test] | ||
| fn test_root_selection_finds_a_container_of_repeated_cards() { | ||
| // As above: the port is only evidence while it and the script agree on the rule. | ||
| assert!( | ||
| EXTRACT_CONTENT_JS_TEMPLATE.contains("candidates.push({ node, carriers })"), | ||
| "selection must collect every qualifying node, not stop at the first branch that has one" | ||
| ); | ||
| assert!( | ||
| EXTRACT_CONTENT_JS_TEMPLATE.contains("c.node.contains(o.node)"), | ||
| "a candidate containing another must lose to it, which is what keeps selection off the whole page" | ||
| ); | ||
|
|
||
| struct Node { | ||
| tag: &'static str, | ||
| name: &'static str, | ||
|
|
@@ -2477,8 +2498,22 @@ mod tests { | |
| /// | ||
| /// Ported from the JS the script runs, because the extraction itself needs a live browser — same shape as `test_truncation_marker_fits_inside_the_cap`. | ||
| /// The subtract-the-overflow form this replaces stayed under the cap but landed at 34k against 50k on a Wikipedia-shaped page, because cutting prose drops markers and so drops table entries faster than the prose itself shrank. | ||
| /// | ||
| /// The ceiling is asserted against what `render_page_body` actually produces rather than against the ported cost function. | ||
| /// Re-deriving the cost on both sides is self-consistent by construction, so it cannot see the budget and the renderer disagreeing — which is exactly what happened: the budget counted entries and the renderer also emitted a line introducing them, putting the payload 93 characters past the operator's number. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This diff only touches function tableCost(kept) {
let n = 0;
for (const e of kept) n += ('⟨' + e.id + '⟩ ' + e.url + '\n').length;
return n;
}It never adds the header line ( The test's local Looks like the intended change to the JS Generated by Claude Code |
||
| #[test] | ||
| fn test_budget_search_fills_the_cap_without_exceeding_it() { | ||
| // The port below models the script; this checks the script still does what is modelled. | ||
| // A port is only evidence about the script while the two agree, and nothing else here would notice if the header cost were dropped from the template — the port would go on validating a corrected budget against `render_page_body` and pass. | ||
| assert!( | ||
| EXTRACT_CONTENT_JS_TEMPLATE.contains("Links (click with browser_click"), | ||
| "the script's own table cost must include the line that opens the table, or this test measures something the browser never runs" | ||
| ); | ||
| assert!( | ||
| EXTRACT_CONTENT_JS_TEMPLATE.contains("paths are relative to"), | ||
| "that line carries the origin, whose length varies per page and so must be measured rather than assumed" | ||
| ); | ||
|
|
||
| /// One `⟨n⟩` marker per line, so a cut drops markers the way a real page does. | ||
| fn page(n_links: usize, prose_len: usize, url_len: usize) -> (String, Vec<String>) { | ||
| let links: Vec<String> = (0..n_links) | ||
|
|
@@ -2500,34 +2535,58 @@ mod tests { | |
| let keep = budget.saturating_sub(marker.chars().count()); | ||
| format!("{}{marker}", text.chars().take(keep).collect::<String>()) | ||
| } | ||
| fn table_cost(text: &str, links: &[String]) -> usize { | ||
| (0..links.len()) | ||
| /// The extraction's own view of what the table costs, header included. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Correctness: this PR only patches the Rust-side test's local reimplementation of the budget search — it never touches the production Look at function tableCost(kept) {
let n = 0;
for (const e of kept) n += ('⟨' + e.id + '⟩ ' + e.url + '\n').length;
return n;
}and the search that uses it (lines 1569-1584, So the fixed The pre-existing This looks like the PR title/changelog claim a behavioral fix (" Generated by Claude Code |
||
| fn table_cost(text: &str, links: &[String], origin: &str) -> usize { | ||
| let entries: usize = (0..links.len()) | ||
| .filter(|i| text.contains(&format!("\u{27e8}{}\u{27e9}", i + 1))) | ||
| .map(|i| { | ||
| format!("\u{27e8}{}\u{27e9} {}\n", i + 1, links[i]) | ||
| .chars() | ||
| .count() | ||
| }) | ||
| .sum() | ||
| .sum(); | ||
| if entries == 0 { | ||
| return 0; | ||
| } | ||
| let mut header = "\n\nLinks (click with browser_click, e.g. \u{27e8}1\u{27e9})\n" | ||
| .chars() | ||
| .count(); | ||
| if !origin.is_empty() { | ||
| header += format!("; paths are relative to {origin}").chars().count(); | ||
| } | ||
| entries + header | ||
| } | ||
| /// The extraction's response for a given prose cut, as `render_page_body` receives it. | ||
| fn response(content: &str, links: &[String], origin: &str) -> serde_json::Value { | ||
| let kept: Vec<serde_json::Value> = (0..links.len()) | ||
| .filter(|i| content.contains(&format!("\u{27e8}{}\u{27e9}", i + 1))) | ||
| .map(|i| serde_json::json!({"id": i + 1, "url": links[i]})) | ||
| .collect(); | ||
| serde_json::json!({"content": content, "links": kept, "links_base": origin}) | ||
| } | ||
| /// `(total, was_truncated)` — a page that fits outright is not expected to fill the cap. | ||
| fn budgeted(content: &str, links: &[String], cap: usize) -> (usize, bool) { | ||
| /// `(rendered length, was_truncated)` — a page that fits outright is not expected to fill the cap. | ||
| fn budgeted(content: &str, links: &[String], cap: usize, origin: &str) -> (usize, bool) { | ||
| let rendered = |budget: usize| { | ||
| let out = cut(content, budget); | ||
| render_page_body(&response(&out, links, origin)) | ||
| .chars() | ||
| .count() | ||
| }; | ||
| let total = |budget: usize| { | ||
| let out = cut(content, budget); | ||
| out.chars().count() + table_cost(&out, links) | ||
| out.chars().count() + table_cost(&out, links, origin) | ||
| }; | ||
| if total(cap) <= cap { | ||
| return (total(cap), false); | ||
| return (rendered(cap), false); | ||
| } | ||
| let (mut lo, mut hi, mut best) = (0usize, cap, total(0)); | ||
| let (mut lo, mut hi, mut best) = (0usize, cap, rendered(0)); | ||
| for _ in 0..24 { | ||
| if lo >= hi { | ||
| break; | ||
| } | ||
| let mid = (lo + hi).div_ceil(2); | ||
| let t = total(mid); | ||
| if t <= cap { | ||
| best = t; | ||
| if total(mid) <= cap { | ||
| best = rendered(mid); | ||
| lo = mid; | ||
| } else { | ||
| hi = mid - 1; | ||
|
|
@@ -2544,10 +2603,11 @@ mod tests { | |
| (2000, 200_000, 1_000, 80), | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The four cases here all clear the cap with comfortable margin: the live Wikipedia measurement in the PR description lands 19 characters under 50,000, and the synthetic cases are sized similarly loosely, well inside the 95% tolerance band the truncated branch checks. Generated by Claude Code |
||
| ] { | ||
| let (content, links) = page(n_links, prose_len, url_len); | ||
| let (total, truncated) = budgeted(&content, &links, cap); | ||
| // A real origin, since the table's opening line carries it and so its length counts against the cap. | ||
| let (total, truncated) = budgeted(&content, &links, cap, "https://en.wikipedia.org"); | ||
| assert!( | ||
| total <= cap, | ||
| "cap {cap}: prose plus table is {total} chars, past the ceiling the operator set" | ||
| "cap {cap}: the rendered page is {total} chars, past the ceiling the operator set" | ||
| ); | ||
| // Only where the page actually overflows: the HN-shaped row above fits whole at 23,904 and must not be padded out to the cap to satisfy this. | ||
| if truncated { | ||
|
|
@@ -2594,6 +2654,12 @@ mod tests { | |
| /// Ported from the JS the script runs, because the extraction itself needs a live browser: an operator sizing `max_content_chars` to a context window gets a real ceiling, and the marker reports the pre-truncation length so the model can tell how much it is missing rather than only that something was lost (#6624). | ||
| #[test] | ||
| fn test_truncation_marker_fits_inside_the_cap() { | ||
| // As above: this port predates the others and had nothing tying it to the script either. | ||
| assert!( | ||
| EXTRACT_CONTENT_JS_TEMPLATE.contains("... (truncated, ' + total + ' chars total)"), | ||
| "the marker must report the pre-truncation length, which is what makes it worth its own characters" | ||
| ); | ||
|
|
||
| fn truncate(content: &str, cap: usize) -> String { | ||
| if content.chars().count() <= cap { | ||
| return content.to_string(); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This describes
max_content_charsas now bounding the header line, but the diff doesn't changeEXTRACT_CONTENT_JS_TEMPLATE'stableCost()inbrowser.rs— only the Rust unit test was updated to include the header cost in its own local port. As it stands this fragment describes a fix that isn't in the code yet (see the comment onbrowser.rs); worth holding until the JS side actually changes, or the release notes will claim a behavior change that didn't ship.Generated by Claude Code