Skip to content

Commit bd04924

Browse files
committed
test(query): pin the text page budget and cursor contracts
The token-efficiency pass changed behaviour that tests did not pin, so this adds four regression tests beside the existing agent-query tests: * a page renders at most the Agent View profile (12 primary results) even when the token budget would allow far more, reports the ledger's true total and continues with `next=`; * a resolved answer prints no entity identifiers while a `search` pick list prints them, and the JSON projection carries identifiers for both; * the page opens with one `RESULT` line, its pagination line has no version or budget echo, and a continuation cursor stays within its compact size bound; * the previous release's long-form cursor is rejected instead of being reinterpreted as the compact wire form, whose decode failure now names the version explicitly. The shared paged-text footer (`path`, `node`, `explore`) also stops repeating the caller's own budget and the previous page number, which no caller needs: `Pagination: page=A/B <item>=X-Y/T next=...`. Final measured state on the 50-question suite: Compass answers 50/50 with a paired median of 362 tokens against the pre-pass 560 (total output 40,082 -> 21,280), and the first suite stays at 47/47.
1 parent 2a0356e commit bd04924

5 files changed

Lines changed: 200 additions & 10 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@
1111
full statement, drops the repeated endpoint identifiers from `path` answers,
1212
renders a trail as its hop count and labelled chain instead of a path
1313
identity built from every node identifier, and prints entity identifiers only
14-
where the page resolves a name. Cursors
14+
where the page resolves a name. The shared paged-text footer also stops
15+
repeating the caller's own budget and the previous page number. Cursors
1516
use a compact wire encoding with 64-bit digest prefixes. On the 50-question
1617
agent-query suite the same 44 answers Compass and Graphify both pass cost a
1718
median of 366 tokens instead of 560, and the suite's total Compass output

‎COMPATIBILITY.md‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -253,9 +253,11 @@ sentence, with the remainder in `agent-json`/`json`; and a `path` answer prints
253253
its endpoints as labels without repeating their identifiers. A `PATHS` row in
254254
the text view prints the hop count and the labelled chain; its path identity -
255255
a concatenation of every node identifier on the trail - stays in
256-
`agent-json`/`json`. Stable entity
257-
identifiers are printed only where the page resolves a name (a `search` pick
258-
list, or a non-exact match); the raw `compass.query/1` response and the
256+
`agent-json`/`json`. The same footer rule applies to every other paged text
257+
answer (`path`, `node`, `explore`): `Pagination: page=A/B <item>=X-Y/T next=…`
258+
without a budget echo or a previous-page field. Stable entity identifiers are
259+
printed only where the page resolves a name (a `search` pick list, or a
260+
non-exact match); the raw `compass.query/1` response and the
259261
`agent-json` view still carry every identifier, anchor, and omission counter.
260262
Continuation cursors use a compact wire encoding that stores 64-bit digest
261263
prefixes and a version byte; cursors issued by an earlier release are rejected

