diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-research-analysis.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-research-analysis.tsx index 425cfde938..ecf4f70d5a 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-research-analysis.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-research-analysis.tsx @@ -219,11 +219,12 @@ export function ResearchAnalysisPanel({analysis: a}: {analysis: Controller}) { const label = metricCopy[row.metric]; return
{label?.[zh ? 0 : 1] ?? row.metric}{verdictLabel(row.metric, row.verdict, zh)}

{label?.[zh ? 2 : 3]}

{row.reason}

{citationButtons(row.citations)}
; })})} - {a.citationBusy ?

{zh ? "正在读取引用原文…" : "Reading cited source…"}

: null} - {a.citationError ?

{a.citationError}

: null} - {a.citation ?
{if (event.key === "Escape") {event.stopPropagation(); a.closeCitation();}}}>

{zh ? "引用原文" : "Cited original"}

{a.citation.artifact} · {zh ? "行" : "lines"} {a.citation.first_line}–{a.citation.last_line}

{a.citation.text}
: null}
{zh ? "来源、方法、覆盖与分析成本" : "Sources, method, coverage and analysis cost"}

{p.objective}

{zh ? "来源范围" : "Source scope"}
{p.scope.case_id} · {p.scope.run_id}
{p.scope.window}
{zh ? "方法" : "Method"}
{result.method_version} · {zh ? "模型判断未校准" : "Uncalibrated model judgment"}
{zh ? "模型设置" : "Requested model"}
{p.config.model} / {p.config.effort} / {p.config.service_tier} · {zh ? "实际服务档位" : "Served tier"}: {p.job?.served_service_tier ?? (zh ? "未知" : "unknown")}
{zh ? "分析用量" : "Analysis usage"}
{p.job?.analysis_wall_seconds == null ? "—" : `${p.job.analysis_wall_seconds.toFixed(1)}s`} · {zh ? "输入" : "input"} {p.job?.usage?.input_tokens?.toLocaleString() ?? "—"} / {zh ? "输出" : "output"} {p.job?.usage?.output_tokens?.toLocaleString() ?? "—"} tokens · {zh ? "金额未采集,推理 token 已包含于输出" : "Dollar cost unmeasured; reasoning tokens are included in output"}
{zh ? "覆盖" : "Coverage"}
{p.episode_count} {zh ? "个固定片段" : "fixed episodes"} · {p.source_count} {zh ? "份来源" : "sources"} · {zh ? "总体片段数" : "Population"}: {p.selection.population_episodes ?? (zh ? "未知" : "unknown")}
{zh ? "取样规则" : "Selection"}
{p.selection.rule}
{zh ? "版本" : "Revision"}
{p.scope.revision}

{zh ? "引用行存在不等于解释正确;本页不提供独立科学校准结论。" : "A valid citation range does not establish entailment or independent scientific calibration."}

: null} + {/* Bound native evidence is readable before any model assessment exists. */} + {a.citationBusy ?

{zh ? "正在读取引用原文…" : "Reading cited source…"}

: null} + {a.citationError ?

{a.citationError}

: null} + {a.citation ?
{if (event.key === "Escape") {event.stopPropagation(); a.closeCitation();}}}>

{zh ? "引用原文" : "Cited original"}

{a.citation.artifact} · {zh ? "行" : "lines"} {a.citation.first_line}–{a.citation.last_line}

