mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 07:11:57 +00:00
91aa34d014
复核第 2 轮(ce71f7d)找到两处 major、两处 minor:转抄的识别逐个往上递归、追够八层(CARD_DEPTH)就按遮罩算——没设任何遮罩 的工作流里,循环里累加自己上一轮产出的口径卡在写作目录里指回自己,一直追到八层,写成「已遮罩」,正式运行判失败、报错说 「取自设置了遮罩的列」;每个指标的护栏 nodes.acc 选中整张卡的全部指标,递归次数约为(指标数 + 1)的八次方,四个指标四十秒、 卡住事件循环;封存之后循环超过八轮的,证据面板写「已遮罩」,可验证报告把文档整份只给哈希。紧挨着改字仍是黑名单:西里尔、 希腊的 I、O、M、K,汉字「丨」,换行、制表、各种宽度的空格后接「万」,「(万元)」,负号前面放一个别的文字系统里长得像撇号的 字母,都还是绿色的「全部通过」、提示也不显示。另有:* 展开派生表、WITH 里没起别名的 UPPER(phone) 认不出是确定取自遮罩列, 号码进了写作目录和文档;上游卡的节点产出读不出来时转抄当作认不出出处,号码随卡 2 的节点完成事件出去了。 转抄的遮罩在指标依赖图上一次认(recompute.masked_closure,写作目录、证据面板、推演、可验证报告共用) - 先按转抄以外的输入认每个指标(写作目录、可验证报告认老文档只按确定取自遮罩列的 sure;证据面板、推演宁可多遮),再沿转抄的 边(nodes.<卡>.metrics[i].value、vars.<assign_to>.metrics[i]、整张卡 / 清单文字 / 整份指标列表 / 下标不是字面量的连到那张卡 的全部指标)往下游传,直到不再变化。从「都照给」起往上收:环(循环里抄自己上一轮、几张卡互相转抄)本身不带来遮罩,环上有 一个指标别的输入取自遮罩格,整个环都不给。每个指标的结论只往严里变,工作量和边数成正比;删掉 CARD_DEPTH、CARD_TOO_DEEP 和逐层递归(_InputMasks._copied、_Store._copied_kind),可验证报告的缓存不再按 (卡, 指标) 混着深度记 - api/evidence.py 的 _CopyGraph(按请求记在 _Masks 上,键是 (指标集工件, 指标在卡上的位置)):写作目录只有最后一次的产出, 抄自己上一轮的卡是一个环;封存之后按每次执行认(_card_outputs,同一次执行只找一次),一次一次往回连到第一轮。bundle/build.py 的 _Store.metric_word 按同样的键各认法一张图 - 推演(api/whatif.py → copied_inputs)的转抄输入按证据面板同一张图认:转抄的是不给的指标就不给值、不能改(原因「转抄的口径卡 指标取自设置了遮罩的列」),核对不了的不给、不推演。以前只按值认,转抄来的三位数余数照给、还能改 - 上游卡当时的产出读不出来(文件不在、与哈希不一致)、卡上又记着这个输入出自口径卡、转抄来了值的,转抄核对不了(同按位置取的 一格取值链上的产出读不出来),可验证报告里卡 2 只给哈希;第一轮还没有上一轮(转抄来的是空值)的不算 - 可验证报告附带口径卡另看卡上记着的转抄值(_copies_masked):转抄的是不给、核对不了的指标,或者整张卡、清单文字转抄来的值里 写着号码(len(nodes.card.text) 的输入就是整段清单文字),卡只给 id。指标的值照给时以前照常附带 - 确定取自遮罩列(_sure)在整份算的快照里另按列名认(_named_from_masks):SQLite、MySQL 拿没起别名的表达式原文当列名,* 展开 派生表、WITH、UNION 一支时原样带出;按列名拼一条只选这一项的 SQL 交给同一套认,COUNT(phone)、FILTER、分档标签照旧不算。 单元格引用的自查、收遮罩值、裁判摘录跟着收紧 显示文字核对(verifier.js):紧挨着数字头尾的那一个字改成白名单(COMMON) - 名单:汉字(含「一」,去掉丨、丶、丿这类笔画字)、中文标点、半角和全角空格、ASCII 字母、常用半角标点和 = + / ~ * # < > 等 算式符号、× ÷ ≈ ≤ ≥、上一轮放行的破折号写法(——、—、–、―、﹘、ー、─、〜、〰);逗号、句点、连字符看上下文(句点在数字头 前面只认 NO. 这种字母缩写)。名单以外的(别的文字系统的字母、制表换行和各种宽度的空格、零宽字符、全角拉丁字母、表情)判 无法核对,原因「紧挨着这个数的字不在常见写法里,无法确认读者看到的数」,总览随之不是绿的、显示去系统里核对的提示 - 像数字的字母另收 o、竖线一类(|、|、丨、竖排破折号、制表符竖线、拉丁 / 西里尔 / 希腊里长得像 l、I 的)和圆圈一类(西里尔、 希腊的 O、空心圆);量级单位另收十、拾、佰、仟和当「万」用的 w、W,前面隔着的改成任何空白和左括号、左引号(「(万元)」) - 负号前面的「编号」例外只认 ASCII 字母数字;后面接 e 和数字(科学记数法)同紧挨着数字,判不通过 - 开发数据 39 份报告 1591 处、验收数据 58 份报告 1695 处引用按新规则扫过,没有一处判不通过或无法核对;prose、formats 样本原样 导出全绿,别的样本页总览和第 2 轮一字不差;复核者的 42 种伪造写法(forge.py、forge2.py)全部不是绿色、提示都显示 测试 - 新增 tests/test_report_masked_copy_closure.py:没设遮罩的循环自引用照常显示、正式运行成功(复核者的探针);循环三轮、十轮的 老文档照常附带;两张卡互相转抄照给;环上一个输入取自号码列整个环都不给、同循环里不相干的卡照给;上游产出读不出来三种转抄 写法都不漏号码;卡上记着转抄清单文字的卡不附带;推演按同一张图认转抄输入;* 展开里没起别名的号码表达式八种写法算确定取自 遮罩列、只数个数和分档标签的不算;纯函数的环、整张卡转抄、读不出来、两千个指标线性;写作目录和可验证报告在三张卡各 8 个 指标、循环十轮时各一秒以内(写作目录每个指标只认一次),另有真跑两张卡各 8 个指标十轮的计时 - test_report_masked_card_copies:认转抄补读不出来的几种;「追不到头按遮罩」改成环上认(没别的输入照给、有号码输入整个环不给) - test_bundle_shown:名单以外的字、像数字的字母、隔着各种空白和左括号的量级单位、别的文字系统字母后的负号、科学记数法各自判 无法核对或不通过、总览不是绿的;常见半角标点、算式符号照常通过;表情紧挨着数字改判无法核对(隔一个空格照常通过) - check-bundle 补长得像 I、M 的西里尔字母、汉字「丨」、数学粗体 l、隔着细空格的一位数、开尔文符号、隔着换行和 em 空格的「万」、 「(万元)」判无法核对,撇号字母后的负号、e3 判不通过,各自点名、总览不是绿的 - 每处改动都做了「去掉实现、测试变红」:整套退回第 2 轮(27 项红、写作目录计时十秒没认完、可验证报告长循环把文档只给哈希); 环按遮罩算、不沿边往下游传、读不出来当认不出出处、不看卡上的转抄值、不按列名认、推演不用依赖图、每个指标各建一张图;名单 不判、名单去掉等号、不去笔画字、像数字的字母不扩、量级单位前只隔空格、负号例外按文字区、科学记数法不算、单位不收十和 w、 句点不看前面是不是字母
410 lines
23 KiB
Python
410 lines
23 KiB
Python
"""数字推演:在一次运行的基础上改动输入,推演指标会怎样变化,不改原运行。
|
||
|
||
POST /api/runs/{run_id}/evidence/whatif:证据面板里点开口径卡上的一个数,改某个输入的值、或者排除它背后查询结果里的
|
||
几行,看这个指标和报告里引用它的地方会变成多少。推演永远不是出具:不追加任何事件(last_seq 不变)、不落工件、
|
||
不改报告,结果只交回给发请求的人。
|
||
|
||
GET /api/runs/{run_id}/evidence/whatif/rows:推演时勾行用,按页取封存范围内一份查询快照的行(遮罩列写「已遮罩」)。
|
||
证据面板的查询步骤只给被引用的行前后各 2 行,整份结果作输入(len(vars.res.rows) 这种)时每一行都可能要勾。
|
||
|
||
只认这次运行封存范围内的工件,和证据接口同一套白名单(api/evidence.py 的 _sealed / _sealed_loader):口径卡的指标集
|
||
从封存的 node.finished 台账找,排除行只认封存范围内交回过的查询快照,nodes.* 的完整值从封存的节点产出工件取,
|
||
vars.* 的完整值(value_artifact)只认封存的指标集里写着的。还在跑、停在审批的运行也能推演,结果里 sealed 为假:
|
||
数据可能还会变。遮罩列(查询当时记下的加数据源现在设的,SQL 里改了名字的也算)不给值、也不能拿来推演,用到它的指标
|
||
原值也不给,见 engine/recompute.py。
|
||
|
||
重算本身是纯函数(engine/recompute.py)。这里负责:认请求、找卡、准备工件和遮罩、找报告里引用的地方、标出可能受影响
|
||
的下游口径卡。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
from collections.abc import Iterator, Mapping
|
||
from dataclasses import replace
|
||
from typing import Any
|
||
|
||
from fastapi import APIRouter, Body
|
||
|
||
from app.api.coded import CodedHTTPException
|
||
from app.api.evidence import (
|
||
MASKED,
|
||
MISSING_QUERY,
|
||
REPORT_NOT_FOUND,
|
||
TAMPERED_QUERY,
|
||
_MEMO,
|
||
_Masks,
|
||
_MaskSets,
|
||
_reports,
|
||
_Sealed,
|
||
_sealed,
|
||
_sealed_cards,
|
||
_sealed_loader,
|
||
_snapshot_secrets,
|
||
copied_inputs,
|
||
)
|
||
from app.core.artifact_store import load
|
||
from app.engine import recompute
|
||
from app.engine.merge_query import MERGE_SOURCE
|
||
|
||
router = APIRouter(prefix="/api/runs", tags=["whatif"])
|
||
|
||
BAD_REQUEST = "whatif_bad_request"
|
||
BAD_INPUT = "whatif_bad_input"
|
||
CARD_NOT_FOUND = "whatif_card_not_found"
|
||
CARD_TAMPERED = "whatif_card_tampered"
|
||
METRIC_NOT_FOUND = "whatif_metric_not_found"
|
||
|
||
BAD_REQUEST_TEXT = ("请求格式有误:card 应为口径卡节点的 ID;report、metric 可省略,填写时为节点 ID 和指标 ID;"
|
||
"overrides.inputs 应为「输入路径 → 新值」,overrides.exclude_rows 应为「查询快照的工件 ID → 行号列表」")
|
||
#: 一次最多改几个输入、排除几份快照里的行、每份最多几行:再多就是请求写错了
|
||
MAX_INPUTS = 200
|
||
MAX_SNAPSHOTS = 50
|
||
MAX_ROWS = 10000
|
||
#: 勾行时一页给几行、最多几行
|
||
ROWS_PAGE = 50
|
||
MAX_ROWS_PAGE = 200
|
||
OUT_OF_SCOPE = "这份查询快照不属于这次运行,或不在封存范围内,不能用于推演"
|
||
|
||
|
||
@router.post("/{run_id}/evidence/whatif")
|
||
async def evidence_whatif(run_id: str, payload: Any = Body(None)) -> dict[str, Any]:
|
||
"""在一次运行的口径卡上推演:请求体 {report?, card, metric?, overrides: {inputs, exclude_rows}}。
|
||
|
||
返回 {sealed, seal, card, metrics, segments, unsupported, affected_cards}:每个指标的旧值、新值、变化量和输入,
|
||
报告里引用这些指标的地方会写成什么,不能用于推演的输入和原因,以及引用了这张卡、可能受影响(未重算)的下游口径卡。
|
||
"""
|
||
report, card_node, metric, changes = _request(payload)
|
||
sealed = await _sealed(run_id)
|
||
reports = _reports(sealed)
|
||
if report is not None and not any(r.node_id == report for r in reports):
|
||
raise CodedHTTPException(404, "本次运行中没有所选报告撰写节点的文档", REPORT_NOT_FOUND)
|
||
cards = _sealed_cards(sealed)
|
||
artifact, card, started = _card_of(sealed, cards, card_node, reports, report)
|
||
if metric is not None and not any(isinstance(m, dict) and str(m.get("id")) == metric
|
||
for m in card.get("metrics") or []):
|
||
raise CodedHTTPException(404, f"口径卡中没有指标「{metric}」", METRIC_NOT_FOUND)
|
||
# 转抄上游口径卡的输入给不给值:和证据面板同一张指标依赖图(copied_inputs → recompute.masked_closure),在事件循环里
|
||
# 认好交进去,重算的线程里不再查库
|
||
copied = await copied_inputs(sealed, card_node, artifact, card)
|
||
sources = _sources(sealed, cards, started, asyncio.get_running_loop(), copied=copied)
|
||
docs = [(r.node_id, r.doc) for r in reports
|
||
if r.doc is not None and r.hash_ok is True and (report is None or r.node_id == report)]
|
||
try:
|
||
# 重算连同读工件(快照、完整值、节点产出)都在线程里:快照大的时候不挡住同一进程里别的请求。
|
||
# 遮罩用到哪份快照才算哪份(_Hidden),查库那一步交回事件循环
|
||
result, segments = await asyncio.to_thread(_compute, card, changes, sources, metric, docs, card_node, artifact)
|
||
except recompute.WhatIfError as e:
|
||
raise CodedHTTPException(422, str(e), BAD_INPUT) from None
|
||
# 改动可能改变了某个指标——算出来变了,或者碰到了它却推演不了(还原不了、遮罩),新值不知道——下游才可能受影响。
|
||
# 不能只看 changed:整个推演不了的指标 changed 一律为空,什么都没改时也一样(recompute.whatif 的 maybe_changed)
|
||
maybe = result["maybe_changed"]
|
||
edges = [(e.get("source"), e.get("target")) for e in sealed.graph.get("edges") or [] if isinstance(e, dict)]
|
||
return {
|
||
"run_id": run_id,
|
||
"card": {"node_id": card_node, "artifact": artifact, "caliber": card.get("caliber") or "",
|
||
"version": card.get("caliber_version") or ""},
|
||
"report": report,
|
||
# 封存了、而且封存核对通过:推演依据的数据不会再变。没封存(还在跑、停在审批)或封存核对没过的照样能推演
|
||
"sealed": sealed.trusted,
|
||
"seal": {"sealed": sealed.sealed, "ok": sealed.seal["ok"]},
|
||
"metrics": result["metrics"],
|
||
"segments": segments,
|
||
"unsupported": result["unsupported"],
|
||
# 什么都没变,下游也不会变;顺着画布的边找,经中间节点用到卡上值的下游卡也标
|
||
"affected_cards": recompute.downstream_cards(cards, card_node, edges=edges) if maybe else [],
|
||
}
|
||
|
||
|
||
@router.get("/{run_id}/evidence/whatif/rows")
|
||
async def evidence_whatif_rows(run_id: str, artifact: str | None = None, offset: str | None = None,
|
||
limit: str | None = None) -> dict[str, Any]:
|
||
"""一页查询快照的行:{artifact, columns, rows, row_offset, row_index, total_rows, masked, hash_ok, sealed, note?}。
|
||
|
||
形状和证据面板的查询步骤一致(界面用同一个函数读)。只认这次运行封存范围内交回过的快照;遮罩列(查询当时记下的
|
||
加数据源现在设的,SQL 里改了名字的也算)写「已遮罩」;读不出来、与哈希不一致的不给行,note 照实说。只读:不追加
|
||
事件、不落工件。
|
||
"""
|
||
if not artifact or not artifact.strip():
|
||
raise CodedHTTPException(422, "请求格式有误:artifact 应为查询快照的工件 ID", BAD_REQUEST)
|
||
artifact = artifact.strip()
|
||
start, size = _count(offset, "offset", 0), min(_count(limit, "limit", ROWS_PAGE), MAX_ROWS_PAGE)
|
||
sealed = await _sealed(run_id)
|
||
info = sealed.queries.get(artifact)
|
||
if info is None:
|
||
raise CodedHTTPException(422, OUT_OF_SCOPE, BAD_INPUT)
|
||
try:
|
||
snap = await asyncio.to_thread(load, artifact)
|
||
hash_ok: bool | None = True if snap is not None else None
|
||
except ValueError:
|
||
snap, hash_ok = None, False
|
||
page: dict[str, Any] = {
|
||
"run_id": run_id, "artifact": artifact, "node_id": info.get("node_id"), "tool": info.get("tool"),
|
||
"source": info.get("source") or (snap.get("source") if isinstance(snap, dict) else None),
|
||
"columns": [], "rows": [], "row_offset": start, "row_index": [], "total_rows": None, "truncated": None,
|
||
"masked": [], "hash_ok": hash_ok, "sealed": sealed.trusted,
|
||
}
|
||
if not isinstance(snap, dict) or not isinstance(snap.get("rows"), list):
|
||
return {**page, "note": TAMPERED_QUERY if hash_ok is False else MISSING_QUERY}
|
||
hidden = await _mask_of(snap, info, _Masks())
|
||
columns = [str(c) for c in snap.get("columns") or []]
|
||
masked = {i for i, c in enumerate(columns) if c.lower() in hidden}
|
||
rows = snap["rows"]
|
||
picked = range(start, min(start + size, len(rows)))
|
||
return {**page, "columns": columns, "total_rows": len(rows), "truncated": bool(snap.get("truncated")),
|
||
"masked": [columns[i] for i in sorted(masked)],
|
||
"column_types": snap.get("column_types") if isinstance(snap.get("column_types"), dict) else {},
|
||
"row_index": list(picked),
|
||
"rows": [[MASKED if i in masked else v for i, v in enumerate(rows[r])] if isinstance(rows[r], list)
|
||
else rows[r] for r in picked]}
|
||
|
||
|
||
def _count(raw: str | None, name: str, default: int) -> int:
|
||
"""查询参数里的非负整数;没给用默认值。写错了 422,说清是哪一个。"""
|
||
if raw is None or not raw.strip():
|
||
return default
|
||
try:
|
||
value = int(raw)
|
||
except ValueError:
|
||
value = -1
|
||
if value < 0:
|
||
raise CodedHTTPException(422, f"请求格式有误:{name} 应为不小于 0 的整数", BAD_REQUEST)
|
||
return value
|
||
|
||
|
||
def _compute(card: dict[str, Any], changes: recompute.Changes, sources: recompute.Sources, metric: str | None,
|
||
docs: list[tuple[str, dict[str, Any]]], card_node: str, artifact: str
|
||
) -> tuple[dict[str, Any], list[dict[str, Any]]]:
|
||
result = recompute.whatif(card, changes, sources, metric=metric)
|
||
segments = recompute.report_segments(docs, card_node=card_node, card_artifact=artifact, results=result["metrics"])
|
||
return result, segments
|
||
|
||
|
||
def _request(payload: Any) -> tuple[str | None, str, str | None, recompute.Changes]:
|
||
"""(report, card, metric, 改动)。形状不对一律 422 whatif_bad_request;值对不对(路径在不在、行号越没越界)由重算时核对。"""
|
||
def bad() -> CodedHTTPException:
|
||
return CodedHTTPException(422, BAD_REQUEST_TEXT, BAD_REQUEST)
|
||
|
||
if not isinstance(payload, dict):
|
||
raise bad()
|
||
card, report, metric = payload.get("card"), payload.get("report"), payload.get("metric")
|
||
if not isinstance(card, str) or not card.strip():
|
||
raise bad()
|
||
if any(v is not None and (not isinstance(v, str) or not v.strip()) for v in (report, metric)):
|
||
raise bad()
|
||
overrides = payload.get("overrides")
|
||
overrides = {} if overrides is None else overrides
|
||
if not isinstance(overrides, dict):
|
||
raise bad()
|
||
inputs = {} if overrides.get("inputs") is None else overrides["inputs"]
|
||
exclude = {} if overrides.get("exclude_rows") is None else overrides["exclude_rows"]
|
||
if not isinstance(inputs, dict) or not isinstance(exclude, dict) \
|
||
or len(inputs) > MAX_INPUTS or len(exclude) > MAX_SNAPSHOTS:
|
||
raise bad()
|
||
rows: dict[str, frozenset[int]] = {}
|
||
for artifact, picked in exclude.items():
|
||
if not isinstance(picked, list) or len(picked) > MAX_ROWS \
|
||
or not all(isinstance(r, int) and not isinstance(r, bool) for r in picked):
|
||
raise bad()
|
||
if picked:
|
||
rows[str(artifact)] = frozenset(picked)
|
||
return (report.strip() if report else None, card.strip(), metric.strip() if metric else None,
|
||
recompute.Changes(inputs={str(k): v for k, v in inputs.items()}, exclude=rows))
|
||
|
||
|
||
def _card_of(sealed: _Sealed, cards: list[tuple[str, str, dict[str, Any]]], node_id: str, reports: list[Any],
|
||
report: str | None) -> tuple[str, dict[str, Any], int]:
|
||
"""要推演的那份指标集:(工件 id, 内容, 那次执行的 node.started 序号)。
|
||
|
||
哪些 node.finished 算这张卡的执行,和 _sealed_cards 一样认:台账里的 metric_set 条目;一期以前没有台账的,画布上确实
|
||
是口径卡的节点,产出本身就是指标集。同一张卡在循环里算过几次时,选报告引用的那一份(指定了 report 看那份报告,
|
||
没指定看任意一份),都没引用就取最后一次。内容只认 _sealed_cards 取回、复验过哈希的:执行记录里有、内容却对不上
|
||
哈希的说清是被改过,不说「没有这张卡」。
|
||
"""
|
||
calibers = set(sealed.nodes_of("metrics"))
|
||
runs: list[tuple[int, str]] = []
|
||
for seq, etype, nid, data in sealed.events:
|
||
if etype != "node.finished" or nid != node_id or not isinstance(data, dict):
|
||
continue
|
||
arts = [str(e["artifact"]) for e in data.get("evidence") or []
|
||
if isinstance(e, dict) and e.get("kind") == "metric_set" and e.get("artifact")]
|
||
if not arts and nid in calibers and data.get("artifact"):
|
||
arts = [str(data["artifact"])]
|
||
runs.extend((seq, art) for art in arts)
|
||
if not runs:
|
||
raise CodedHTTPException(404, "这次运行中没有这张口径卡的结果:节点不存在、不是口径卡、尚未执行或执行失败",
|
||
CARD_NOT_FOUND)
|
||
cited = {e.get("artifact") for r in reports if r.doc is not None and (report is None or r.node_id == report)
|
||
for e in (r.doc.get("catalog") or {}).values()
|
||
if isinstance(e, dict) and e.get("kind") == "metric" and e.get("node_id") == node_id}
|
||
seq, artifact = next(((s, a) for s, a in reversed(runs) if a in cited), runs[-1])
|
||
good = {art: card for nid, art, card in cards if nid == node_id}
|
||
if artifact not in good:
|
||
raise CodedHTTPException(409, "口径卡的指标集与哈希不一致或无法读取,疑似被修改,不能推演", CARD_TAMPERED)
|
||
started = max((s for s, etype, nid, _ in sealed.events if etype == "node.started" and nid == node_id and s < seq),
|
||
default=seq)
|
||
return artifact, good[artifact], started
|
||
|
||
|
||
def _sources(sealed: _Sealed, cards: list[tuple[str, str, dict[str, Any]]], started: int,
|
||
loop: asyncio.AbstractEventLoop, *, copied: Mapping[int, Mapping[str, Any]] | None = None
|
||
) -> recompute.Sources:
|
||
"""重算要用的东西(recompute.Sources):封存范围内的工件、口径卡那次执行之前的节点产出和跑完的时刻、快照交回的
|
||
时刻、用到哪份才算哪份的遮罩、转抄上游口径卡的输入给不给值(copied)。拿到的 Sources 要在工作线程里用(_Hidden
|
||
在线程里等事件循环查库)。"""
|
||
loader = _loader(sealed, cards)
|
||
hidden = _Hidden(sealed, loader, loop)
|
||
return recompute.Sources(load=loader, queries=tuple(sealed.queries), outputs=_outputs_before(sealed, started),
|
||
masked=hidden, returned=_returned(sealed), finished=_finished_before(sealed, started),
|
||
secrets=hidden.secrets, copied=dict(copied or {}))
|
||
|
||
|
||
def _outputs_before(sealed: _Sealed, started: int) -> dict[str, str]:
|
||
"""口径卡那次执行开始之前,每个节点最后一次的产出工件:口径卡当时读到的 nodes.* 就是它们。"""
|
||
out: dict[str, str] = {}
|
||
for seq, etype, node_id, data in sealed.events:
|
||
if seq >= started:
|
||
break
|
||
if etype == "node.finished" and node_id and isinstance(data, dict) and data.get("artifact"):
|
||
out[node_id] = str(data["artifact"])
|
||
return out
|
||
|
||
|
||
def _finished_before(sealed: _Sealed, started: int) -> dict[str, int]:
|
||
"""口径卡那次执行开始之前,每个节点最后一次跑完的事件序号:口径卡读到的 vars.*、nodes.* 是那一刻写下的。"""
|
||
out: dict[str, int] = {}
|
||
for seq, etype, node_id, _ in sealed.events:
|
||
if seq >= started:
|
||
break
|
||
if etype == "node.finished" and node_id:
|
||
out[node_id] = seq
|
||
return out
|
||
|
||
|
||
def _returned(sealed: _Sealed) -> dict[str, int]:
|
||
"""封存范围内每份查询快照第一次交回的事件序号:tool.end 的 query_artifact,或者 node.finished 台账里的 query
|
||
条目(合并查询只有后者)。排除这份快照里的行,只可能影响在这之后才算出来的值。"""
|
||
out: dict[str, int] = {}
|
||
for seq, etype, _, data in sealed.events:
|
||
if not isinstance(data, dict):
|
||
continue
|
||
if etype == "tool.end" and data.get("query_artifact"):
|
||
out.setdefault(str(data["query_artifact"]), seq)
|
||
elif etype == "node.finished":
|
||
for entry in data.get("evidence") or []:
|
||
if isinstance(entry, dict) and entry.get("kind") == "query" and entry.get("artifact"):
|
||
out.setdefault(str(entry["artifact"]), seq)
|
||
return out
|
||
|
||
|
||
def _loader(sealed: _Sealed, cards: list[tuple[str, str, dict[str, Any]]]) -> Any:
|
||
"""只认这次运行封存范围内的工件:证据接口的白名单(查询、表结构、检索快照和台账记过的),加上封存的节点产出工件,
|
||
以及封存的指标集里写着的完整值(value_artifact——写在内容寻址、又在封存范围内的指标集里,属于封存链的延伸)。
|
||
|
||
未封存、封存核对没过的运行也能推演(结果里 sealed 为假):白名单按同一套规则算,只是不要求封存完好。
|
||
取回时照旧复验哈希,对不上抛 ValueError(artifact_store.load 的约定),由重算照实说。
|
||
"""
|
||
base = _sealed_loader(replace(sealed, seal={**sealed.seal, "sealed": True, "ok": True}))
|
||
extra = {str(data["artifact"]) for _, etype, _, data in sealed.events
|
||
if etype == "node.finished" and isinstance(data, dict) and data.get("artifact")}
|
||
extra |= {str(item["value_artifact"]) for _, _, card in cards
|
||
for metric in card.get("metrics") or [] if isinstance(metric, dict)
|
||
for item in metric.get("inputs") or [] if isinstance(item, dict) and item.get("value_artifact")}
|
||
memo: dict[str, Any] = {}
|
||
|
||
def loader(artifact: str) -> Any:
|
||
key = str(artifact)
|
||
if key not in memo:
|
||
memo[key] = load(key) if key in extra else base(key)
|
||
return memo[key]
|
||
return loader
|
||
|
||
|
||
class _Hidden(Mapping[str, frozenset[str]]):
|
||
"""封存范围内的查询快照 → 要遮的列(小写),推演用到哪份才算哪份。
|
||
|
||
遮的列和证据面板查询步骤同一套(api/evidence.py 的 _mask_sets):查询当时记下的加数据源现在设的,按 SQL 认出
|
||
改了名字的也算,合并查询按它合并的那几个数据源、逐格来历和合并 SQL 算。快照读不出来、被改过时照样按台账记的数据源
|
||
算:遮罩不能因为快照坏了就失效(那一格的值另由重算不给)。
|
||
|
||
在重算的工作线程里取用:快照经推演用的同一个 loader 读(重算本来也要读这一份,读过的不再读盘);查数据源现在
|
||
设的遮罩要访问库,交回事件循环去查(_Masks 是异步的),线程在这里等它。以前每次请求在事件循环里把这次运行的
|
||
全部快照读一遍、复验一遍哈希,不管卡上用没用到。
|
||
|
||
secrets 同样在工作线程里收:这次运行查到过的遮罩值(和证据面板的 _Masks.run_secrets 同一个口径、同一份进程内记录
|
||
_MEMO),重算里认不出出处的输入、整份结果当输入的指标要按值认时才收,一次请求只收一次;证据面板、之前的推演收过的
|
||
快照不再读。分析结果同样按快照工件记在进程里。
|
||
"""
|
||
|
||
def __init__(self, sealed: _Sealed, loader: Any, loop: asyncio.AbstractEventLoop) -> None:
|
||
self._sealed, self._loader, self._loop = sealed, loader, loop
|
||
self._masks = _Masks()
|
||
self._memo: dict[str, _MaskSets] = {}
|
||
self._secrets: Any = None
|
||
self._collected = False
|
||
|
||
def __getitem__(self, artifact: str) -> frozenset[str]:
|
||
return frozenset(self._sets(artifact)[1].hidden)
|
||
|
||
def _sets(self, artifact: str) -> tuple[Any, _MaskSets]:
|
||
"""(读出来的快照,读不出来为 None;它的遮罩)。"""
|
||
key = str(artifact)
|
||
if key not in self._sealed.queries:
|
||
raise KeyError(key)
|
||
try:
|
||
snap = self._loader(key)
|
||
except ValueError:
|
||
snap = None
|
||
snap = snap if isinstance(snap, dict) else None
|
||
if key not in self._memo:
|
||
found = _mask_sets_of(snap, self._sealed.queries[key], self._masks, key)
|
||
self._memo[key] = self._wait(found)
|
||
return snap, self._memo[key]
|
||
|
||
def secrets(self) -> Any:
|
||
"""这次运行查到过的遮罩值(masking._Secrets,没有为 None):数据源现在设了遮罩、或者已经不在了的查询快照里确定
|
||
取自遮罩列的值,合并查询不收(api/evidence.py 的 _snapshot_secrets)。每份快照收到的值先查进程内记录(_MEMO),
|
||
记过的不再读。"""
|
||
if not self._collected:
|
||
self._collected = True
|
||
parts = []
|
||
for artifact, _source, key in self._wait(self._masks.value_keys(self._sealed.queries)):
|
||
known, got = _MEMO.values(key)
|
||
if not known:
|
||
snap, sets = self._sets(artifact)
|
||
if snap is None or snap.get("source") == MERGE_SOURCE:
|
||
continue
|
||
got = _MEMO.keep_values(key, _snapshot_secrets(snap, sets))
|
||
parts.append((key, got))
|
||
self._secrets = _MEMO.pooled(parts)
|
||
return self._secrets
|
||
|
||
def _wait(self, coro: Any) -> Any:
|
||
"""在工作线程里等事件循环跑完 coro(查库)。在事件循环里同步等会卡死,直接报错。"""
|
||
try:
|
||
running = asyncio.get_running_loop()
|
||
except RuntimeError:
|
||
running = None
|
||
if running is self._loop:
|
||
coro.close()
|
||
raise RuntimeError("遮罩只能在工作线程里取:在事件循环里同步等它查库会卡死")
|
||
return asyncio.run_coroutine_threadsafe(coro, self._loop).result()
|
||
|
||
def __iter__(self) -> Iterator[str]:
|
||
return iter(self._sealed.queries)
|
||
|
||
def __len__(self) -> int:
|
||
return len(self._sealed.queries)
|
||
|
||
|
||
async def _mask_sets_of(snap: dict[str, Any] | None, info: dict[str, Any], masks: _Masks,
|
||
artifact: str | None = None) -> _MaskSets:
|
||
"""一份查询快照的遮罩,和证据面板的查询步骤同一个函数(api/evidence.py 的 _mask_sets):查询当时记下的加数据源现在
|
||
设的,SQL 里给遮罩列起了别名、套了函数的结果列也算。snap 为 None(读不出来)时只能按台账记的数据源现在设的遮罩算。
|
||
artifact 是 snap 的工件 id:给了的,分析结果按快照记在进程里,之后的推演、证据面板不再分析。"""
|
||
return await masks.sets(artifact, info.get("source"), snap)
|
||
|
||
|
||
async def _mask_of(snap: dict[str, Any] | None, info: dict[str, Any], masks: _Masks) -> set[str]:
|
||
"""一份查询快照要遮的列(小写,_mask_sets_of 的 hidden)。运行对比(api/compare.py)按这个名字调用。"""
|
||
return (await _mask_sets_of(snap, info, masks)).hidden
|