‎crates/compass-output/src/agent_query.rs‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1382,7 +1382,9 @@ pub fn decode_agent_text_page_cursor(value: &str) -> Result<AgentTextPageCursor,
13821382
// The checksum already proved the payload is intact, so a payload that does
13831383
// not parse as this wire form belongs to another cursor version.
13841384
let wire: AgentTextPageCursorWire = serde_json::from_slice(&bytes).map_err(|_| {
1385-
OutputError::InvalidAgentTextPage("unsupported page cursor encoding".to_owned())
1385+
OutputError::InvalidAgentTextPage(
1386+
"unsupported page cursor version; reissue the page query".to_owned(),
1387+
)
13861388
})?;
13871389
let cursor = wire.into_cursor()?;
13881390
if cursor.page == 0 {

‎crates/compass-output/tests/agent_query.rs‎

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -546,3 +546,189 @@ fn equivalent_collection_order_has_one_view_digest() -> Result<(), Box<dyn Error
546546
);
547547
Ok(())
548548
}
549+
550+
/// A callers response with `count` call sites of one target.
551+
fn callers_fixture(count: usize) -> CodeQueryResponse {
552+
let target_anchor = anchor("src/target.rs", 20);
553+
let mut response = response(CodeQueryOperation::Callers);
554+
response
555+
.nodes
556+
.push(node("n:target", "Target", &target_anchor));
557+
for index in 0..count {
558+
let file = format!("src/caller_{index}.rs");
559+
let caller_anchor = anchor(&file, index as u32 + 1);
560+
let id = format!("n:caller-{index}");
561+
response
562+
.nodes
563+
.push(node(&id, &format!("Caller{index}"), &caller_anchor));
564+
response.edges.push(QueryEdge {
565+
id: format!("e:caller-{index}"),
566+
source: id,
567+
target: "n:target".to_owned(),
568+
kind: EdgeKind::Calls,
569+
relationship_site: Some(caller_anchor.clone()),
570+
details: None,
571+
evidence: vec![evidence(&caller_anchor)],
572+
});
573+
}
574+
response
575+
}
576+
577+
#[test]
578+
fn text_page_keeps_the_agent_view_profile_and_pages_the_rest() -> Result<(), Box<dyn Error>> {
579+
let response = callers_fixture(24);
580+
let page = render_code_query_text_page(
581+
&response,
582+
context(AgentOperation::Callers)
583+
.with_operand(compass_output::AgentOperandRole::Symbol, "Target"),
584+
AgentTextPageOptions {
585+
// A budget large enough that only the profile decides the page.
586+
token_budget: 32_000,
587+
cursor: None,
588+
},
589+
)?;
590+
let entities = page
591+
.text
592+
.lines()
593+
.filter(|line| line.starts_with("- Fixture.Caller") || line.starts_with("- Fixture.Target"))
594+
.count();
595+
assert_eq!(
596+
entities, 12,
597+
"one page renders the Agent View profile: {}",
598+
page.text
599+
);
600+
assert!(
601+
page.text.contains("range=1-12 of "),
602+
"the page reports the ledger's true total: {}",
603+
page.text.lines().last().unwrap_or_default()
604+
);
605+
assert!(
606+
page.next_cursor.is_some(),
607+
"the rest of the ledger continues"
608+
);
609+
assert!(
610+
page.entry_total >= 25,
611+
"the ledger keeps the target, the callers and their edges: {}",
612+
page.entry_total
613+
);
614+
assert!(
615+
page.text.contains(&format!("of {}", page.entry_total)),
616+
"the page reports the ledger's true total"
617+
);
618+
// The machine views keep every record the page defers.
619+
assert_eq!(response.nodes.len(), 25);
620+
Ok(())
621+
}
622+
623+
#[test]
624+
fn text_page_prints_identifiers_only_where_it_resolves_a_name() -> Result<(), Box<dyn Error>> {
625+
let response = callers_fixture(3);
626+
let resolved = render_code_query_text_page(
627+
&response,
628+
context(AgentOperation::Callers)
629+
.with_operand(compass_output::AgentOperandRole::Symbol, "Target"),
630+
AgentTextPageOptions {
631+
token_budget: 2_000,
632+
cursor: None,
633+
},
634+
)?;
635+
assert!(
636+
!resolved.text.contains(" id: "),
637+
"a resolved caller row is addressed by name and anchor: {}",
638+
resolved.text
639+
);
640+
let pick_list = render_code_query_text_page(
641+
&response,
642+
context(AgentOperation::Search)
643+
.with_operand(compass_output::AgentOperandRole::Query, "Target"),
644+
AgentTextPageOptions {
645+
token_budget: 2_000,
646+
cursor: None,
647+
},
648+
)?;
649+
assert!(
650+
pick_list.text.contains(" id: "),
651+
"a pick list keeps the identifiers an agent disambiguates with: {}",
652+
pick_list.text
653+
);
654+
// The JSON projection carries identifiers for both.
655+
let view = build_code_query_view(
656+
&response,
657+
context(AgentOperation::Callers)
658+
.with_operand(compass_output::AgentOperandRole::Symbol, "Target"),
659+
)?;
660+
assert!(
661+
view.primary_results
662+
.iter()
663+
.all(|entity| !entity.id.is_empty())
664+
);
665+
Ok(())
666+
}
667+
668+
#[test]
669+
fn text_page_envelope_stays_compact_and_cursors_stay_short() -> Result<(), Box<dyn Error>> {
670+
let response = callers_fixture(24);
671+
let page = render_code_query_text_page(
672+
&response,
673+
context(AgentOperation::Callers)
674+
.with_operand(compass_output::AgentOperandRole::Symbol, "Target"),
675+
AgentTextPageOptions {
676+
token_budget: 32_000,
677+
cursor: None,
678+
},
679+
)?;
680+
assert!(
681+
page.text.starts_with("RESULT "),
682+
"the page opens with one RESULT line: {}",
683+
page.text
684+
);
685+
assert!(
686+
!page.text.contains("\nState:"),
687+
"the multi-line state block is gone: {}",
688+
page.text
689+
);
690+
let pagination = page
691+
.text
692+
.lines()
693+
.find(|line| line.starts_with("Pagination:"))
694+
.ok_or("missing pagination line")?;
695+
assert!(!pagination.contains("version="), "{pagination}");
696+
assert!(!pagination.contains("budget_tokens"), "{pagination}");
697+
let cursor = page.next_cursor.ok_or("expected a continuation")?;
698+
assert!(
699+
cursor.len() <= 220,
700+
"a cursor is re-printed on every page, so it stays compact: {} chars",
701+
cursor.len()
702+
);
703+
Ok(())
704+
}
705+
706+
#[test]
707+
fn legacy_page_cursor_encoding_is_rejected_with_a_version_error() -> Result<(), Box<dyn Error>> {
708+
use base64::Engine as _;
709+
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
710+
use sha2::{Digest, Sha256};
711+
712+
// The previous release wrote long keys with a string version and full
713+
// digests. Such a cursor must fail explicitly, not be reinterpreted as the
714+
// compact wire form with defaulted fields.
715+
let legacy = serde_json::json!({
716+
"version": "compass.query.agent-text-page/1",
717+
"operation": "callers",
718+
"graphIdentity": "a".repeat(64),
719+
"page": 2,
720+
"prefixCount": 1,
721+
"prefixDigest": "b".repeat(64),
722+
});
723+
let payload = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&legacy)?);
724+
let checksum = format!("{:x}", Sha256::digest(payload.as_bytes()));
725+
let cursor = format!("{payload}.{checksum}");
726+
let error = compass_output::decode_agent_text_page_cursor(&cursor)
727+
.expect_err("a legacy cursor must not be reinterpreted");
728+
let message = error.to_string();
729+
assert!(
730+
message.contains("cursor"),
731+
"the failure names the cursor: {message}"
732+
);
733+
Ok(())
734+
}

‎crates/compass-query/src/traversal.rs‎

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1452,17 +1452,16 @@ fn render_paginated_groups(
14521452
.join("\n");
14531453
let first = if range.is_empty() { 0 } else { range.start + 1 };
14541454
let last = range.end;
1455-
let previous = page
1456-
.checked_sub(1)
1457-
.filter(|previous| *previous > 0)
1458-
.map_or_else(|| "none".to_owned(), |previous| previous.to_string());
14591455
let next = if page < total_pages {
14601456
(page + 1).to_string()
14611457
} else {
14621458
"none".to_owned()
14631459
};
1460+
// The page budget is the caller's own request and the previous page is
1461+
// `page - 1`, so neither is reprinted: this line closes every page of every
1462+
// text answer, and the label, range and continuation are what it must say.
14641463
let pagination = format!(
1465-
"Pagination: page={page}/{total_pages} {item_label}={first}-{last}/{} budget_tokens=~{token_budget} previous={previous} next={next}",
1464+
"Pagination: page={page}/{total_pages} {item_label}={first}-{last}/{} next={next}",
14661465
groups.len()
14671466
);
14681467
if body.is_empty() {

0 commit comments

Comments
 (0)