Skip to content

Commit c494c27

Browse files
authored
Merge pull request #70 from QuantStrategyLab/codex/quant-advisor-manifest-v2-20260904
Fail closed on untrusted AI signal provenance
2 parents 108cc8f + 758b7e6 commit c494c27

6 files changed

Lines changed: 396 additions & 32 deletions

File tree

src/quant_advisor_research/advisory_report.py

Lines changed: 119 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,11 @@
22

33
import argparse
44
import datetime as dt
5+
import hashlib
56
import json
67
import os
8+
import re
9+
import subprocess
710
from collections import defaultdict
811
from collections.abc import Mapping
912
from dataclasses import dataclass
@@ -96,6 +99,13 @@
9699
AI_POLICY_ALLOWED_KEYS = frozenset({"execution_allowed", "portfolio_allocation_allowed", "downstream_use"})
97100
AI_POLICY_FORBIDDEN_TERMS = frozenset({"live", "allocation", "broker", "execution", "order", "position", "account"})
98101
AI_POLICY_BLOCKING_TERMS = frozenset({"blocked", "not allowed", "do not", "never", "no "})
102+
AI_SIGNAL_MANIFEST_NAME = "latest_signal.manifest.json"
103+
AI_SIGNAL_MANIFEST_PATH = "data/output/latest_signal.manifest.json"
104+
AI_SIGNAL_PATH = "data/output/latest_signal.json"
105+
AI_SIGNAL_PRODUCER_REPOSITORY = "QuantStrategyLab/ResearchSignalContextPipelines"
106+
AI_SIGNAL_PROVENANCE_WARNING = "ai_signal_provenance_untrusted"
107+
SHA256_PATTERN = re.compile(r"[0-9a-f]{64}")
108+
GIT_SHA_PATTERN = re.compile(r"[0-9a-f]{40}")
99109
_UNAVAILABLE_INPUT = object()
100110

