20260601 sequence triage支持独立时间序列

This commit is contained in:
2026-06-01 23:41:17 +08:00
parent 902f3ba889
commit 9579f65501
4 changed files with 304 additions and 27 deletions

View File

@@ -547,6 +547,198 @@ mod tests {
assert!(output.get("adjusted").is_none());
}
#[test]
fn run_uses_cir_validation_time_and_sorts_by_time_not_raw_seq() {
let temp = tempfile::tempdir().expect("tempdir");
let root = temp.path();
let stable = object("rsync://example.net/pp/stable.roa", 0x11);
write_sample(
root,
"left5",
"left",
5,
std::slice::from_ref(&stable),
&[],
64496,
);
write_sample(
root,
"left1",
"left",
1,
std::slice::from_ref(&stable),
&[],
64496,
);
write_sample(root, "right3", "right", 3, &[], &[], 64497);
std::fs::write(
root.join("left.jsonl"),
jsonl(&[
item("left", 1, "left5/result.ccr", "left5/result.cir"),
item("left", 2, "left1/result.ccr", "left1/result.cir"),
]),
)
.unwrap();
std::fs::write(
root.join("right.jsonl"),
jsonl(&[item("right", 10, "right3/result.ccr", "right3/result.cir")]),
)
.unwrap();
run(Args {
left_sequence: root.join("left.jsonl"),
right_sequence: root.join("right.jsonl"),
out_dir: root.join("out"),
align_window_runs: 0,
align_window_secs: 0,
sample_limit: 20,
warmup_samples: 0,
cooldown_samples: 0,
timeline_sample_limit: 0,
})
.expect("run");
let output: Value = serde_json::from_str(
&std::fs::read_to_string(root.join("out/sequence-triage.json")).unwrap(),
)
.unwrap();
assert_eq!(output["left"]["samples"][0]["seq"].as_u64(), Some(2));
assert_eq!(output["left"]["samples"][1]["seq"].as_u64(), Some(1));
assert_eq!(
sandwich_class_occurrences(&output, "PEER_MISSING_STABLE_OBJECT"),
1
);
assert_eq!(
output["parameters"]["sequenceSemantics"]["timeSource"].as_str(),
Some("cir.validation_time")
);
}
#[test]
fn run_uses_adjacent_source_windows_only() {
let temp = tempfile::tempdir().expect("tempdir");
let root = temp.path();
let stable = object("rsync://example.net/pp/stable.roa", 0x11);
write_sample(
root,
"left1",
"left",
1,
std::slice::from_ref(&stable),
&[],
64496,
);
write_sample(root, "left3", "left", 3, &[], &[], 64496);
write_sample(
root,
"left5",
"left",
5,
std::slice::from_ref(&stable),
&[],
64496,
);
write_sample(root, "right4", "right", 4, &[], &[], 64497);
std::fs::write(
root.join("left.jsonl"),
jsonl(&[
item("left", 1, "left1/result.ccr", "left1/result.cir"),
item("left", 3, "left3/result.ccr", "left3/result.cir"),
item("left", 5, "left5/result.ccr", "left5/result.cir"),
]),
)
.unwrap();
std::fs::write(
root.join("right.jsonl"),
jsonl(&[item("right", 4, "right4/result.ccr", "right4/result.cir")]),
)
.unwrap();
run(Args {
left_sequence: root.join("left.jsonl"),
right_sequence: root.join("right.jsonl"),
out_dir: root.join("out"),
align_window_runs: 0,
align_window_secs: 0,
sample_limit: 20,
warmup_samples: 0,
cooldown_samples: 0,
timeline_sample_limit: 0,
})
.expect("run");
let output: Value = serde_json::from_str(
&std::fs::read_to_string(root.join("out/sequence-triage.json")).unwrap(),
)
.unwrap();
assert_eq!(
sandwich_class_occurrences(&output, "PEER_MISSING_STABLE_OBJECT"),
0
);
}
#[test]
fn run_checks_every_peer_sample_inside_adjacent_window() {
let temp = tempfile::tempdir().expect("tempdir");
let root = temp.path();
let stable = object("rsync://example.net/pp/stable.roa", 0x11);
write_sample(
root,
"left1",
"left",
1,
std::slice::from_ref(&stable),
&[],
64496,
);
write_sample(
root,
"left5",
"left",
5,
std::slice::from_ref(&stable),
&[],
64496,
);
write_sample(root, "right2", "right", 2, &[], &[], 64497);
write_sample(root, "right4", "right", 4, &[], &[], 64497);
write_sample(root, "right6", "right", 6, &[], &[], 64497);
std::fs::write(
root.join("left.jsonl"),
jsonl(&[
item("left", 1, "left1/result.ccr", "left1/result.cir"),
item("left", 5, "left5/result.ccr", "left5/result.cir"),
]),
)
.unwrap();
std::fs::write(
root.join("right.jsonl"),
jsonl(&[
item("right", 2, "right2/result.ccr", "right2/result.cir"),
item("right", 4, "right4/result.ccr", "right4/result.cir"),
item("right", 6, "right6/result.ccr", "right6/result.cir"),
]),
)
.unwrap();
run(Args {
left_sequence: root.join("left.jsonl"),
right_sequence: root.join("right.jsonl"),
out_dir: root.join("out"),
align_window_runs: 0,
align_window_secs: 0,
sample_limit: 20,
warmup_samples: 0,
cooldown_samples: 0,
timeline_sample_limit: 0,
})
.expect("run");
let output: Value = serde_json::from_str(
&std::fs::read_to_string(root.join("out/sequence-triage.json")).unwrap(),
)
.unwrap();
assert_eq!(
sandwich_class_occurrences(&output, "PEER_MISSING_STABLE_OBJECT"),
2
);
assert_eq!(output["right"]["sampleCount"].as_u64(), Some(3));
}
fn sandwich_class_occurrences(output: &Value, class: &str) -> u64 {
output["sandwich"]["classificationCounts"]
.as_array()

View File

@@ -59,12 +59,12 @@ pub(super) fn load_sequence(path: &Path, side: Side) -> Result<Vec<SequenceSampl
ccr_path.display()
)
})?;
let validation_time = raw
let _sequence_validation_time = raw
.validation_time
.as_deref()
.map(parse_rfc3339)
.transpose()?
.unwrap_or(cir.validation_time);
.transpose()?;
let validation_time = cir.validation_time;
let objects = cir
.objects
.iter()
@@ -122,12 +122,12 @@ pub(super) fn load_sequence(path: &Path, side: Side) -> Result<Vec<SequenceSampl
vaps,
});
}
samples.sort_by_key(|sample| sample.raw.seq);
for pair in samples.windows(2) {
if pair[0].raw.seq >= pair[1].raw.seq {
return Err("sequence must be sorted by increasing seq".into());
}
}
samples.sort_by(|left, right| {
left.validation_time
.cmp(&right.validation_time)
.then_with(|| left.raw.seq.cmp(&right.raw.seq))
.then_with(|| left.raw.run_id.cmp(&right.raw.run_id))
});
if samples.iter().any(|sample| {
sample
.raw

View File

@@ -30,12 +30,18 @@ pub(super) fn build_output(
"warmupSamples": args.warmup_samples,
"cooldownSamples": args.cooldown_samples,
"timelineSampleLimit": if args.timeline_sample_limit == 0 { args.sample_limit } else { args.timeline_sample_limit },
"sequenceSemantics": {
"timeSource": "cir.validation_time",
"sourceSampleOrder": "each side is sorted by CIR validation_time, with seq/runId used only as stable tie breakers",
"sourceWindow": "only adjacent samples from the same side after CIR-time sorting form a source interval",
"peerWindow": "all peer samples with source_start.time < peer.time < source_end.time are checked independently",
},
},
"left": sequence_summary(left),
"right": sequence_summary(right),
"sandwich": {
"strictTimeWindow": true,
"method": "For each side, use two adjacent source samples as a stable interval. If source_start.time < peer.time < source_end.time and the source value is identical at both interval endpoints, the peer sample is expected to contain the same value.",
"method": "For each side sorted by CIR validation_time, use two adjacent source samples as a stable interval. If source_start.time < peer.time < source_end.time and the source value is identical at both interval endpoints, every peer sample in the interval is expected to contain the same value.",
"totals": {
"occurrences": sandwich.total_occurrences,
"uniqueKeys": sandwich.unique_keys.len(),
@@ -63,6 +69,7 @@ pub(super) fn build_output(
"intraRpChurnMeaning": "Adjacent-run churn measures how much each RP's own observed object/output/reject sets changed during the sequence. It is a baseline for judging whether cross-RP anomalies exceed natural sequence movement.",
"limits": [
"Sequence triage only reads sequence JSONL plus referenced CCR/CIR files.",
"Sample time is always read from CIR validation_time; sequence JSONL validationTime is accepted only as optional metadata.",
"It does not read report.json, logs, repo-bytes DB, cache, mirror, or raw objects for root cause proof.",
"Sandwich anomalies indicate stable-interval contradictions, not final root cause. Manual evidence is still required for network, repository, or implementation attribution."
]