Skip to content

Commit ecf81a4

Browse files
Pigbibicodex
andauthored
fix: harden M0 research ledger validation (#306)
Co-authored-by: Codex <noreply@openai.com>
1 parent 285e977 commit ecf81a4

5 files changed

Lines changed: 230 additions & 30 deletions

File tree

docs/m0_research_ledger_contract.md

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,12 @@ errors: [安全错误码, ...]
3131
- 不含账户、仓位、权重、订单、路由、平台、运行时、密钥或执行语义。
3232

3333
快照不得把失效研究线索重新标为新信号。`ready` 来源必须提供时间与来源
34-
digest;`unavailable` 来源不得携带 hypothesis。
34+
digest,且 `errors` 必须为空;`unavailable` 来源不得携带 hypothesis。来源、
35+
subject、theme 与 hypothesis 标识采用与 `QuantAdvisorResearch` 完全相同的
36+
字符集,不接受 `=`。M0 v5/v6 的 provenance 也是闭合配对:
37+
38+
- v5 必须为 `model_recommendations.v5``source_input_digest=null`
39+
- v6 必须为 `model_recommendations.v6``source_input_digest` 为 SHA-256。
3540

3641
## 聚合行为
3742

@@ -43,10 +48,16 @@ digest;`unavailable` 来源不得携带 hypothesis。
4348
3. 同一 subject + source digest 出现不同内容时,作为
4449
`m0_source_subject_collision` 故障闭合剔除。
4550
4. 同一 subject 的不同报告保留为独立观测;若 `primary_horizon` 不同,
46-
标记 `horizon_conflict.status=conflict`。这是研究队列的人工/后续验证信号,
47-
不是交易信号。
51+
仅当**当前 fresh**观测不同才标记 `horizon_conflict.status=conflict`
52+
已失效观测被独立投影为 `historical_stale_horizon_drift`:只有存在当前
53+
fresh 基准且历史 horizon 不同时才标记 `drift`;完全 stale 的 subject
54+
只标记 `unavailable`,不会伪造当前冲突。这两者都是研究队列的人工/后续
55+
验证信息,不是交易信号。
4856
5. 依据 `now``expires_at` 产生 `fresh``stale``unknown`;来源自身
4957
`stale` 时不会被提升为 fresh。
58+
6. source 的 `generated_at``computed_at` 晚于聚合传入的 `now` 时,整个
59+
source 以 `m0_source_future_timestamp` 故障闭合剔除;不会以 `unknown`
60+
继续展示或参与去重。
5061

5162
输出台账始终固定:
5263

python/scripts/m0_research_ledger.py

Lines changed: 66 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,10 @@
7575
{"event_driven", "long_horizon_growth", "value_quality", "macro_context", "mixed_research"}
7676
)
7777
_SOURCE_STATUSES = frozenset({"ready", "unavailable", "stale"})
78-
_IDENTIFIER = re.compile(r"^[A-Za-z0-9._:/=-]{1,128}$")
78+
# Keep this byte-for-byte compatible with QuantAdvisorResearch's M0 contract.
79+
# In particular, ``=`` is not a valid subject, theme, source, or hypothesis
80+
# identifier there and must not be accepted by this downstream mirror.
81+
_IDENTIFIER = re.compile(r"^[A-Za-z0-9._:/-]{1,128}$")
7982
_ERROR_CODE = re.compile(r"^[a-z][a-z0-9_.-]{0,63}$")
8083
_SHA256 = re.compile(r"^[0-9a-f]{64}$")
8184
_UTC_TIMESTAMP = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?Z$")
@@ -285,6 +288,8 @@ def validate_m0_research_source_snapshot(payload: object) -> dict[str, Any]:
285288
source_report_digest is None or generated_at is None or computed_at is None
286289
):
287290
raise M0ResearchLedgerValidationError("ready_source_metadata_invalid")
291+
if snapshot["data_status"] == "ready" and errors:
292+
raise M0ResearchLedgerValidationError("ready_source_errors_invalid")
288293
if snapshot["data_status"] == "unavailable" and hypotheses:
289294
raise M0ResearchLedgerValidationError("unavailable_source_hypotheses_invalid")
290295
if hypotheses and source_report_digest is None:
@@ -322,6 +327,52 @@ def _source_error(error_set: set[str], code: str) -> None:
322327
error_set.add(code)
323328

