Export partially-captured groups as stable

Stop treating partially-captured oldest timestamp groups (including Arc 10-pulls) as a special "incomplete" case. All decoded rows are now exported and marked with stable UIDs; the previous INCOMPLETE_* warnings and related console hint logic were removed. annotate_groups and annotate_arc_groups were simplified to always set uid_status="stable" and export_record=True, and arc_stability_warnings was deleted. CLI, live capture runner, console, tests, docs and mappings were updated to reflect the new behavior and to remove emission/handling of those informational warnings.
This commit is contained in:
Golumpa 2026-06-11 14:57:08 +01:00
parent 20b2086299
commit 5d16dac4d5
10 changed files with 53 additions and 170 deletions

View file

@ -20,7 +20,6 @@ from nte_history_exporter.constants import POOL_META
from nte_history_exporter.mappings import ARC_META, CHARACTERS, ITEMS, REWARDS_BY_ID
from nte_history_exporter.decoder.protocol import decode_reward_key, infer_reward_type
from nte_history_exporter.decoder.arc import (
arc_stability_warnings,
build_arc_rows_from_pairs,
decode_arc_key,
decode_arc_timestamp,
@ -163,19 +162,15 @@ class BoundaryExportTests(unittest.TestCase):
}
self.assertNotEqual(make_uid(row, 0), "5adcf52282e15445466863b271f3b745")
def test_pages_1_to_5_exports_dice_complete_oldest_group(self):
def test_pages_1_to_5_exports_every_row(self):
rows = load_reference_csv("monopoly_history_poc_13_pages_1_to_5_v4.csv")
annotated, warnings = annotate_groups(rows)
annotated = annotate_groups(rows)
exported = [row for row in annotated if row["export_record"] is True]
skipped = [row for row in annotated if row["export_record"] is False]
# The oldest group has exactly 10 dice rolls (plus a Points Gift), which
# proves the pull set is complete even though the scan stopped on a full page.
# Every decoded row is exported; boundary groups are never dropped.
self.assertEqual(len(annotated), 25)
self.assertEqual(len(exported), 25)
self.assertEqual(len(skipped), 0)
self.assertEqual(len(warnings), 0)
@staticmethod
def _synthetic_row(page, timestamp_hex, result_type):
@ -189,24 +184,21 @@ class BoundaryExportTests(unittest.TestCase):
"quantity": 1,
}
def test_oldest_group_with_partial_dice_count_exports_with_warning(self):
def test_oldest_group_with_partial_dice_count_exports_without_warning(self):
rows = [self._synthetic_row(1, "aa", "dice") for _ in range(5)]
rows += [self._synthetic_row(2, "bb", "dice") for _ in range(5)]
rows += [self._synthetic_row(3, "bb", "dice") for _ in range(4)]
rows += [self._synthetic_row(3, "bb", "points_gift")]
annotated, warnings = annotate_groups(rows)
annotated = annotate_groups(rows)
exported = [row for row in annotated if row["export_record"] is True]
# Oldest group is an unfinished 10-pull on a full final page. Its captured
# prefix is ordinal-stable, so it is exported with an informational warning
# rather than dropped.
# Oldest group is a partially captured 10-pull on a full final page. Its
# captured prefix is ordinal-stable, so it is exported with stable UIDs.
self.assertEqual(len(exported), 15)
self.assertEqual(len(warnings), 1)
self.assertEqual(warnings[0]["code"], "INCOMPLETE_TIMESTAMP_GROUP_EXPORTED")
self.assertEqual(warnings[0]["dice_records"], 9)
oldest = [row for row in annotated if row["timestamp_raw_hex"] == "bb"]
self.assertTrue(all(row["uid"] for row in oldest))
self.assertTrue(all(row["uid_status"] == "stable" for row in oldest))
self.assertEqual([row["timestamp_group_ordinal"] for row in oldest], list(range(10)))
def test_incomplete_oldest_prefix_keeps_stable_uids(self):
@ -215,8 +207,8 @@ class BoundaryExportTests(unittest.TestCase):
full += [self._synthetic_row(3, "bb", "dice") for _ in range(2)]
truncated = [r for r in full if r["page"] in (1, 2)]
full_rows, _ = annotate_groups([dict(r) for r in full])
trunc_rows, _ = annotate_groups([dict(r) for r in truncated])
full_rows = annotate_groups([dict(r) for r in full])
trunc_rows = annotate_groups([dict(r) for r in truncated])
full_uids = [r["uid"] for r in full_rows if r["timestamp_raw_hex"] == "bb"][:3]
trunc_uids = [r["uid"] for r in trunc_rows if r["timestamp_raw_hex"] == "bb"]
@ -230,11 +222,10 @@ class BoundaryExportTests(unittest.TestCase):
rows += [self._synthetic_row(2, "bb", "dice") for _ in range(5)]
rows += [self._synthetic_row(3, "bb", "dice") for _ in range(5)]
annotated, warnings = annotate_groups(rows)
annotated = annotate_groups(rows)
exported = [row for row in annotated if row["export_record"] is True]
self.assertEqual(len(exported), 15)
self.assertEqual(len(warnings), 0)
def test_run_selection_anchors_to_page_1_and_keeps_newest(self):
# Page 2's response was lost: captured pages 1, 3, 4, 5.
@ -257,7 +248,7 @@ class BoundaryExportTests(unittest.TestCase):
def test_full_reference_scan_exports_all_rows(self):
rows = load_reference_csv("monopoly_history_poc_10_all_44_pages_v4.csv")
annotated, warnings = annotate_groups(rows)
annotated = annotate_groups(rows)
exported = [row for row in annotated if row["export_record"] is True]
json_path = EXPORTS / "monopoly_history_export_10_all_44_pages_v4.json"
@ -270,12 +261,11 @@ class BoundaryExportTests(unittest.TestCase):
self.assertEqual(len(annotated), reference["scan"]["decoded_records"])
self.assertEqual(len(exported), reference["scan"]["exported_records"])
self.assertEqual(warnings, [])
def test_sanitized_export_omits_raw_packet_fields(self):
rows = load_reference_csv("monopoly_history_poc_13_pages_1_to_5_v4.csv")
annotated, warnings = annotate_groups(rows)
export = build_export_json(annotated, warnings)
annotated = annotate_groups(rows)
export = build_export_json(annotated, [])
self.assertEqual(export["format"], "nte-history-export")
self.assertIn("exporter", export)
@ -366,7 +356,7 @@ class BoundaryExportTests(unittest.TestCase):
self.assertEqual(decoded[0]["reward_type"], "arc")
self.assertEqual(decoded[0]["reward_key_hex"], reference_rows[0]["arc_key_hex"])
def test_arc_partial_timestamp_group_is_exported_with_warning(self):
def test_arc_partial_timestamp_group_is_exported_without_warning(self):
rows = load_arc_csv("arc_pages_1_to_5_v2.csv")
pairs = []
for page in range(1, 6):
@ -375,17 +365,14 @@ class BoundaryExportTests(unittest.TestCase):
pairs.append((page, page * 2, page, 1.0, page + 100, 1.1, response))
decoded = build_arc_rows_from_pairs(pairs)
exported = [row for row in decoded if row["export_record"] is True]
incomplete = [row for row in decoded if row["uid_status"] == "incomplete_stable"]
warnings = arc_stability_warnings(decoded)
# The oldest group is a 10-pull split by stopping at page 5 (5 of 10 rows).
# Its captured prefix is ordinal-stable, so it is exported, not dropped.
# Its captured prefix is ordinal-stable, so every row is exported with a
# stable UID and no warning.
self.assertEqual(len(decoded), 25)
self.assertEqual(len(exported), 25)
self.assertEqual(len(incomplete), 5)
self.assertEqual(len(warnings), 1)
self.assertEqual(warnings[0]["code"], "INCOMPLETE_ARC_10_PULL_EXPORTED")
self.assertTrue(all(row["uid"] for row in incomplete))
self.assertTrue(all(row["uid"] for row in decoded))
self.assertTrue(all(row["uid_status"] == "stable" for row in decoded))
def test_arc_incomplete_prefix_keeps_stable_uids(self):
rows = load_arc_csv("arc_pull_10_all_pages_v2.csv")
@ -468,13 +455,14 @@ class BoundaryExportTests(unittest.TestCase):
},
]
annotated, warnings = annotate_groups(rows)
annotated = annotate_groups(rows)
# Ordinals cover every row in the group, but the dice-only count drives
# timestamp_group_size_seen (2 dice in the 4-record group).
self.assertEqual([row["timestamp_group_ordinal"] for row in annotated[:4]], [0, 1, 2, 3])
self.assertEqual({row["timestamp_group_size_seen"] for row in annotated[:4]}, {2})
self.assertEqual({row["timestamp_group_record_size_seen"] for row in annotated[:4]}, {4})
self.assertEqual(warnings[0]["dice_records"], 1)
self.assertEqual(warnings[0]["records"], 1)
self.assertTrue(all(row["export_record"] for row in annotated))
def test_live_session_pairs_request_and_response(self):
session = LiveHistorySession("192.168.0.10")