{a.citation.text}
: null} : null} ; } diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-research-progress.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-research-progress.tsx index 69d6f38306..d4179411a3 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-research-progress.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-research-progress.tsx @@ -1,13 +1,17 @@ import {ArrowUpRight, ChevronDown} from "lucide-react"; import {TRAJECTORY_METRICS} from "../../../../../../loopx/control_plane/capabilities/trajectory_assessment_types.js"; import type {AssessmentCitation, AssessmentVerdict, TrajectoryAssessment, TrajectoryEpisode, TrajectoryInput, TrajectoryMetric, TrajectorySolverUsage} from "../../../../../../loopx/control_plane/capabilities/trajectory_assessment_types.js"; +import type {TrajectoryNativeOutcomes} from "../../../../../../loopx/control_plane/capabilities/trajectory_native_outcomes.js"; import "./goal-research-progress.css"; // Read models from benchmark-toolkit. This view formats existing counts and // assessments; it never scores, reclassifies an episode or combines runs. export type ResearchProgressPack = {pack_id: string; title: string; scope: TrajectoryInput["scope"]; selection: TrajectoryInput["selection"]; episode_count: number; episodes?: TrajectoryEpisode[]; result?: TrajectoryAssessment | null; applicability?: "current" | "historical"; - solver_usage?: {artifact: string; artifact_sha256: string; projection: TrajectorySolverUsage; applicability: "current" | "historical"; reason_code: string | null}}; + job?: {status: string; attempt?: number; analysis_wall_seconds?: number | null; + usage?: {input_tokens?: number; output_tokens?: number} | null; config?: {model: string; effort: string; service_tier: string}} | null; + solver_usage?: {artifact: string; artifact_sha256: string; projection: TrajectorySolverUsage; applicability: "current" | "historical"; reason_code: string | null}; + native_outcomes?: {artifact: string; artifact_sha256: string; projection: TrajectoryNativeOutcomes; applicability: "current" | "historical"; reason_code: string | null}}; export const metricCopy: Record = { goal_alignment: ["目标关联", "Goal alignment", "干预是否关联原目标的真实瓶颈", "Does the intervention address a bottleneck of the original objective?"], benefit_test: ["收益检验", "Benefit test", "是否真正检验原目标收益,而非只完成前置诊断", "Was original-objective utility tested, beyond diagnostic prerequisites?"], @@ -25,6 +29,7 @@ export function verdictLabel(metric: TrajectoryMetric, verdict: AssessmentVerdic export function ResearchProgress({pack: p, packs, selectedId, error, zh, onSelect, onCitation}: {pack: ResearchProgressPack | null; packs: Pick[]; selectedId: string; error: string; zh: boolean; onSelect: (id: string) => void; onCitation: (citation: AssessmentCitation, origin?: HTMLButtonElement) => void}) { const measured = p?.solver_usage, usage = measured?.projection, result = p?.result; + const native = p?.native_outcomes, outcomes = native?.projection; const number = (n: number) => new Intl.NumberFormat(zh ? "zh-CN" : "en", {maximumFractionDigits: 1}).format(n); const percent = (n: number) => new Intl.NumberFormat(zh ? "zh-CN" : "en", {style: "percent", maximumFractionDigits: 0}).format(n); const duration = (s: number) => `${number(s / 60)} ${zh ? "分钟" : "min"}`; @@ -67,10 +72,28 @@ export function ResearchProgress({pack: p, packs, selectedId, error, zh, onSelec
{zh ? "题目 / 运行" : "Case / run"}
{p.scope.case_id}
{p.scope.run_id}
{zh ? "证据窗口" : "Evidence window"}
{p.scope.window}
{zh ? "取样规则" : "Selection rule"}
{p.selection.rule}
{zh ? "总体研究片段数" : "Population of episodes"}
{p.selection.population_episodes == null ? (zh ? "未知" : "Unknown") : number(p.selection.population_episodes)}
{result ? <>
{zh ? "分析方法" : "Analysis method"}
{result.method_version} · {zh ? "Agent 判读未校准" : "Uncalibrated Agent judgment"}
{zh ? "结果标识" : "Result key"}
{result.analysis_key}
{zh ? "分析限制" : "Limitations"}
{result.limitations.map((line, i) =>

{line}

)}
: null} -
{zh ? "来源版本" : "Source revision"}
{p.scope.revision}
{zh ? "原生收益" : "Native utility"}
{zh ? "此包尚未接入原生起点、保留最佳、最后有效值、达标时间/成本或增益面积投影。不能从模型判读或成本补算。" : "This pack has no native incumbent, retained-best, final-valid, threshold time/cost or gain-area projection. Model judgments and usage cannot supply them."}
+
{zh ? "来源版本" : "Source revision"}
{p.scope.revision}
+
{zh ? "原生结果" : "Native outcomes"}
{outcomes ? <> +

{outcomes.metric.name} · {outcomes.metric.unit} · {outcomes.metric.higher_is_better ? (zh ? "越高越好" : "higher is better") : (zh ? "越低越好" : "lower is better")}

+

{outcomes.start} — {outcomes.end} · {outcomes.complete ? (zh ? "声明为完整前缀" : "Declared complete prefix") : (zh ? "部分窗口,起点未知" : "Partial window; initial unknown")} · {outcomes.valid_count} / {outcomes.sample_count} {zh ? "项有效结果" : "valid results"}

+
{([ ["initial", "首次提交", "Initial submission"], ["best_valid", "窗口内最佳有效值", "Best valid in capture"], ["retained", "原生保留结果", "Native retained result"], ["final_valid", "最后有效提交", "Final valid submission"] ] as const).map(([key, cn, en]) => { + const point = outcomes[key]; + return
{zh ? cn : en}
{point ? <>{String(point.value)} · {point.candidate_ref} : (zh ? "— 未知,不记零" : "— Unknown, not zero")}
; + })}
+ {native?.applicability === "historical" ?

{zh ? "原生来源已变化或不可读;仅保留历史捕获,当前结果未核验。" : "Native source changed or is unavailable; retained historical capture, current results unverified."}

: null} +

{zh ? "原生测量与分析器判断分开。有效性和保留选择来自提供方,不认证研究因果收益;用量窗口独立,不补算达标成本或全程账单。" : "Native measurements are separate from analyzer judgments. Validity and retention are provider-recorded, not causal benefit. Usage has its own window; threshold and total-study costs are not inferred."}

+ : (zh ? "— 尚无同范围的原生结果捕获;不能从分析器判断或用量补算。" : "— No native capture for this scope; analyzer judgments and usage cannot supply it.")}
{zh ? "用量与成本" : "Usage and cost"}{usage ? ` · ${zh ? "捕获跨度" : "Captured elapsed"} ${duration(usage.elapsed_seconds)}` : ` · ${zh ? "尚无完整用量捕获" : "No complete usage capture"}`}{measured?.applicability === "historical" ? (zh ? " · 历史捕获" : " · Historical capture") : ""}

{zh ? "只读捕获,窗口可能与以上判读片段及下方 Explore 历史不同。" : "Read-only capture; its window can differ from the judgments above and Explore history below."}