101111
HORIZON_WINDOWS = {
@@ -501,6 +511,114 @@ def load_ai_signal(path: str | Path | None, *, source_bytes: bytes | None = None
501511
return payload
502512

503513

514+
def _ai_provenance_untrusted() -> None:
515+
raise AISignalValidationError(AI_SIGNAL_PROVENANCE_WARNING)
516+
517+
518+
def _git_output(repo: Path, *args: str) -> bytes:
519+
result = subprocess.run(
520+
["git", "-C", str(repo), *args],
521+
check=False,
522+
stdout=subprocess.PIPE,
523+
stderr=subprocess.DEVNULL,
524+
)
525+
if result.returncode != 0:
526+
_ai_provenance_untrusted()
527+
return result.stdout
528+
529+
530+
def _repository_from_remote(remote: str) -> str:
531+
value = remote.strip().removesuffix("/").removesuffix(".git")
532+
for prefix in ("https://github.com/", "git@github.com:", "ssh://git@github.com/"):
533+
if value.startswith(prefix):
534+
return value.removeprefix(prefix)
535+
return ""
536+
537+
538+
def load_trusted_ai_signal(
539+
path: str | Path | None,
540+
*,
541+
source_bytes: bytes | None = None,
542+
) -> dict[str, Any] | None:
543+
if path is None:
544+
return None
545+
signal_path = Path(path).resolve()
546+
signal_bytes = source_bytes
547+
if signal_bytes is None:
548+
try:
549+
signal_bytes = signal_path.read_bytes()
550+
except OSError:
551+
raise AISignalValidationError("ai_signal_unavailable") from None
552+
payload = load_ai_signal(signal_path, source_bytes=signal_bytes)
553+
if payload is None:
554+
return None
555+
manifest_path = signal_path.with_name(AI_SIGNAL_MANIFEST_NAME)
556+
try:
557+
manifest_bytes = manifest_path.read_bytes()
558+
manifest = json.loads(manifest_bytes.decode("utf-8"))
559+
except (OSError, UnicodeError, json.JSONDecodeError):
560+
_ai_provenance_untrusted()
561+
if not isinstance(manifest, Mapping):
562+
_ai_provenance_untrusted()
563+
required_keys = {
564+
"manifest_type",
565+
"schema_version",
566+
"artifact",
567+
"as_of",
568+
"generated_at",
569+
"expires_at",
570+
"mode",
571+
"producer",
572+
"input_digest",
573+
"policy",
574+
}
575+
artifact = manifest.get("artifact")
576+
producer = manifest.get("producer")
577+
policy = manifest.get("policy")
578+
if (
579+
set(manifest) != required_keys
580+
or manifest.get("manifest_type") != "research_signal_context"
581+
or manifest.get("schema_version") != 2
582+
or not isinstance(artifact, Mapping)
583+
or set(artifact) != {"path", "sha256"}
584+
or artifact.get("path") != AI_SIGNAL_PATH
585+
or not isinstance(artifact.get("sha256"), str)
586+
or SHA256_PATTERN.fullmatch(artifact["sha256"]) is None
587+
or artifact["sha256"] != hashlib.sha256(signal_bytes).hexdigest()
588+
or not isinstance(producer, Mapping)
589+
or set(producer) != {"repository", "commit_sha"}
590+
or producer.get("repository") != AI_SIGNAL_PRODUCER_REPOSITORY
591+
or not isinstance(producer.get("commit_sha"), str)
592+
or GIT_SHA_PATTERN.fullmatch(producer["commit_sha"]) is None
593+
or not isinstance(manifest.get("input_digest"), str)
594+
or re.fullmatch(r"sha256:[0-9a-f]{64}", manifest["input_digest"]) is None
595+
or not isinstance(policy, Mapping)
596+
or set(policy) != {"execution_allowed"}
597+
or policy.get("execution_allowed") is not False
598+
or any(manifest.get(key) != payload.get(key) for key in ("as_of", "generated_at", "expires_at", "mode"))
599+
):
600+
_ai_provenance_untrusted()
601+
602+
try:
603+
repo = Path(_git_output(signal_path.parent, "rev-parse", "--show-toplevel").decode("utf-8").strip()).resolve()
604+
signal_relative = signal_path.relative_to(repo).as_posix()
605+
manifest_relative = manifest_path.resolve().relative_to(repo).as_posix()
606+
head = _git_output(repo, "rev-parse", "HEAD").decode("ascii").strip()
607+
remote = _git_output(repo, "remote", "get-url", "origin").decode("utf-8").strip()
608+
except (UnicodeError, ValueError):
609+
_ai_provenance_untrusted()
610+
if (
611+
signal_relative != AI_SIGNAL_PATH
612+
or manifest_relative != AI_SIGNAL_MANIFEST_PATH
613+
or GIT_SHA_PATTERN.fullmatch(head) is None
614+
or _repository_from_remote(remote) != AI_SIGNAL_PRODUCER_REPOSITORY
615+
or _git_output(repo, "show", f"{head}:{signal_relative}") != signal_bytes
616+
or _git_output(repo, "show", f"{head}:{manifest_relative}") != manifest_bytes
617+
):
618+
_ai_provenance_untrusted()
619+
return payload
620+
621+
504622

505623
def load_theme_momentum(path: str | Path | None, *, source_bytes: bytes | None = None) -> dict[str, Any] | None:
506624
if path is None:
@@ -1721,7 +1839,7 @@ def build_advisory_report(
17211839
ai_quality_warnings.append("ai_signal_unavailable")
17221840
else:
17231841
try:
1724-
candidate_ai_signal = load_ai_signal(
1842+
candidate_ai_signal = load_trusted_ai_signal(
17251843
ai_signal_path,
17261844
source_bytes=ai_bytes if isinstance(ai_bytes, bytes) else None,
17271845
)

src/quant_advisor_research/build_pipeline.py

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,17 @@
1111
from urllib.parse import quote
1212
from urllib.request import urlopen
1313

14-
from .advisory_report import build_advisory_report, render_markdown, write_json, write_text
14+
from .advisory_report import (
15+
AISignalValidationError,
16+
build_advisory_report,
17+
load_trusted_ai_signal,
18+
render_markdown,
19+
write_json,
20+
write_text,
21+
)
1522
from .artifacts import write_report_manifest
1623
from .market_confirmation import (
24+
EXCLUDED_SYMBOLS,
1725
build_market_confirmation_rows,
1826
collect_symbols,
1927
load_proxy_urls,
@@ -119,12 +127,29 @@ def build_market_confirmation_artifact(
119127
cache_max_age_days: int,
120128
) -> Path:
121129
theme_momentum = load_theme_momentum(theme_momentum_path) if theme_momentum_path else None
122-
symbols = collect_symbols(
123-
political_watchlist_path=political_watchlist_path,
124-
ai_signal_path=ai_signal_path,
125-
theme_momentum=theme_momentum,
126-
max_symbols=max_symbols,
130+
ai_signal = None
131+
if ai_signal_path:
132+
try:
133+
ai_signal = load_trusted_ai_signal(ai_signal_path)
134+
except AISignalValidationError:
135+
pass
136+
symbols = set(
137+
collect_symbols(
138+
political_watchlist_path=political_watchlist_path,
139+
ai_signal_path=None,
140+
theme_momentum=theme_momentum,
141+
max_symbols=2**31 - 1,
142+
)
127143
)
144+
if ai_signal:
145+
for symbol in ai_signal.get("universe", []):
146+
if isinstance(symbol, str) and symbol.strip():
147+
symbols.add(symbol.upper())
148+
for key in ("candidate_bias", "research_bias", "symbol_bias", "symbol_theme_exposure"):
149+
value = ai_signal.get(key)
150+
if isinstance(value, dict):
151+
symbols.update(str(symbol).upper() for symbol in value if str(symbol).strip())
152+
symbols = sorted(symbols - EXCLUDED_SYMBOLS)[:max_symbols]
128153
proxy_urls = load_proxy_urls(
129154
proxy_list_path=proxy_list,
130155
proxy_urls_text=proxy_urls_text,

tests/test_advisory_report.py

Lines changed: 61 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
from __future__ import annotations
22

33
import datetime as dt
4+
import hashlib
45
import json
6+
import subprocess
57
from pathlib import Path
68

79
import pytest
@@ -25,6 +27,55 @@
2527
ROOT = Path(__file__).resolve().parents[1]
2628

2729

30+
def write_trusted_example_ai_signal(tmp_path: Path) -> Path:
31+
repo = tmp_path / "ResearchSignalContextPipelines"
32+
signal = repo / "data/output/latest_signal.json"
33+
signal.parent.mkdir(parents=True)
34+
signal.write_bytes((ROOT / "examples/research_signal_context.example.json").read_bytes())
35+
payload = json.loads(signal.read_text(encoding="utf-8"))
36+
manifest = {
37+
"manifest_type": "research_signal_context",
38+
"schema_version": 2,
39+
"artifact": {
40+
"path": "data/output/latest_signal.json",
41+
"sha256": hashlib.sha256(signal.read_bytes()).hexdigest(),
42+
},
43+
"as_of": payload["as_of"],
44+
"generated_at": payload["generated_at"],
45+
"expires_at": payload["expires_at"],
46+
"mode": payload["mode"],
47+
"producer": {
48+
"repository": "QuantStrategyLab/ResearchSignalContextPipelines",
49+
"commit_sha": "a" * 40,
50+
},
51+
"input_digest": f"sha256:{'b' * 64}",
52+
"policy": {"execution_allowed": False},
53+
}
54+
signal.with_name("latest_signal.manifest.json").write_text(json.dumps(manifest) + "\n", encoding="utf-8")
55+
subprocess.run(["git", "init", "-q", repo], check=True)
56+
subprocess.run(
57+
["git", "-C", repo, "remote", "add", "origin", "https://github.com/QuantStrategyLab/ResearchSignalContextPipelines.git"],
58+
check=True,
59+
)
60+
subprocess.run(["git", "-C", repo, "add", "data/output"], check=True)
61+
subprocess.run(
62+
[
63+
"git",
64+
"-C",
65+
repo,
66+
"-c",
67+
"user.name=Test",
68+
"-c",
69+
"user.email=test@example.invalid",
70+
"commit",
71+
"-qm",
72+
"trusted fixture",
73+
],
74+
check=True,
75+
)
76+
return signal
77+
78+
2879
def test_build_advisory_report_blocks_execution_and_allocation() -> None:
2980
report = build_advisory_report(
3081
as_of="2026-05-30",
@@ -61,13 +112,13 @@ def test_low_confidence_events_remain_verify_source_until_verified() -> None:
61112
assert by_symbol["EVT1"]["evidence_score"] > by_symbol["EVT4"]["evidence_score"]
62113

63114

64-
def test_ai_avoid_bias_defers_research_item() -> None:
115+
def test_ai_avoid_bias_defers_research_item(tmp_path: Path) -> None:
65116
report = build_advisory_report(
66117
as_of="2026-05-30",
67118
cadence="monthly",
68119
political_events_path=ROOT / "examples/political_events.example.csv",
69120
political_watchlist_path=ROOT / "examples/political_watchlist.example.csv",
70-
ai_signal_path=ROOT / "examples/research_signal_context.example.json",
121+
ai_signal_path=write_trusted_example_ai_signal(tmp_path),
71122
)
72123

73124
by_symbol = {item["symbol"]: item for item in report["recommendations"]}
@@ -138,13 +189,13 @@ def test_mixed_confidence_recommendation_is_not_tier_one(tmp_path: Path) -> None
138189
assert rec["recommendation_tier"] == "tier_2"
139190

140191

141-
def test_long_horizon_window_is_measured_in_years() -> None:
192+
def test_long_horizon_window_is_measured_in_years(tmp_path: Path) -> None:
142193
report = build_advisory_report(
143194
as_of="2026-05-30",
144195
cadence="weekly",
145196
political_events_path=ROOT / "examples/political_events.example.csv",
146197
political_watchlist_path=ROOT / "examples/political_watchlist.example.csv",
147-
ai_signal_path=ROOT / "examples/research_signal_context.example.json",
198+
ai_signal_path=write_trusted_example_ai_signal(tmp_path),
148199
)
149200

150201
by_symbol = {item["symbol"]: item for item in report["recommendations"]}
@@ -280,7 +331,7 @@ def test_report_manifest_records_contract_version_and_hashes(tmp_path: Path) ->
280331
assert manifest["artifacts"]["markdown"]["sha256"]
281332

282333

283-
def test_theme_bias_can_lift_static_watchlist_item_without_direct_symbol_bias(tmp_path: Path) -> None:
334+
def test_legacy_v1_theme_bias_is_untrusted_no_op(tmp_path: Path) -> None:
284335
events_path = tmp_path / "events.csv"
285336
events_path.write_text(
286337
"event_id,event_date,symbol,event_type,direction,confidence,source_url,notes\n",
@@ -339,11 +390,12 @@ def test_theme_bias_can_lift_static_watchlist_item_without_direct_symbol_bias(tm
339390

340391
rec = report["recommendations"][0]
341392
assert rec["symbol"] == "MU"
342-
assert rec["rating"] == "watch"
343-
assert rec["evidence_score"] > 4
344-
assert any("主题=hbm_memory" in reason for reason in rec["reasons"])
393+
assert rec["rating"] == "monitor"
394+
assert rec["evidence_score"] == 4
395+
assert rec["ai_context"]["source"] == ""
396+
assert report["summary"]["data_quality_warnings"] == ["ai_signal_provenance_untrusted"]
345397
assert report["summary"]["long_context_available"] is False
346-
assert report["summary"]["long_context_missing_reason"] == "current_candidates_do_not_meet_long_context_gate"
398+
assert report["summary"]["long_context_missing_reason"] == "ai_signal_not_available"
347399
assert "MU" not in report["summary"]["long_context_symbols"]
348400
assert report["final_decisions"]["horizon_action_buckets"]["long"]["watch"] == []
349401

0 commit comments

Comments
 (0)