324329

330+
def _snapshot_time_is_future(snapshot: Mapping[str, Any], now: dt.datetime) -> bool:
331+
"""Return whether source metadata could have been produced after this ledger.
332+
333+
A future source clock is not merely displayed as an ``unknown`` freshness
334+
state. The entire source is omitted so a clock-skewed or replayed source
335+
cannot become a current research input by accident.
336+
"""
337+
338+
for field, label in (("generated_at", "source_generated_at"), ("computed_at", "source_computed_at")):
339+
value = _parse_timestamp(snapshot[field], label, nullable=True)
340+
if value is not None and value > now:
341+
return True
342+
return False
343+
344+
345+
def _horizon_views(observations: Sequence[Mapping[str, Any]]) -> tuple[dict[str, Any], dict[str, Any]]:
346+
"""Separate present disagreement from non-actionable historical drift."""
347+
348+
fresh_horizons = sorted(
349+
{
350+
observation["research_context"]["primary_horizon"]
351+
for observation in observations
352+
if observation["freshness"]["status"] == "fresh"
353+
}
354+
)
355+
stale_horizons = sorted(
356+
{
357+
observation["research_context"]["primary_horizon"]
358+
for observation in observations
359+
if observation["freshness"]["status"] == "stale"
360+
}
361+
)
362+
current = {
363+
"status": "conflict" if len(fresh_horizons) > 1 else "none",
364+
"primary_horizons": fresh_horizons,
365+
}
366+
if fresh_horizons:
367+
stale_status = "drift" if stale_horizons and stale_horizons != fresh_horizons else "none"
368+
else:
369+
# A stale-only subject has no current primary horizon against which to
370+
# call a drift. It remains visible for audit, but is not an alert.
371+
stale_status = "unavailable" if stale_horizons else "none"
372+
historical_stale = {"status": stale_status, "primary_horizons": stale_horizons}
373+
return current, historical_stale
374+
375+
325376
def aggregate_m0_research_sources(
326377
snapshots: Sequence[object], *, now: str | dt.datetime
327378
) -> dict[str, Any]:
@@ -352,6 +403,9 @@ def aggregate_m0_research_sources(
352403
except M0ResearchLedgerValidationError:
353404
_source_error(error_set, "m0_source_invalid")
354405
continue
406+
if _snapshot_time_is_future(snapshot, now_at):
407+
_source_error(error_set, "m0_source_future_timestamp")
408+
continue
355409
if snapshot["data_status"] == "unavailable":
356410
_source_error(error_set, "m0_source_unavailable")
357411
continue
@@ -388,13 +442,15 @@ def aggregate_m0_research_sources(
388442
fresh_count = 0
389443
stale_count = 0
390444
unknown_count = 0
391-
conflict_count = 0
445+
current_conflict_count = 0
446+
historical_stale_drift_count = 0
392447
for (kind, identifier), observations in sorted(subject_entries.items()):
393448
observations.sort(key=lambda entry: (entry["source_report_digest"], entry["source_entry_digest"]))
394-
horizons = sorted({entry["research_context"]["primary_horizon"] for entry in observations})
395-
conflict = len(horizons) > 1
396-
if conflict:
397-
conflict_count += 1
449+
horizon_conflict, historical_stale_horizon_drift = _horizon_views(observations)
450+
if horizon_conflict["status"] == "conflict":
451+
current_conflict_count += 1
452+
if historical_stale_horizon_drift["status"] == "drift":
453+
historical_stale_drift_count += 1
398454
for observation in observations:
399455
status = observation["freshness"]["status"]
400456
if status == "fresh":
@@ -407,10 +463,8 @@ def aggregate_m0_research_sources(
407463
{
408464
"subject": {"kind": kind, "identifier": identifier},
409465
"observations": observations,
410-
"horizon_conflict": {
411-
"status": "conflict" if conflict else "none",
412-
"primary_horizons": horizons,
413-
},
466+
"horizon_conflict": horizon_conflict,
467+
"historical_stale_horizon_drift": historical_stale_horizon_drift,
414468
}
415469
)
416470

@@ -427,7 +481,8 @@ def aggregate_m0_research_sources(
427481
"fresh_observation_count": fresh_count,
428482
"stale_observation_count": stale_count,
429483
"unknown_observation_count": unknown_count,
430-
"horizon_conflict_count": conflict_count,
484+
"horizon_conflict_count": current_conflict_count,
485+
"historical_stale_horizon_drift_count": historical_stale_drift_count,
431486
},
432487
"subjects": subjects,
433488
"policy": {

python/tests/test_m0_research_ledger.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,17 @@ def test_source_and_ledger_schemas_remain_closed_and_read_only(self):
9292
self.assertFalse(source_schema["additionalProperties"])
9393
self.assertFalse(ledger_schema["additionalProperties"])
9494
self.assertEqual(source_schema["properties"]["schema_version"]["const"], "qsl_m0_research_source_snapshot.v1")
95+
self.assertEqual(source_schema["properties"]["source_id"]["pattern"], "^[A-Za-z0-9._:/-]{1,128}$")
96+
self.assertEqual(source_schema["allOf"][0]["then"]["properties"]["errors"], {"maxItems": 0})
97+
provenance_variants = source_schema["$defs"]["m0Hypothesis"]["properties"]["provenance"]["oneOf"]
98+
self.assertEqual(len(provenance_variants), 2)
99+
self.assertEqual(provenance_variants[0]["properties"]["source_schema_version"], {"const": "5"})
100+
self.assertEqual(provenance_variants[0]["properties"]["source_input_digest"], {"type": "null"})
101+
self.assertEqual(provenance_variants[1]["properties"]["source_schema_version"], {"const": "6"})
102+
self.assertEqual(
103+
provenance_variants[1]["properties"]["source_input_digest"],
104+
{"type": "string", "pattern": "^[0-9a-f]{64}$"},
105+
)
95106
self.assertEqual(ledger_schema["properties"]["policy"]["properties"]["no_order"], {"const": True})
96107
self.assertEqual(
97108
ledger_schema["properties"]["policy"]["properties"]["permitted_next_step"],
@@ -126,9 +137,11 @@ def test_aggregation_deduplicates_subject_and_source_and_flags_horizon_conflict(
126137
"stale_observation_count": 0,
127138
"unknown_observation_count": 0,
128139
"horizon_conflict_count": 1,
140+
"historical_stale_horizon_drift_count": 0,
129141
})
130142
subject = ledger["subjects"][0]
131143
self.assertEqual(subject["horizon_conflict"], {"status": "conflict", "primary_horizons": ["long", "medium"]})
144+
self.assertEqual(subject["historical_stale_horizon_drift"], {"status": "none", "primary_horizons": []})
132145
self.assertEqual(subject["observations"][0]["source_ids"], ["quant-advisor-research", "research-mirror"])
133146

134147
def test_expired_or_stale_source_is_visible_but_cannot_become_fresh(self):
@@ -143,11 +156,36 @@ def test_expired_or_stale_source_is_visible_but_cannot_become_fresh(self):
143156
self.assertEqual(ledger["data_status"], "stale")
144157
self.assertEqual(ledger["summary"]["fresh_observation_count"], 0)
145158
self.assertEqual(ledger["summary"]["stale_observation_count"], 2)
159+
self.assertEqual(ledger["summary"]["horizon_conflict_count"], 0)
160+
self.assertEqual(ledger["summary"]["historical_stale_horizon_drift_count"], 0)
146161
self.assertEqual(
147162
{entry["freshness"]["status"] for item in ledger["subjects"] for entry in item["observations"]},
148163
{"stale"},
149164
)
150165

166+
def test_historical_stale_horizon_drift_does_not_create_a_current_conflict(self):
167+
fresh = self._snapshot(self._hypothesis(primary_horizon="medium"), data_status="ready")
168+
historical = self._snapshot(
169+
self._hypothesis(
170+
report_digest="d" * 64,
171+
entry_digest="e" * 64,
172+
hypothesis_id="m0r-long-history",
173+
primary_horizon="long",
174+
),
175+
data_status="stale",
176+
)
177+
ledger = m0_research_ledger.aggregate_m0_research_sources(
178+
[fresh, historical], now="2026-08-21T12:00:00Z"
179+
)
180+
subject = ledger["subjects"][0]
181+
self.assertEqual(subject["horizon_conflict"], {"status": "none", "primary_horizons": ["medium"]})
182+
self.assertEqual(
183+
subject["historical_stale_horizon_drift"],
184+
{"status": "drift", "primary_horizons": ["long"]},
185+
)
186+
self.assertEqual(ledger["summary"]["horizon_conflict_count"], 0)
187+
self.assertEqual(ledger["summary"]["historical_stale_horizon_drift_count"], 1)
188+
151189
def test_m0_authority_execution_escape_and_source_digest_mismatch_fail_closed(self):
152190
for mutate, message in (
153191
(lambda value: value.update(authority="shadow_only"), "authority_invalid"),
@@ -165,6 +203,53 @@ def test_m0_authority_execution_escape_and_source_digest_mismatch_fail_closed(se
165203
with self.assertRaisesRegex(m0_research_ledger.M0ResearchLedgerValidationError, "source_report_digest_mismatch"):
166204
m0_research_ledger.validate_m0_research_source_snapshot(snapshot)
167205

206+
def test_ready_source_errors_and_non_qar_identifier_fail_closed(self):
207+
snapshot = self._snapshot(self._hypothesis())
208+
snapshot["errors"] = ["upstream_timeout"]
209+
with self.assertRaisesRegex(m0_research_ledger.M0ResearchLedgerValidationError, "ready_source_errors_invalid"):
210+
m0_research_ledger.validate_m0_research_source_snapshot(snapshot)
211+
212+
hypothesis = self._hypothesis()
213+
hypothesis["subject"]["identifier"] = "SOXX=leveraged"
214+
with self.assertRaisesRegex(m0_research_ledger.M0ResearchLedgerValidationError, "subject_identifier_invalid"):
215+
m0_research_ledger.validate_m0_research_hypothesis(hypothesis)
216+
217+
def test_v5_v6_provenance_pairing_matches_the_closed_schema(self):
218+
v5 = self._hypothesis()
219+
v5["provenance"].update(
220+
source_schema_version="5",
221+
source_contract_version="model_recommendations.v5",
222+
source_input_digest=None,
223+
)
224+
m0_research_ledger.validate_m0_research_hypothesis(v5)
225+
226+
invalid_v6 = self._hypothesis()
227+
invalid_v6["provenance"]["source_input_digest"] = None
228+
with self.assertRaisesRegex(m0_research_ledger.M0ResearchLedgerValidationError, "source_input_digest_invalid"):
229+
m0_research_ledger.validate_m0_research_hypothesis(invalid_v6)
230+
231+
invalid_v5 = copy.deepcopy(v5)
232+
invalid_v5["provenance"]["source_contract_version"] = "model_recommendations.v6"
233+
with self.assertRaisesRegex(m0_research_ledger.M0ResearchLedgerValidationError, "source_contract_version_invalid"):
234+
m0_research_ledger.validate_m0_research_hypothesis(invalid_v5)
235+
236+
def test_future_source_metadata_is_omitted_fail_closed(self):
237+
for field, value in (
238+
("generated_at", "2026-08-22T12:00:00Z"),
239+
("computed_at", "2026-08-22T12:00:00Z"),
240+
):
241+
with self.subTest(field=field):
242+
snapshot = self._snapshot(self._hypothesis())
243+
snapshot[field] = value
244+
if field == "generated_at":
245+
snapshot["computed_at"] = "2026-08-22T12:01:00Z"
246+
ledger = m0_research_ledger.aggregate_m0_research_sources(
247+
[snapshot], now="2026-08-21T12:00:00Z"
248+
)
249+
self.assertEqual(ledger["data_status"], "unavailable")
250+
self.assertEqual(ledger["summary"]["observation_count"], 0)
251+
self.assertEqual(ledger["errors"], ["m0_source_future_timestamp"])
252+
168253
def test_same_subject_and_source_with_different_payloads_is_omitted_fail_closed(self):
169254
first = self._hypothesis()
170255
collision = self._hypothesis(entry_digest="f" * 64, hypothesis_id="m0r-collision")

schemas/qsl-m0-research-ledger.v1.schema.json

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,15 @@
1414
"summary": {
1515
"type": "object",
1616
"additionalProperties": false,
17-
"required": ["subject_count", "observation_count", "fresh_observation_count", "stale_observation_count", "unknown_observation_count", "horizon_conflict_count"],
17+
"required": ["subject_count", "observation_count", "fresh_observation_count", "stale_observation_count", "unknown_observation_count", "horizon_conflict_count", "historical_stale_horizon_drift_count"],
1818
"properties": {
1919
"subject_count": { "type": "integer", "minimum": 0, "maximum": 50000 },
2020
"observation_count": { "type": "integer", "minimum": 0, "maximum": 50000 },
2121
"fresh_observation_count": { "type": "integer", "minimum": 0, "maximum": 50000 },
2222
"stale_observation_count": { "type": "integer", "minimum": 0, "maximum": 50000 },
2323
"unknown_observation_count": { "type": "integer", "minimum": 0, "maximum": 50000 },
24-
"horizon_conflict_count": { "type": "integer", "minimum": 0, "maximum": 50000 }
24+
"horizon_conflict_count": { "type": "integer", "minimum": 0, "maximum": 50000 },
25+
"historical_stale_horizon_drift_count": { "type": "integer", "minimum": 0, "maximum": 50000 }
2526
}
2627
},
2728
"subjects": {
@@ -30,15 +31,15 @@
3031
"items": {
3132
"type": "object",
3233
"additionalProperties": false,
33-
"required": ["subject", "observations", "horizon_conflict"],
34+
"required": ["subject", "observations", "horizon_conflict", "historical_stale_horizon_drift"],
3435
"properties": {
3536
"subject": {
3637
"type": "object",
3738
"additionalProperties": false,
3839
"required": ["kind", "identifier"],
3940
"properties": {
4041
"kind": { "enum": ["asset_idea", "theme_context", "strategy_hypothesis", "risk_context"] },
41-
"identifier": { "type": "string", "pattern": "^[A-Za-z0-9._:/=-]{1,128}$" }
42+
"identifier": { "type": "string", "pattern": "^[A-Za-z0-9._:/-]{1,128}$" }
4243
}
4344
},
4445
"observations": {
@@ -55,7 +56,22 @@
5556
"status": { "enum": ["none", "conflict"] },
5657
"primary_horizons": {
5758
"type": "array",
58-
"minItems": 1,
59+
"minItems": 0,
60+
"maxItems": 4,
61+
"uniqueItems": true,
62+
"items": { "enum": ["short", "medium", "long", "not_applicable"] }
63+
}
64+
}
65+
},
66+
"historical_stale_horizon_drift": {
67+
"type": "object",
68+
"additionalProperties": false,
69+
"required": ["status", "primary_horizons"],
70+
"properties": {
71+
"status": { "enum": ["none", "drift", "unavailable"] },
72+
"primary_horizons": {
73+
"type": "array",
74+
"minItems": 0,
5975
"maxItems": 4,
6076
"uniqueItems": true,
6177
"items": { "enum": ["short", "medium", "long", "not_applicable"] }
@@ -93,11 +109,11 @@
93109
"minItems": 1,
94110
"maxItems": 100,
95111
"uniqueItems": true,
96-
"items": { "type": "string", "pattern": "^[A-Za-z0-9._:/=-]{1,128}$" }
112+
"items": { "type": "string", "pattern": "^[A-Za-z0-9._:/-]{1,128}$" }
97113
},
98114
"source_report_digest": { "type": "string", "pattern": "^[0-9a-f]{64}$" },
99115
"source_entry_digest": { "type": "string", "pattern": "^[0-9a-f]{64}$" },
100-
"hypothesis_id": { "type": "string", "pattern": "^[A-Za-z0-9._:/=-]{1,128}$" },
116+
"hypothesis_id": { "type": "string", "pattern": "^[A-Za-z0-9._:/-]{1,128}$" },
101117
"as_of": { "type": "string", "format": "date" },
102118
"generated_at": { "type": "string", "format": "date-time", "maxLength": 64 },
103119
"expires_at": { "type": "string", "format": "date-time", "maxLength": 64 },
@@ -121,7 +137,7 @@
121137
"type": "array",
122138
"maxItems": 24,
123139
"uniqueItems": true,
124-
"items": { "type": "string", "pattern": "^[A-Za-z0-9._:/=-]{1,128}$" }
140+
"items": { "type": "string", "pattern": "^[A-Za-z0-9._:/-]{1,128}$" }
125141
}
126142
}
127143
},

0 commit comments

Comments
 (0)