+
+
{zh ? "分析尝试开销" : "Analysis attempt cost"}
{p.job?.usage ? <> + {zh ? "第" : "Attempt"} {p.job.attempt ?? "—"} · {zh ? "输入" : "input"} {p.job.usage.input_tokens == null ? "—" : number(p.job.usage.input_tokens)} · {zh ? "输出" : "output"} {p.job.usage.output_tokens == null ? "—" : number(p.job.usage.output_tokens)} tokens + {p.job.config ? ` · ${p.job.config.model}` : ""} + {p.job.analysis_wall_seconds != null ? ` · ${duration(p.job.analysis_wall_seconds)}` : ""} + : (zh ? "— 本次分析尝试未测得,不记零" : "— No measurement for this analysis attempt; not zero")}
+
{zh ? "合计边界" : "Aggregation boundary"}
{zh ? "分析仅显示当前回执的本次尝试;不含更早失败、恢复、评测或操作者开销。不与求解捕获盲目相加,金额及完整研究成本仍未知。" : "Analysis is this attempt's receipt only; earlier failures, recovery, evaluation and operator work are excluded. It is not blindly added to solver capture. Monetary and complete study costs remain unknown."}
+
{zh ? "捕获窗口跨度" : "Captured elapsed time"}
{usage ? duration(usage.elapsed_seconds) : "—"}
{usage ? `${number(usage.completed_turns)} ${zh ? "轮完整记录,不是全程总成本" : "complete Turns, not total study cost"}` : (zh ? "尚无完整用量捕获" : "No complete usage capture")}
{zh ? "未缓存输入" : "Uncached input"}
{usage ? number(usage.uncached_input_tokens) : "—"}
{usage ? "tokens" : (zh ? "未测,不记零" : "Unmeasured, not zero")}
diff --git a/docs/architecture/rfcs/goal-workspace-surface-v0.md b/docs/architecture/rfcs/goal-workspace-surface-v0.md index c389c34b4e..9290498c5d 100644 --- a/docs/architecture/rfcs/goal-workspace-surface-v0.md +++ b/docs/architecture/rfcs/goal-workspace-surface-v0.md @@ -732,6 +732,16 @@ independent scientific calibration or completion of V12. Host registration remains preparation outside the ordinary reading journey. CLI preparation uses the producer's existing interface; no new interactive Lark path is qualified. +The follow-up native-readback proposal extends that same trusted registration +with an optional provider outcome capture, scoped to the exact case/run/revision. +The existing source disclosure separates first submission, best valid in capture, +native-retained and final valid values, with original citations and historical/ +partial/unknown states. Submission order, not feedback arrival, identifies the +candidate. This is not another scorer or automatic solver feedback. Captured +solver usage and the current analysis attempt's receipt stay separate; full-study +cost and causal benefit remain unknown. No first-viewport/hero/CTA change is +introduced; six uncalibrated process judgments retain their existing role. + ## 12. Open decisions | # | Decision | Owner | Options | Recommendation | Needed before | diff --git a/docs/architecture/rfcs/goal-workspace-surface-v0.zh-CN.md b/docs/architecture/rfcs/goal-workspace-surface-v0.zh-CN.md index fd7c9204ce..210fd5cf90 100644 --- a/docs/architecture/rfcs/goal-workspace-surface-v0.zh-CN.md +++ b/docs/architecture/rfcs/goal-workspace-surface-v0.zh-CN.md @@ -520,6 +520,12 @@ producer 接入一次显式分析、取消、重试和有引用的结果阅读 任意 run 自动准备、独立科学校准或 V12 完成。host 注册仍是普通阅读旅程之外的准备, CLI 准备复用 producer 既有接口;本切片未验收新增飞书交互入口。 +后续原生读回提案在同一可信注册入口增加可选 provider 结果捕获,固定题目、运行和 +来源版本。既有来源展开分别展示首次提交、捕获内最佳有效值、原生保留和最后有效提交, +含原文引用及历史/部分/未知状态;候选按提交顺序而非反馈到达排序。不新增评分器或 +自动 solver 回灌。求解捕获与本次分析尝试的回执开销分别保留,完整研究成本及因果收益 +仍未知;首屏、hero、CTA 不变,六项未校准的过程判断维持原有角色。 + ## 12. 未决事项 | # | 事项 | Owner | 选项 | 建议 | 需在何时前决定 | diff --git a/docs/development/assets/research-analysis/native-outcomes-desktop.png b/docs/development/assets/research-analysis/native-outcomes-desktop.png new file mode 100644 index 0000000000..c30c1bb2d0 Binary files /dev/null and b/docs/development/assets/research-analysis/native-outcomes-desktop.png differ diff --git a/docs/development/assets/research-analysis/native-outcomes-mobile.png b/docs/development/assets/research-analysis/native-outcomes-mobile.png new file mode 100644 index 0000000000..bd5ca958fb Binary files /dev/null and b/docs/development/assets/research-analysis/native-outcomes-mobile.png differ diff --git a/docs/reference/trajectory-assessment.md b/docs/reference/trajectory-assessment.md index 2c26a91eb3..d145976f80 100644 --- a/docs/reference/trajectory-assessment.md +++ b/docs/reference/trajectory-assessment.md @@ -192,6 +192,77 @@ journey or qualify the model assessor. ## Measured solver session cost (optional) +### Native outcome readback (optional, separate from analyzer judgments) + +The trusted provider/Agent may add `--native-outcomes-json native-outcomes.json` +to the existing `benchmark trajectory-pack-register` operation. The file is +relative to its already authorized `--source-root`; the renderer cannot register +paths or invent measurements. Reading it does not execute analysis or change a +solver, evaluator, retained archive or study board. Omitting it preserves the +previous pack identity and response shape. + +The provider-exported JSON has this local diagnostic contract: + +```json +{ + "schema_version": "trajectory_native_outcomes_v1", + "scope": {"goal_id":"solver", "case_id":"case", "run_id":"run", "revision":"fixed", "window":"declared capture"}, + "start": "2026-01-01T00:00:00Z", + "end": "2026-01-01T00:10:00Z", + "complete": true, + "metric": {"name":"error", "unit":"points", "higher_is_better":false}, + "retained_result_id": "result-1", + "samples": [{ + "submission_index":1, "result_id":"result-1", "candidate_ref":"candidate-a", + "submitted_at":"2026-01-01T00:01:00Z", "observed_at":"2026-01-01T00:02:00Z", + "valid":true, "value":2, + "citation":{"source_id":"native-source", "first_line":1, "last_line":1} + }] +} +``` + +Every scope field must equal the registered assessment input. Source IDs and +line ranges must belong to that input; measurement/candidate IDs are opaque. +UTC timestamps must name real calendar instants (including leap-day validity), +not dates that a JavaScript parser silently normalizes into another window. +The source must contain provider-recorded released outcomes, not assessor +ratings or hidden evaluator material. The input curator/provider remains +responsible for the claim's truth and validity. `complete` declares prefix +coverage, not official integrity or score countability. A complete prefix has +contiguous submission indices starting at one. An invalid record uses +`valid:false,value:null`; zero is a valid measured value, never a missing value. +`retained_result_id:null` means retention is unknown, not that the mathematically +best value was adopted. + +The existing typed benchmark-toolkit prepare owner returns a separate bounded +`native_outcomes` projection: first valid submission-one value when the prefix +is complete, best valid measurement within the capture under the explicit native +metric direction, provider-selected retained result, and final valid submission. +These are distinct: delayed feedback does not promote an older submission to +the final candidate, and a better measured score does not override retention or +guardrails. No generic score, monetary estimate or causal benefit is derived. +The original analysis key/model input is unchanged; native capture provenance +instead versions the pack identity. Changed/unavailable files leave historical +captured values visible with their warning, rather than borrowing another pack. + +The existing Goal research source disclosure renders those facts and opens only +their bound original citations, even before model analysis exists. Solver +capture, native outcome and analyzed-episode windows remain separate. The cost +disclosure also shows the current analysis attempt's observed receipt usage; +earlier failures, native evaluation, recovery, operator costs and unmeasured +windows are not silently combined into a full-study bill. This does not complete +threshold-cost, gain-area, matched-study or scientific qualification. + +Synthetic packaged native readback (not benchmark findings): + +![Native outcome source disclosure](../development/assets/research-analysis/native-outcomes-desktop.png) + +[Narrow-screen source disclosure](../development/assets/research-analysis/native-outcomes-mobile.png). +The existing six-judgment first viewport is unchanged; these facts live in its +existing source disclosure, with costs in their separate disclosure. + +### Solver capture registration + A trusted pack curator can bind a **complete captured Codex session** separately from the model evidence. The existing register command accepts: diff --git a/examples/personal-workspace-browser/research-history.mjs b/examples/personal-workspace-browser/research-history.mjs index c566ae1cdf..adbde8cb8c 100644 --- a/examples/personal-workspace-browser/research-history.mjs +++ b/examples/personal-workspace-browser/research-history.mjs @@ -24,7 +24,15 @@ function analysisReadback(selected = "a") { turns: [{turn_id: "synthetic-turn", start: "2026-01-01T00:00:00Z", end: "2026-01-01T00:01:01Z", first_line: 2, last_line: 3, elapsed_seconds: 61, usage: {input_tokens: 1100, cached_input_tokens: 1000, uncached_input_tokens: 100, output_tokens: 20}}], limitations: []}}}), episodes: selected === "c" ? [{id: "episode", question: "Is this experiment discriminative?", source_ids: ["source"], eligible_metrics: ["discrimination"]}] : [{id: "episode", question: "Does a useful diagnosis prove original-objective utility?", source_ids: ["source"], eligible_metrics: ["goal_alignment", "benefit_test", "discrimination", "information_gain", "evidence_adoption", "redundancy"]}, {id: "uncertain", question: "Was the later decision informed by this evidence?", source_ids: ["source"], eligible_metrics: ["evidence_adoption"]}], - job: {status: "succeeded", analysis_wall_seconds: null, usage: null}, + ...(["a", "b", "d"].includes(selected) ? {native_outcomes: {artifact: "native-outcomes.json", artifact_sha256: "9".repeat(64), + applicability: selected === "b" ? "historical" : "current", reason_code: selected === "b" ? "source_changed" : null, + projection: {method: "source_bound_native_outcomes_v1", scope, start: "2026-01-01T00:00:00Z", end: "2026-01-01T00:10:00Z", + complete: true, metric: {name: "error", unit: "points", higher_is_better: false}, sample_count: 3, valid_count: 2, + ...Object.fromEntries(["initial", "best_valid", "retained", "final_valid"].map(key => [key, {submission_index: key === "initial" ? 1 : 3, + result_id: key === "initial" ? "native-initial" : "native-final", candidate_ref: key === "initial" ? "candidate-first" : "candidate-retained", + submitted_at: "2026-01-01T00:01:00Z", observed_at: "2026-01-01T00:02:00Z", value: key === "initial" ? 7 : 3, + citation: {source_id: "source", first_line: 1, last_line: 2}}])), limitations: []}}} : {}), + job: {status: "succeeded", attempt: 2, analysis_wall_seconds: 12, usage: {input_tokens: 30, output_tokens: 4}, config}, result: {schema_version: "trajectory_assessment_v1", method_version: "research-episodes-v2", analysis_key: (selected === "c" ? "e" : "c").repeat(64), scope, selection, calibration: "uncalibrated_model", evidence_manifest: [{id: "source", artifact: "facts.txt", artifact_sha256: "f".repeat(64), first_line: 1}], limitations: ["Synthetic presentation fixture; not a scientific result"], metrics: [...(selected === "c" ? [{metric: "benefit_test", yes: 0, no: 0, unknown: 0, eligible: 0, assessed_fraction: null, missing_data_bounds: null}, {metric: "discrimination", yes: 0, no: 0, unknown: 1, eligible: 1, assessed_fraction: 0, missing_data_bounds: [0, 1]}] @@ -318,7 +326,24 @@ Path(sys.argv[1]).write_text(json.dumps({'goal_id':goal,'display_name':'Syntheti await progress.locator('.research-usage > summary').click(); assert.equal(await progress.getByText("100", {exact: true}).count(), 1, "Uncached input is shown without charging cache twice"); assert.match(await progress.innerText(), /1,?100.*1,?000/s); + assert.match(await progress.innerText(), /30.*4.*tokens/s, "Analysis attempt cost is distinct from solver usage"); + assert.match(await progress.innerText(), /更早失败.*恢复.*评测/s); await progress.locator('.research-usage > summary').click(); + const basis = progress.locator('.research-progress-basis'); + await basis.locator('summary').click(); + assert.match(await basis.innerText(), /首次提交.*7.*candidate-first/s); + assert.match(await basis.innerText(), /原生保留结果.*3.*candidate-retained/s); + await basis.getByRole('button', {name: '查看原始证据', exact: true}).first().scrollIntoViewIfNeeded(); + assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1), 'Native details remain readable on a narrow screen'); + await page.screenshot({path: resolve(outputDir, "research-native-outcomes-mobile.png"), animations: "disabled"}); + await page.setViewportSize({width:1280,height:900}); + await page.screenshot({path: resolve(outputDir, "research-native-outcomes-desktop.png"), animations: "disabled"}); + await basis.getByRole('button', {name: '查看原始证据', exact: true}).first().click(); + await citedOriginal.waitFor(); + assert.match(await citedOriginal.innerText(), /Synthetic original evidence/); + await citedOriginal.press('Escape'); + await history.getByRole('button', {name: '关闭分析', exact: true}).click(); + await basis.locator('summary').click(); await benefitMetric.locator('summary').click(); assert.match(await progress.innerText(), /0 \/ 1/); assert.match(await progress.innerText(), /1 \/ 2/); @@ -367,6 +392,14 @@ Path(sys.argv[1]).write_text(json.dumps({'goal_id':goal,'display_name':'Syntheti assert.match(await progress.innerText(), /此范围尚无分析器判读/); assert.equal(await progress.locator('.research-effect-metrics').count(), 0, 'An unanalysed cost pack never borrows another pack’s judgments'); assert.equal(await analysis.count(), 0, 'Choosing an unanalysed source never requests analysis'); + await basis.locator('summary').click(); + assert.match(await basis.innerText(), /原生保留结果.*candidate-retained/s, 'Native facts do not require an analyzer result'); + const nativeCitation = basis.getByRole('button', {name: '查看原始证据', exact: true}).first(); + await nativeCitation.focus(); await nativeCitation.press('Enter'); + await citedOriginal.waitFor(); + await citedOriginal.press('Escape'); + await history.getByRole('button', {name: '关闭分析', exact: true}).click(); + await basis.locator('summary').click(); // Analysis sources do not require an Explore log. The real read-only // backend must keep their measurements discoverable with zero events. const emptyFixture = resolve(temporary, "empty.json"); diff --git a/loopx/capabilities/benchmark_toolkit/trajectory_assessment_store.py b/loopx/capabilities/benchmark_toolkit/trajectory_assessment_store.py index 87161963c2..461c588997 100644 --- a/loopx/capabilities/benchmark_toolkit/trajectory_assessment_store.py +++ b/loopx/capabilities/benchmark_toolkit/trajectory_assessment_store.py @@ -52,6 +52,8 @@ def _pack_id(owner_goal_id: str, record: dict) -> str: } if "solver_usage" in record: identity["solver_usage"] = record["solver_usage"] + if "native_outcomes" in record: + identity["native_outcomes"] = record["native_outcomes"] return _digest(identity) @@ -146,8 +148,8 @@ def _usage_capture(source_root: Path, artifact: str, session_id: str) -> dict: } -def _usage_view(record: dict) -> dict | None: - captured = record.get("solver_usage") +def _captured_view(record: dict, field: str) -> dict | None: + captured = record.get(field) if captured is None: return None reason = None @@ -223,7 +225,7 @@ def _summary(record: dict, reason: str | None) -> dict: "config": record["config"], "availability": "unavailable" if reason else "available", "reason_code": reason, - **({"solver_usage": _usage_view(record)} if "solver_usage" in record else {}), + **({field: _captured_view(record, field) for field in ("solver_usage", "native_outcomes") if field in record}), } @@ -237,6 +239,7 @@ def register_pack( config: dict | None = None, usage_jsonl: str | None = None, usage_session_id: str | None = None, + native_outcomes_json: str | None = None, ) -> dict: """Trusted operator only; no HTTP/renderer path-registration endpoint.""" if not title.strip(): @@ -266,6 +269,17 @@ def register_pack( record["solver_usage"] = _usage_capture( source_root, usage_jsonl, usage_session_id ) + if native_outcomes_json is not None: + data = _source_path(source_root, native_outcomes_json).read_bytes() + measured = effect_runtime_result("benchmark.trajectory.prepare", { + "input": record["input"], "config": record["config"], + "native_outcomes": json.loads(data), + })["native_outcomes"] + record["native_outcomes"] = { + "artifact": native_outcomes_json, + "artifact_sha256": hashlib.sha256(data).hexdigest(), + "projection": measured, + } owner = _owner(runtime_root, owner_goal_id) (owner / "packs").mkdir(parents=True, exist_ok=True, mode=0o700) pack_id = _pack_id(owner_goal_id, record) @@ -445,12 +459,15 @@ def read_citation( runtime_root=runtime_root, owner_goal_id=owner_goal_id, pack_id=pack_id ) result = view["result"] - if result is None or not any( + citations = [c for a in (result or {}).get("assessments", []) for c in a["citations"]] + native = (view.get("native_outcomes") or {}).get("projection") or {} + native_citations = [native[key]["citation"] for key in ("initial", "best_valid", "retained", "final_valid") if native.get(key)] + requested = {"source_id": source_id, "first_line": first_line, "last_line": last_line} + if not any( c["source_id"] == source_id and c["first_line"] == first_line and c["last_line"] == last_line - for a in result["assessments"] - for c in a["citations"] + for c in [*citations, *native_citations] ): raise PackUnavailable("citation_not_in_result") source = next(s for s in record["input"]["sources"] if s["id"] == source_id) @@ -465,5 +482,6 @@ def read_citation( first_line - source["first_line"] : last_line - source["first_line"] + 1 ] ), - "applicability": view["applicability"], + "applicability": "historical" if requested in native_citations + and view["native_outcomes"]["applicability"] == "historical" else view["applicability"], } diff --git a/loopx/cli_commands/benchmark_trajectory.py b/loopx/cli_commands/benchmark_trajectory.py index e6d91ea7ff..d1a333964f 100644 --- a/loopx/cli_commands/benchmark_trajectory.py +++ b/loopx/cli_commands/benchmark_trajectory.py @@ -41,6 +41,10 @@ def register_benchmark_trajectory_commands(subparsers, add_format) -> None: "--usage-session-id", help="Expected captured solver session identity; required with --usage-jsonl.", ) + register.add_argument( + "--native-outcomes-json", + help="Optional provider-recorded native outcomes, relative to source-root; same pack scope, never analyzer scores.", + ) add_format(register) listing = subparsers.add_parser( "trajectory-packs", @@ -87,6 +91,7 @@ def _handle_benchmark_trajectory_command( title=args.title, usage_jsonl=args.usage_jsonl, usage_session_id=args.usage_session_id, + native_outcomes_json=args.native_outcomes_json, ) if args.benchmark_command == "trajectory-pack-register" else discover(**scope) diff --git a/loopx/control_plane/capabilities/trajectory_assessment.ts b/loopx/control_plane/capabilities/trajectory_assessment.ts index 49a5abae5f..3ae78e633d 100644 --- a/loopx/control_plane/capabilities/trajectory_assessment.ts +++ b/loopx/control_plane/capabilities/trajectory_assessment.ts @@ -1,5 +1,6 @@ /** Benchmark-toolkit's observer sidecar; never a solver or native-score authority. */ import {createHash} from 'node:crypto'; +import {projectTrajectoryNativeOutcomes} from './trajectory_native_outcomes.ts'; import {BARE_SHA256_PATTERN} from '../content_digest.ts'; import {EffectRuntimeRequestError} from '../effect_runtime_errors.ts'; @@ -94,6 +95,7 @@ export function prepareTrajectoryAssessment(params: Record) { const analysis_key = createHash('sha256').update(JSON.stringify({method: TRAJECTORY_METHOD, rubric: RUBRIC, input, config: configIdentity})).digest('hex'); const numberedSources = input.sources.map(s => ({...s, text: s.text.split('\n').map((line, n) => `${s.first_line+n}: ${line}`).join('\n')})); return {analysis_key, method_version: TRAJECTORY_METHOD, config: configIdentity, input, + ...(params.native_outcomes === undefined ? {} : {native_outcomes: projectTrajectoryNativeOutcomes(params.native_outcomes, input)}), prompt: `${RUBRIC}\n\nEvidence pack:\n${JSON.stringify({...input, sources: numberedSources})}`, output_schema: TRAJECTORY_OUTPUT_SCHEMA}; } export function qualifyTrajectoryAssessment(params: Record): TrajectoryAssessment { diff --git a/loopx/control_plane/capabilities/trajectory_native_outcomes.ts b/loopx/control_plane/capabilities/trajectory_native_outcomes.ts new file mode 100644 index 0000000000..cac3e837d2 --- /dev/null +++ b/loopx/control_plane/capabilities/trajectory_native_outcomes.ts @@ -0,0 +1,99 @@ +/** Source-bound native measurements, not analyzer judgments or score authority. */ +import {EffectRuntimeRequestError} from "../effect_runtime_errors.ts"; +import type {AssessmentCitation, TrajectoryInput} from "./trajectory_assessment_types.ts"; + +export interface NativeOutcomePoint { + submission_index: number; result_id: string; candidate_ref: string; + submitted_at: string; observed_at: string; value: number; citation: AssessmentCitation; +} +export interface TrajectoryNativeOutcomes { + method: "source_bound_native_outcomes_v1"; + scope: TrajectoryInput["scope"]; start: string; end: string; complete: boolean; + metric: {name: string; unit: string; higher_is_better: boolean}; + sample_count: number; valid_count: number; + initial: NativeOutcomePoint | null; best_valid: NativeOutcomePoint | null; + retained: NativeOutcomePoint | null; final_valid: NativeOutcomePoint | null; + limitations: string[]; +} + +function fail(message: string): never { throw new EffectRuntimeRequestError(message); } +function object(value: unknown, label: string): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) fail(`${label} must be an object`); + return value as Record; +} +function identifier(value: unknown, label: string): string { + if (typeof value !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,159}$/.test(value)) { + fail(`${label} must be an opaque public-safe identifier`); + } + return value; +} +function metricLabel(value: unknown, label: string): string { + if (typeof value !== "string" || !value.trim() || value.length > 80 || /[\u0000-\u001f]/.test(value)) { + fail(`${label} must be a bounded display label`); + } + return value; +} +function timestamp(value: unknown, label: string): string { + if (typeof value !== "string" || !/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d{1,3})?Z$/.test(value) + || !Number.isFinite(Date.parse(value))) fail(`${label} must be a UTC timestamp`); + // Date.parse normalizes impossible calendar dates and 24:00 into another + // instant. Compare canonical milliseconds, accepting the declared precision + // without silently moving the provider's measurement window. + const canonical = value.slice(0, 19) + "." + (value.slice(19, -1).slice(1) || "").padEnd(3, "0") + "Z"; + if (new Date(value).toISOString() !== canonical) fail(`${label} must be a UTC timestamp`); + return value; +} + +/** The provider declares validity/retention. This owner checks binding and ranks + * valid measurements within that declared capture, never certifying a study. */ +export function projectTrajectoryNativeOutcomes(value: unknown, input: TrajectoryInput): TrajectoryNativeOutcomes { + const raw = object(value, "native outcomes"), scope = object(raw.scope, "native outcome scope"); + if (raw.schema_version !== "trajectory_native_outcomes_v1") fail("unsupported native outcomes schema"); + if (Object.keys(input.scope).some(key => scope[key] !== input.scope[key as keyof typeof input.scope]) + || Object.keys(scope).length !== Object.keys(input.scope).length) fail("native outcome scope differs from the registered pack"); + const start = timestamp(raw.start, "native start"), end = timestamp(raw.end, "native end"); + if (Date.parse(start) > Date.parse(end) || typeof raw.complete !== "boolean") fail("native capture requires an ordered window and explicit coverage"); + const metric = object(raw.metric, "native metric"); + const name = metricLabel(metric.name, "native metric name"), unit = metricLabel(metric.unit, "native metric unit"); + if (typeof metric.higher_is_better !== "boolean") fail("native metric direction must be explicit"); + if (!Array.isArray(raw.samples) || raw.samples.length > 10000) fail("native samples must be an array of at most 10000 records"); + const valid: NativeOutcomePoint[] = [], seen = new Set(); + let previous = 0; + for (const value of raw.samples) { + const sample = object(value, "native sample"), index = sample.submission_index; + if (!Number.isSafeInteger(index) || Number(index) <= previous + || raw.complete && index !== previous + 1) fail("native submission indices must be distinct, ordered and complete when declared complete"); + previous = Number(index); + const result = identifier(sample.result_id, "native result"), candidate = identifier(sample.candidate_ref, "native candidate"); + if (seen.has(result)) fail("native result identity is duplicated"); + seen.add(result); + const submitted = timestamp(sample.submitted_at, "native submitted_at"), observed = timestamp(sample.observed_at, "native observed_at"); + if (Date.parse(submitted) < Date.parse(start) || Date.parse(observed) > Date.parse(end) + || Date.parse(submitted) > Date.parse(observed)) fail("native result lies outside its capture or precedes submission"); + if (typeof sample.valid !== "boolean" || sample.valid && (typeof sample.value !== "number" || !Number.isFinite(sample.value)) + || !sample.valid && sample.value !== null) fail("native validity and measured value must agree; missing/invalid is not zero"); + const citation = object(sample.citation, "native citation"), source = input.sources.find(s => s.id === citation.source_id); + const first = citation.first_line, last = citation.last_line; + if (!source || !Number.isSafeInteger(first) || !Number.isSafeInteger(last) + || Number(first) < source.first_line || Number(last) < Number(first) + || Number(last) >= source.first_line + source.text.split("\n").length) fail("native citation is outside registered evidence"); + if (sample.valid) valid.push({submission_index: Number(index), result_id: result, candidate_ref: candidate, + submitted_at: submitted, observed_at: observed, value: sample.value as number, + citation: {source_id: source.id, first_line: Number(first), last_line: Number(last)}}); + } + let best: NativeOutcomePoint | null = null; + for (const point of valid) if (!best || (metric.higher_is_better ? point.value > best.value : point.value < best.value)) best = point; + const retainedId = raw.retained_result_id; + if (retainedId !== null) identifier(retainedId, "retained native result"); + const retained = valid.find(point => point.result_id === retainedId) ?? null; + if (retainedId !== null && !retained) fail("retained native result must be a valid result in this capture"); + return {method: "source_bound_native_outcomes_v1", scope: input.scope, start, end, complete: raw.complete, + metric: {name, unit, higher_is_better: metric.higher_is_better}, sample_count: raw.samples.length, + valid_count: valid.length, initial: raw.complete ? valid.find(point => point.submission_index === 1) ?? null : null, + best_valid: best, retained, final_valid: valid.at(-1) ?? null, + limitations: ["Provider-recorded measurements in the declared capture; not study countability or a causal effect.", + "Validity and retained selection belong to the native provider, not model judgments or Todo status.", + "Initial requires a complete prefix and a valid first submission; invalid or missing results are not zero.", + "Submission order determines initial/final; delayed feedback does not make an older candidate current.", + "Costs retain their own measured windows; no monetary, whole-study or per-result cost is inferred."]}; +} diff --git a/tests/capabilities/test_trajectory_assessment_store.py b/tests/capabilities/test_trajectory_assessment_store.py index 21e96f8164..6479f0b99b 100644 --- a/tests/capabilities/test_trajectory_assessment_store.py +++ b/tests/capabilities/test_trajectory_assessment_store.py @@ -9,6 +9,7 @@ from datetime import datetime, timedelta, timezone import pytest +from loopx.control_plane.effect_runtime import EffectRuntimeRejected from loopx.capabilities.benchmark_toolkit.trajectory_assessment_store import ( PackUnavailable, @@ -134,6 +135,62 @@ def test_scope_discovery_execute_read_citation_and_changed_source_history(pack): ) +def test_native_results_register_through_real_cli_and_read_without_analysis(pack, tmp_path): + scope, _, original, input_path, sources, _ = pack + native = { + "schema_version": "trajectory_native_outcomes_v1", + "scope": json.loads(input_path.read_text())["scope"], + "start": "2026-01-01T00:00:00Z", "end": "2026-01-01T00:10:00Z", + "complete": True, "metric": {"name": "error", "unit": "points", "higher_is_better": False}, + "retained_result_id": "native-1", + "samples": [{"submission_index": 1, "result_id": "native-1", "candidate_ref": "candidate-a", + "submitted_at": "2026-01-01T00:01:00Z", "observed_at": "2026-01-01T00:02:00Z", + "valid": True, "value": 2, "citation": {"source_id": "source", "first_line": 2, "last_line": 2}}], + } + native_path = sources / "native-outcomes.json" + native_path.write_text(json.dumps(native)) + registry = tmp_path / "native-registry.json" + registry.write_text(json.dumps({"goals": []})) + result = subprocess.run([sys.executable, "-m", "loopx.cli", "--registry", str(registry), + "--runtime-root", str(scope["runtime_root"]), "--format", "json", "benchmark", "trajectory-pack-register", + "--goal-id", scope["owner_goal_id"], "--input-json", str(input_path), "--source-root", str(sources), + "--title", "Native outcome window", "--native-outcomes-json", native_path.name], + capture_output=True, text=True, check=True) + registered = json.loads(result.stdout) + assert registered["analysis_key"] == original["analysis_key"] + assert "native_outcomes" not in original + assert registered["native_outcomes"]["projection"]["initial"]["value"] == 2 + view = inspect_pack(**scope, pack_id=registered["pack_id"]) + assert view["job"] is None and view["result"] is None + assert view["native_outcomes"]["projection"]["retained"]["candidate_ref"] == "candidate-a" + citation = read_citation(**scope, pack_id=registered["pack_id"], analysis_key=registered["analysis_key"], + source_id="source", first_line=2, last_line=2) + assert citation["text"] == "The native measurement did not improve utility." + with pytest.raises(PackUnavailable, match="citation_not_in_result"): + read_citation(**scope, pack_id=registered["pack_id"], analysis_key=registered["analysis_key"], + source_id="source", first_line=1, last_line=2) + native["samples"][0]["value"] = 3 + native_path.write_text(json.dumps(native)) + old = inspect_pack(**scope, pack_id=registered["pack_id"]) + assert old["native_outcomes"]["applicability"] == "historical" + assert old["native_outcomes"]["projection"]["initial"]["value"] == 2 + assert read_citation(**scope, pack_id=registered["pack_id"], analysis_key=registered["analysis_key"], + source_id="source", first_line=2, last_line=2)["applicability"] == "historical" + assert not list((scope["runtime_root"] / "benchmark" / "trajectory-assessments").glob("*/jobs/*")) + + +def test_native_capture_cannot_borrow_another_run_or_escape_source_root(pack): + scope, _, _, input_path, sources, _ = pack + path = sources / "wrong-native.json" + raw = {"schema_version": "trajectory_native_outcomes_v1", "scope": { + **json.loads(input_path.read_text())["scope"], "run_id": "foreign"}} + path.write_text(json.dumps(raw)) + with pytest.raises(EffectRuntimeRejected, match="scope differs"): + register_pack(**scope, input_path=input_path, source_root=sources, title="wrong", native_outcomes_json=path.name) + with pytest.raises(PackUnavailable, match="invalid_source_path"): + register_pack(**scope, input_path=input_path, source_root=sources, title="wrong", native_outcomes_json="../input.json") + + def test_owner_isolation_unknown_ids_and_keys_are_rejected(pack): scope, operation, _, _, _, _ = pack other = {**scope, "owner_goal_id": "other-owner"} diff --git a/tests/control_plane_ts/trajectory_native_outcomes.test.ts b/tests/control_plane_ts/trajectory_native_outcomes.test.ts new file mode 100644 index 0000000000..df176b9763 --- /dev/null +++ b/tests/control_plane_ts/trajectory_native_outcomes.test.ts @@ -0,0 +1,73 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import {prepareTrajectoryAssessment, type TrajectoryInput} from "../../loopx/control_plane/capabilities/trajectory_assessment.ts"; + +const input: TrajectoryInput = {schema_version:"trajectory_assessment_input_v1", + scope:{goal_id:"g",case_id:"c",run_id:"r",revision:"rev",window:"complete"}, objective:"Minimize native error", + selection:{rule:"all",population_episodes:1,exhaustive:true}, + sources:[{id:"s",artifact:"native.json",artifact_sha256:"a".repeat(64),first_line:1,text:"first\nold delayed result\nnew retained result"}], + episodes:[{id:"e",question:"Does the alternative help?",source_ids:["s"],eligible_metrics:["benefit_test"]}]}; +const config = {model:"fixture",effort:"max",service_tier:"fast"}; +function capture() {return {schema_version:"trajectory_native_outcomes_v1",scope:{...input.scope}, + start:"2026-01-01T00:00:00Z",end:"2026-01-01T00:10:00Z",complete:true, + metric:{name:"error",unit:"points",higher_is_better:false},retained_result_id:"result-3", + samples:[2,5,1].map((value,i)=>({submission_index:i+1,result_id:`result-${i+1}`,candidate_ref:`candidate-${i+1}`, + submitted_at:`2026-01-01T00:0${i}:00Z`,observed_at:`2026-01-01T00:0${i===1?5:3}:00Z`,valid:true,value, + citation:{source_id:"s",first_line:i+1,last_line:i+1}}))};} +function project(value: unknown) {return prepareTrajectoryAssessment({input,config,native_outcomes:value}).native_outcomes!;} + +test("native facts preserve first quality, actual retention and submission order independently of delayed feedback",()=>{ + const native=project(capture()); + assert.equal(native.initial?.value,2); assert.equal(native.best_valid?.value,1); + assert.equal(native.retained?.result_id,"result-3"); assert.equal(native.final_valid?.result_id,"result-3"); + assert.equal(native.sample_count,3); assert.equal(native.valid_count,3); + assert.equal(prepareTrajectoryAssessment({input,config,native_outcomes:capture()}).analysis_key, + prepareTrajectoryAssessment({input,config}).analysis_key,"native readback does not rerun unchanged analysis"); + assert.equal(Object.hasOwn(prepareTrajectoryAssessment({input,config}),"native_outcomes"),false); +}); +test("higher-is-better is explicit; best measurement does not invent retained selection",()=>{ + const raw=capture();raw.metric.higher_is_better=true;raw.retained_result_id="result-1"; + raw.metric.name="Native throughput (%)"; raw.metric.unit="ops/s"; + const native=project(raw);assert.equal(native.best_valid?.value,5);assert.equal(native.retained?.value,2); + assert.equal(native.metric.unit,"ops/s");assert.equal(native.metric.name,"Native throughput (%)"); +}); +test("invalid first measurements and incomplete prefixes remain unknown, never zero",()=>{ + const raw=capture() as unknown as Record; + raw.samples[0].valid=false;raw.samples[0].value=null; + assert.equal(project(raw).initial,null);assert.equal(project(raw).valid_count,2); + raw.complete=false;raw.samples=raw.samples.slice(1); + assert.equal(project(raw).initial,null);assert.equal(project(raw).final_valid?.value,1); + raw.samples[0].valid=false;raw.samples[0].value=0;assert.throws(()=>project(raw),/validity/); +}); +test("wrong source, duplicate results, old scope and invented retention cannot borrow a pack",()=>{ + for(const mutate of [ + (x:any)=>x.scope.run_id="foreign", (x:any)=>x.scope.revision="old", + (x:any)=>x.samples[1].result_id=x.samples[0].result_id, + (x:any)=>x.samples[1].submission_index=1, + (x:any)=>x.samples[0].citation.last_line=4, + (x:any)=>x.samples[0].citation.source_id="foreign", + (x:any)=>x.samples[0].observed_at="2026-01-01T00:11:00Z", + (x:any)=>x.retained_result_id="invented", + (x:any)=>x.samples[0].value=Infinity, + ]) {const x=capture();mutate(x);assert.throws(()=>project(x));} +}); +test("UTC dates must name real calendar instants, preserving legal fractional seconds",()=>{ + for (const date of ["2026-02-30T00:00:00Z", "2025-02-29T00:00:00Z", + "2026-04-31T00:00:00Z", "2026-01-01T24:00:00Z"]) { + for (const field of ["start", "end", "submitted_at", "observed_at"]) { + const raw = capture() as any; + if (field === "start" || field === "end") raw[field] = date; + else raw.samples[0][field] = date; + assert.throws(()=>project(raw), /UTC timestamp/, `${field}: ${date}`); + } + } + for (const fraction of ["", ".1", ".12", ".123"]) { + const raw = capture(); + raw.start = `2024-02-29T00:00:00${fraction}Z`; + raw.end = "2024-03-01T00:00:00Z"; + for (const sample of raw.samples) { + sample.submitted_at = sample.observed_at = raw.start; + } + assert.equal(project(raw).initial?.submitted_at, raw.start); + } +}); diff --git a/tests/presentation/test_research_analysis_api.py b/tests/presentation/test_research_analysis_api.py index c76d3a86e4..5e83c008c4 100644 --- a/tests/presentation/test_research_analysis_api.py +++ b/tests/presentation/test_research_analysis_api.py @@ -149,3 +149,27 @@ def test_http_rejects_other_owner_paths_free_configuration_and_wrong_origin(work with pytest.raises(HTTPError): read() assert not w["count"].exists() + + +def test_native_outcomes_and_bound_citations_read_over_http_without_analysis(workspace): + w = workspace + data = json.loads((w["tmp_path"] / "input.json").read_text()) + native = {"schema_version": "trajectory_native_outcomes_v1", "scope": data["scope"], + "start": "2026-01-01T00:00:00Z", "end": "2026-01-01T00:03:00Z", "complete": True, + "metric": {"name": "error", "unit": "points", "higher_is_better": False}, + "retained_result_id": "result-one", "samples": [{"submission_index": 1, + "result_id": "result-one", "candidate_ref": "candidate-one", "valid": True, "value": 0.000001, + "submitted_at": "2026-01-01T00:01:00Z", "observed_at": "2026-01-01T00:02:00Z", + "citation": {"source_id": "facts", "first_line": 2, "last_line": 2}}]} + (w["sources"] / "native.json").write_text(json.dumps(native)) + registered = register_pack(runtime_root=w["tmp_path"] / "runtime", owner_goal_id="research-owner", + input_path=w["tmp_path"] / "input.json", source_root=w["sources"], title="Native capture", native_outcomes_json="native.json") + snapshot = w["request"](pack_id=registered["pack_id"]) + assert snapshot["pack"]["native_outcomes"]["projection"]["retained"]["value"] == 0.000001 + assert snapshot["pack"]["job"] is None + citation = w["request"](suffix="/citation", pack_id=registered["pack_id"], analysis_key=registered["analysis_key"], + source_id="facts", first_line=2, last_line=2) + assert citation["text"] == data["sources"][0]["text"] + assert not w["count"].exists() + with pytest.raises(HTTPError): + w["request"](goal="other-owner", pack_id=registered["pack_id"])