use saya_agent::{
KnowledgeOutcome, LearningSkipReason, OverrideFindingDto, SuppliedClaimDto, SuppliedContractDto,
};
use saya_types::ClaimStatus;
pub(crate) fn knowledge_supplied_text(
outcome: KnowledgeOutcome,
contracts: &[SuppliedContractDto],
dropped_by_bounds: usize,
) -> String {
match outcome {
KnowledgeOutcome::Off => "memory off · recall disabled\n".into(),
KnowledgeOutcome::Skipped => "memory skipped · not permitted to read saved claims\n".into(),
KnowledgeOutcome::Ran { store_unavailable } => {
if store_unavailable {
return "memory supplied · store unavailable — recall could not read saved claims\n".into();
}
let total: usize = contracts.iter().map(|c| c.claims.len()).sum();
if total == 0 && dropped_by_bounds == 0 {
return String::new();
}
let unconfirmed = count_unconfirmed(contracts);
let mut out = String::from("memory supplied · ");
out.push_str(&format!(
"{total} claim{}",
if total == 1 { "" } else { "s" }
));
if unconfirmed > 0 {
out.push_str(&format!(" ({unconfirmed} unconfirmed)"));
}
if dropped_by_bounds > 0 {
out.push_str(&format!(" · {dropped_by_bounds} more dropped by bounds"));
}
out.push('\n');
for contract in contracts {
out.push_str(&contract_header(contract));
for claim in &contract.claims {
out.push_str(&claim_line(claim));
}
}
out
}
_ => String::new(),
}
}
fn count_unconfirmed(contracts: &[SuppliedContractDto]) -> usize {
contracts
.iter()
.flat_map(|c| c.claims.iter())
.filter(|claim| !is_confirmed(claim.status))
.count()
}
fn is_confirmed(status: ClaimStatus) -> bool {
matches!(status, ClaimStatus::Confirmed)
}
fn contract_header(contract: &SuppliedContractDto) -> String {
format!(
" {} [{}] (profile: {})\n",
contract.object, contract.schema_state, contract.profile
)
}
fn claim_line(claim: &SuppliedClaimDto) -> String {
let id = abbreviate_id(claim.claim_id.as_str());
let kind = claim.kind.as_str();
let value = claim.value.as_str();
let column = match &claim.column {
Some(column) => format!(" col:{column}"),
None => String::new(),
};
let status = claim.status.as_str();
let mark = if is_confirmed(claim.status) {
String::new()
} else {
" (unconfirmed)".to_string()
};
format!(" {id} {kind} {value}{column} {status}{mark}\n")
}
fn abbreviate_id(id: &str) -> String {
const PREFIX: usize = 6;
if id.len() > PREFIX + 1 {
format!("{}…", &id[..PREFIX])
} else {
id.to_string()
}
}
pub(crate) fn knowledge_overridden_text(findings: &[OverrideFindingDto]) -> String {
if findings.is_empty() {
return String::new();
}
let mut out = format!(
"memory overridden · {n} finding{s}\n",
n = findings.len(),
s = if findings.len() == 1 { "" } else { "s" }
);
for finding in findings {
out.push_str(&finding_line(finding));
}
out
}
fn finding_line(finding: &OverrideFindingDto) -> String {
let id = abbreviate_id(finding.claim_id.as_str());
let observed = finding.observed_columns.join(", ");
format!(
" {id} referenced {observed} where you specified {claimed} ({kind})\n",
claimed = finding.claimed_value,
kind = finding.kind,
)
}
pub(crate) fn learning_skipped_text(reason: LearningSkipReason) -> String {
match reason {
LearningSkipReason::TimedOut => {
"memory not recorded · extraction timed out; this turn was not learned from\n".into()
}
LearningSkipReason::Failed => {
"memory not recorded · extraction failed; this turn was not learned from\n".into()
}
_ => "memory not recorded · this turn was not learned from\n".into(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use saya_agent::{
AgentEvent, KnowledgeOutcome, OverrideFindingDto, SuppliedClaimDto, SuppliedContractDto,
};
use saya_types::{ClaimId, ClaimStatus};
fn claim(
id: &str,
kind: &str,
value: &str,
column: Option<&str>,
status: ClaimStatus,
) -> SuppliedClaimDto {
SuppliedClaimDto {
claim_id: ClaimId::parse(id).unwrap(),
kind: kind.into(),
value: value.into(),
column: column.map(str::to_string),
status,
}
}
fn contract(
profile: &str,
object: &str,
state: &str,
claims: Vec<SuppliedClaimDto>,
) -> SuppliedContractDto {
SuppliedContractDto {
profile: profile.into(),
object: object.into(),
schema_state: state.into(),
claims,
}
}
fn ran(store_unavailable: bool) -> KnowledgeOutcome {
KnowledgeOutcome::Ran { store_unavailable }
}
#[test]
fn the_three_outcomes_render_distinguishably() {
let off = knowledge_supplied_text(KnowledgeOutcome::Off, &[], 0);
let skipped = knowledge_supplied_text(KnowledgeOutcome::Skipped, &[], 0);
let ran_empty = knowledge_supplied_text(ran(false), &[], 0);
let ran_found = knowledge_supplied_text(
ran(false),
&[contract(
"analytics",
"catalog.public.orders",
"current",
vec![claim(
"c-1",
"default_time_column",
"created_at",
Some("created_at"),
ClaimStatus::Confirmed,
)],
)],
0,
);
assert_eq!(off, "memory off · recall disabled\n");
assert_eq!(
skipped,
"memory skipped · not permitted to read saved claims\n"
);
assert_eq!(ran_empty, "");
assert!(ran_found.starts_with("memory supplied · 1 claim"));
assert!(
!ran_found.contains("1 claims"),
"a single claim must not read as a plural: {ran_found}"
);
assert_ne!(off, skipped);
assert_ne!(off, ran_empty);
assert_ne!(skipped, ran_empty);
assert_ne!(ran_empty, ran_found);
}
#[test]
fn a_candidate_claim_is_marked() {
let text = knowledge_supplied_text(
ran(false),
&[contract(
"analytics",
"catalog.public.orders",
"current",
vec![
claim(
"c-1",
"default_time_column",
"created_at",
Some("created_at"),
ClaimStatus::Confirmed,
),
claim("c-2", "table_alias", "orders", None, ClaimStatus::Candidate),
],
)],
0,
);
assert!(
text.contains("memory supplied · 2 claims (1 unconfirmed)"),
"{text}"
);
assert!(
text.contains("table_alias orders candidate (unconfirmed)"),
"{text}"
);
assert!(
text.contains("default_time_column created_at col:created_at confirmed\n"),
"{text}"
);
}
#[test]
fn a_nonzero_dropped_count_is_shown() {
let text = knowledge_supplied_text(
ran(false),
&[contract(
"analytics",
"catalog.public.orders",
"current",
vec![claim(
"c-1",
"table_alias",
"orders",
None,
ClaimStatus::Confirmed,
)],
)],
30,
);
assert!(text.contains("· 30 more dropped by bounds"), "{text}");
let all_dropped = knowledge_supplied_text(ran(false), &[], 30);
assert!(
all_dropped.contains("memory supplied · 0 claims"),
"{all_dropped}"
);
assert!(
all_dropped.contains("· 30 more dropped by bounds"),
"{all_dropped}"
);
}
#[test]
fn store_unavailable_renders_distinctly() {
let store_down = knowledge_supplied_text(ran(true), &[], 0);
let ran_empty = knowledge_supplied_text(ran(false), &[], 0);
let skipped = knowledge_supplied_text(KnowledgeOutcome::Skipped, &[], 0);
assert!(
store_down.contains("memory supplied · store unavailable"),
"{store_down}"
);
assert_ne!(store_down, ran_empty);
assert_ne!(store_down, skipped);
}
#[test]
fn no_opaque_identity_reaches_output() {
let fake_identity =
"sha256:9f2a8c7b1e4d0a6f3c5b8e2d7a9f1c4b6e8a0d2f4c6b8e0a2d4f6c8b0e2d4f6";
let text = knowledge_supplied_text(
ran(false),
&[contract(
"analytics",
"catalog.public.orders",
"current",
vec![claim(
"c-1",
"table_alias",
"orders",
None,
ClaimStatus::Confirmed,
)],
)],
0,
);
assert!(text.contains("analytics"), "profile name appears: {text}");
assert!(
!text.contains(fake_identity),
"opaque identity leaked: {text}"
);
}
#[test]
fn event_serializes_with_type_tag() {
let event = AgentEvent::knowledge_supplied(
ran(false),
vec![contract(
"analytics",
"catalog.public.orders",
"current",
vec![claim(
"c-1",
"table_alias",
"orders",
None,
ClaimStatus::Candidate,
)],
)],
0,
);
let json = serde_json::to_string(&event).expect("serializes");
assert!(json.contains(r#""type":"knowledge_supplied""#), "{json}");
assert!(json.contains("supplied"), "{json}");
}
#[test]
fn claim_line_shows_the_short_id_prefix_not_the_full_id() {
let long_id = "c-a86a3f0e9d7c5b4a2f0e9d7c5b4a2f0e9d7c5b4a2f0e9d7c5b4a2f0e9d7c5b4a";
let text = knowledge_supplied_text(
ran(false),
&[contract(
"analytics",
"catalog.public.orders",
"current",
vec![claim(
long_id,
"table_alias",
"orders",
None,
ClaimStatus::Candidate,
)],
)],
0,
);
assert!(
text.contains("c-a86a…"),
"short prefix must appear so the user can type it: {text}"
);
assert!(
!text.contains(&long_id[7..]),
"the full id beyond the prefix must not appear: {text}"
);
}
fn override_finding(id: &str, claimed: &str, observed: &[&str]) -> OverrideFindingDto {
OverrideFindingDto {
claim_id: ClaimId::parse(id).unwrap(),
kind: "default_time_column".into(),
claimed_value: claimed.into(),
observed_columns: observed.iter().map(|s| s.to_string()).collect(),
}
}
#[test]
fn no_findings_render_nothing() {
assert_eq!(knowledge_overridden_text(&[]), "");
}
#[test]
fn one_finding_names_the_referenced_column_and_the_specified_value() {
let text = knowledge_overridden_text(&[override_finding(
"c-rental-time",
"return_date",
&["rental_date"],
)]);
assert!(
text.contains("memory overridden · 1 finding"),
"header: {text}"
);
assert!(
text.contains("referenced rental_date"),
"names the column the SQL referenced: {text}"
);
assert!(
text.contains("where you specified return_date"),
"names what the claim specified: {text}"
);
assert!(
text.contains("default_time_column"),
"names the kind: {text}"
);
}
#[test]
fn multiple_findings_pluralize_the_header() {
let text = knowledge_overridden_text(&[
override_finding("c-a", "return_date", &["rental_date"]),
override_finding("c-b", "created_at", &["updated_at"]),
]);
assert!(
text.contains("memory overridden · 2 findings"),
"pluralized header: {text}"
);
assert!(text.contains("referenced rental_date"), "{text}");
assert!(text.contains("referenced updated_at"), "{text}");
}
#[test]
fn the_rendered_text_does_not_assert_the_model_used_a_time_column() {
let text = knowledge_overridden_text(&[override_finding(
"c-rental-time",
"return_date",
&["rental_date"],
)]);
assert!(
!text.contains("used"),
"the shaper must not assert a causal 'used' about the time column: {text}"
);
assert!(
text.contains("referenced"),
"the shaper says 'referenced': {text}"
);
}
#[test]
fn overridden_event_serializes_with_type_tag_and_no_identity() {
let event = AgentEvent::knowledge_overridden(vec![override_finding(
"c-rental-time",
"return_date",
&["rental_date"],
)]);
let json = serde_json::to_string(&event).expect("serializes");
assert!(
json.contains(r#""type":"knowledge_overridden""#),
"type tag: {json}"
);
let fake_identity =
"sha256:9f2a8c7b1e4d0a6f3c5b8e2d7a9f1c4b6e8a0d2f4c6b8e0a2d4f6c8b0e2d4f6";
assert!(
!json.contains(fake_identity),
"opaque identity leaked into the event: {json}"
);
}
#[test]
fn the_two_skip_reasons_render_the_spec_strings_and_differ() {
use saya_agent::LearningSkipReason;
let timed_out = learning_skipped_text(LearningSkipReason::TimedOut);
let failed = learning_skipped_text(LearningSkipReason::Failed);
assert_eq!(
timed_out,
"memory not recorded · extraction timed out; this turn was not learned from\n",
);
assert_eq!(
failed,
"memory not recorded · extraction failed; this turn was not learned from\n",
);
assert_ne!(timed_out, failed);
assert!(timed_out.contains("memory not recorded"));
assert!(failed.contains("memory not recorded"));
assert!(timed_out.contains("not learned from"));
assert!(failed.contains("not learned from"));
}
}