feat(origin): 新增受控科研分析能力

This commit is contained in:
caoqianming 2026-08-26 15:32:38 +08:00
parent 631a635eab
commit 3068da491f
14 changed files with 1894 additions and 0 deletions

View File

@ -8,6 +8,8 @@
## Unreleased
- Origin 新增受控科研分析,可进行数据体检、归一化、平滑、求导、积分和线性拟合,并交付结果表、诊断信息及可继续检查的 Origin 工程。
- 平台托管的文献、内部材料库和 Materials Project 来源现在使用来源明确的工具,并可在单个来源不可用时继续使用其他来源;访问凭据不进入任务沙箱。
## 0.68.0 — 2026-08-24

View File

@ -483,6 +483,8 @@ Node 通过 `Authorization: Bearer` 与 `X-Node-Id` 建立 `/v1/software-nodes/c
第四阶段落地固定 Origin WorkerNode 仅从管理员安装的固定 Python 运行时启动随程序发布的 `worker.py`,参数只有本机 job 目录请求不能指定脚本、解释器或文件路径。Worker 使用 `originpro` 生成 OPJU、PNG、SVG、PDF、plot spec 和 provenance校验产物签名并原子写入终态。`origin.plot@v2` 保持单一外层契约,旧单图 `series[]``x/y/z/y_error` 统一表达数据角色,再按 `plot.type` 判别必需角色;当前覆盖折线、散点、线点、柱/条形、分组柱形、Y 误差棒、等高线、三维曲面、三元图和规则网格热图,并以可选的 `canvas`、轴排版、图例、标题和 series style 统一表达出版级尺寸与样式。新增复合图不继续枚举 `dual_axis_line` 等组合类型,而使用 `multi_panel` feature`panels[]` 显式描述 14 个二维 panel每条系列用 `kind` 选择基础二维图型、用 `y_axis` 绑定左右轴,并可提供对称 `x_error/y_error``layout` 只允许 1×1、1×2、2×1、2×2 四种受控布局。Worker 从固定基础二维图创建受控原生图层网格及 right-Y 图层,不依赖带隐式主题分组的 panel 模板也不接受请求传入模板名Origin 不支持同一 3D 图窗多图层,因此 contour、surface、ternary 和 heatmap 不混入本阶段 panel。旧 XY 请求原样兼容。进程内 pipeline 按 job 去重,并脱离单次 WebSocket 的取消令牌运行;连接中断只延迟状态/终态上报。Node 进程若在 Worker 启动后重启,则保守失败而不重复驱动 Origin避免无法证明的双执行。
Origin 科研分析以独立 `origin.analysis@v1` capability 承载,不把数据处理、拟合和统计继续塞入 `origin.plot@v2`。首批白名单 operation 为 `data_check/normalize/smooth/differentiate/integrate/linear_fit`:合同绑定单个 CSV/XLSX/JSON artifact 和显式 X/Y 列,固定 Worker 使用版本化数值算法生成结果表与机器可读诊断,再把原始数据、结果和诊断写入可编辑 OPJU线性拟合额外调用 Origin 内置 `LinearFit` 生成原生报告。标准化数值结果以独立 oracle 为发布依据Origin 报告用于工程内继续检查和编辑,避免把正确性绑定到本机语言相关的层级报告表解析。输出固定包含 OPJU、CSV、diagnostics、analysis spec 与 provenance可选 XLSX分析 capability 为无状态一次性 Job结果 CSV/XLSX 可登记为后续 plot Job 输入,不与绘图混在同一请求或 Workspace 事务中。`data_check` 命中请求指定的问题时返回 `diagnostics.passed=false` 而非让 Job 失败,使 Agent 能先体检再决定是否占用后续分析资源。请求仍不能提供 Python、LabTalk、函数表达式、模板或路径两份 Origin adapter 共享同一受管 `origin` runtime并由整机单执行槽保守串行。
Recipe 是专业软件的声明式目标状态,不是任意脚本或逐次鼠标命令。`origin.plot@v2` 以新增 `plot.type=recipe + recipe_version=1` 复用已验证的 `layout/panels/series/axis/legend` 组合结构;旧 `multi_panel` 与 Recipe 进入同一个受控执行器,单图和特殊统计图继续保留兼容入口。模型可以组合契约允许的原子图形能力,但不能提供 Python、LabTalk、解释器、模板名或文件路径。其他软件可采用同一通用 Job 外壳和各自的声明式 Recipe不建设跨软件万能 DSLAPI/SDK executor 优先,未来的 UI Automation 或 Computer Use 仅作为 adapter 内部执行后端,不改变云端 Recipe。
Blender 入口为 `blender.scene.author@v3`,唯一场景类型是 `recipe`;它是 Blender 专属的声明式静态场景目标状态,不是跨软件万能 DSL也不是 Python、逐步按键或任意节点脚本。Recipe v1 组合带稳定 ID、父子层级和集合归属的 geometry/generator/text/empty 对象geometry 覆盖 box/cylinder/cone/sphere/torus/plane、二维轮廓拉伸、轴向旋转、路径扫掠和有壁厚直管受控 modifier 覆盖 boolean/bevel/mirror/array/solidify并统一声明材质、变换、灯光、世界背景和 18 个相机视图;`rotary_kiln` 只是 generator 组件,可与其他对象处于同一场景,不再占据顶层 capability。adapter 1.0.0 把合同校验后的 Recipe 编译为固定 `bpy` 调用,限制请求 256 KiB、200 个声明对象、展开后 1000 个 Blender 对象、100 万 mesh polygon、材质/灯光/视图和修改器数量Worker 还机械校验 ID 唯一性、引用、父子关系、布尔目标、管壁和请求预览的一致性。输出固定包含 `.blend`、首张 PNG、场景清单和溯源可再选择最多七张 PNG 与 GLB预览用 `preview_01``preview_08` 稳定槽位,视图业务名称记录在 manifest。

View File

@ -20,6 +20,8 @@
---
## 已完成关键能力
- **08-26 / Unreleased / Origin 科研分析 MVP**:新增非默认的 `origin.analysis@v1` / adapter 0.1.0,与稳定的 `origin.plot@v2` 分离;首批提供数据体检、最大值/面积/参考点归一化、固定局部多项式平滑、一/二阶导数、区间/累计梯形积分和带 95% 置信区间的线性拟合,固定输出可编辑 OPJU、CSV、诊断、分析规格与溯源可选 XLSX线性拟合工程同时保留 Origin 原生报告。六个合成真值用例在 Origin 2024 / originpro 1.1.15 / numpy 2.2.6 上完成生产 Worker、数值 oracle、OPJU 重开和进程释放门;前三例来自组合执行的逐例通过记录,后三例分别通过,目标机 COM 完整退出约需 2 分钟。Origin/合同/Job/Node 专项 159 项 unittest、Release .NET build、完整 Node publish 与独立 adapter ZIP 校验通过,未连接或写入数据库。
- **08-26 / Unreleased / platform_sources bounded context**:将 paper_server、内部材料库和 Materials Project 收敛为独立顶层包,以显式可信 Provider 列表统一可用性与生命周期,单来源配置/装配失败不阻断其他来源;`core/tool_registry.py` 只保留一个构建入口,与用户连接 `external_systems` 完全独立。模型工具统一改为来源明确的 `paper_server_*`、`materials_library_*`、`materials_project_*`保留材料库批量检索、MP 专用 SDK/CIF/entries 语义及 paper_server 安全下载边界;部署环境变量仍是事实源,无数据库迁移、无版本提升。
- **08-26 / Unreleased / paper_server 宿主侧受控访问**:新增按配置注册的 `paper_server_search` / `paper_server_get` / `paper_server_fetch` typed tools`publication_type` 直接映射后端 `type` 过滤且保留开放原始类型API Key 从 `run_python` 和 Docker sandbox 透传中移除,下载增加同源 URL、100 MiB 上限和原子落盘约束。literature / brief 指引、smoke、RUN、Skill 清单及回归测试同步迁移,线上只读 `type=book` 验证通过,不提升版本号。

6
RUN.md
View File

@ -1160,6 +1160,12 @@ Web 用户登录后,文件栏 Job 中心会聚合本人最近任务。活动
Origin adapter 1.1.0 起随包提供固定真机 QA harness但暂不接入 Node UI。在专用测试节点确认没有活动 Job 后,从 PowerShell 使用 Origin 卡片已安装的受管解释器运行:`<data_root>\runtimes\origin\Scripts\python.exe <node_program>\adapters\origin.plot@v2\acceptance.py --work-root <new_empty_parent>\origin-acceptance-YYYYMMDD`。`--work-root` 必须指向尚不存在的新目录adapter 1.3.0 默认依次执行 annotations、recipe_2x2、heatmap、surface_3d、stacked、stacked_line、violin、band也可重复传入 `--case <name>` 只跑指定用例。每个用例先通过生产 Worker 子进程生成 OPJU/PNG/SVG/PDF再由独立 Origin 会话重开工程,核对图层、系列、工作表/矩阵和数值 oracle复导三种图件并等待 Origin 进程退出;必要时可用 `--release-wait <seconds>` 调整释放等待时间。最终报告位于 `<work-root>\acceptance-report.json`。输入均为脚本生成的合成数据,但 PNG/SVG/PDF 仍需人工完成视觉 gate 后才能判定目标机器通过。
`origin.analysis@v1` 是与绘图分离的无状态科研分析入口,首批 operation 为 `data_check`、`normalize`、`smooth`、`differentiate`、`integrate`、`linear_fit`。请求只绑定一个 CSV/XLSX/JSON artifact`data_check` 可只检查整表外,其余操作必须显式提供 `input/x/y`。数据体检通过 `parameters.fail_on` 指定需要阻断后续流程的问题,命中时 Job 仍成功并在 `diagnostics.json` 返回 `passed=false`;调用方应读取诊断后再决定是否提交分析。归一化的 `max_abs` 使用最大绝对值,`area` 使用有符号梯形积分,`reference` 在数据范围内按 X 线性插值;平滑固定为完整奇数窗口的局部多项式,求导支持一/二阶且接受非等间距 X积分输出累计值及可选 `from/to` 区间值线性拟合输出参数、95% t 置信区间、R²、RMSE、拟合值和残差并在 OPJU 内保留 Origin 原生线性拟合报告。所有数值操作拒绝缺失、非有限、重复或非严格递增的 X/Y 数据,不做隐式排序或删行。
分析任务固定发布 `analysis.opju`、`result-table.csv` 和 `diagnostics.json`,隐藏保存完整 analysis spec 与 provenance请求 `outputs=[{"key":"result_workbook","type":"table","format":"xlsx"}]` 时再增加 XLSX。CSV/XLSX 可登记为后续 `origin.plot@v2` Job 输入,但分析与绘图不合并成同一个请求。`origin.analysis@v1` 与 `origin.plot@v2` 共享 Origin 软件位置及 `runtimes\origin`,不会在 UI 生成第二张软件卡;完整 Node 包会同时携带两份 adapter。只更新分析 adapter 时运行 `windows-node\package-origin-analysis-adapter.bat`,退出 Node 后整体替换 `adapters\origin.analysis@v1\` 再启动;该独立包假定目标节点已经安装 Origin 受管 runtime。
分析 adapter 自带六个固定真机用例。运行:`<data_root>\runtimes\origin\Scripts\python.exe <node_program>\adapters\origin.analysis@v1\acceptance.py --work-root <new_empty_parent>\origin-analysis-acceptance-YYYYMMDD --release-wait 180`。可用 `--case <name>` 逐例执行;每例通过生产 Worker 生成标准产物,再用独立 Origin 会话重开 OPJU核对必需工作表、结果摘要和数值 oracle并等待新 Origin 进程完全退出。目标测试机观察到 COM 退出可能接近 2 分钟,因此整套六例需预留较长命令时间;查询进程列表的瞬时失败会固定重试三次。最终以 `acceptance-report.json``passed=true`、各 case 的 `oracle_validation.passed=true``origin_processes_released=true` 共同作为通过条件。
Blender adapter 可独立运行 `windows-node\package-blender-adapter.bat` 打成 `dist\blender.scene.author@v3-adapter.zip`,替换规则与 Origin 相同。升级时先从托盘退出 Node删除程序目录中的 `adapters\blender.scene.author@v1\`、`adapters\blender.scene.author@v2\`,整体放入 v3 目录后重启;数据根目录中的旧 Workspace 不在清理范围。Blender 的 `requirements.txt` 当前不安装第三方包,`bpy` 不安装到轻量启动 runtime。
注册配置写入 `<data_root>\node.json`Token 使用 DPAPI `LocalMachine` 加密ACL 仅允许注册账号和 `SYSTEM`。应始终用同一专用 Windows 账号配置、注册并运行 Node。当前 MVP 可由 UI 写入该账号的登录启动项,不安装 Windows Service。

View File

@ -0,0 +1,298 @@
{
"capability": "origin.analysis@v1",
"display_name": "Origin 科研分析",
"default_enrollment": false,
"output_namespace": "origin_analysis",
"input_policy": {
"suffixes": [".csv", ".xlsx", ".json"],
"max_count": 1,
"max_bytes": 104857600,
"max_total_bytes": 104857600
},
"outputs": {
"project": {
"filename": "analysis.opju",
"media_type": "application/x-origin-project",
"relative_path": "analysis.opju",
"publish": true,
"required": true
},
"result_table": {
"filename": "result-table.csv",
"media_type": "text/csv",
"relative_path": "result-table.csv",
"publish": true,
"required": true
},
"result_workbook": {
"filename": "result-table.xlsx",
"media_type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"relative_path": "result-table.xlsx",
"publish": true,
"required": false
},
"diagnostics": {
"filename": "diagnostics.json",
"media_type": "application/json",
"relative_path": "diagnostics.json",
"publish": true,
"required": true
},
"analysis_spec": {
"filename": "analysis-spec.json",
"media_type": "application/json",
"relative_path": ".meta/analysis-spec.json",
"publish": false,
"required": true
},
"provenance": {
"filename": "provenance.json",
"media_type": "application/json",
"relative_path": ".meta/provenance.json",
"publish": false,
"required": true
}
},
"feature_path": ["operation", "analysis", "type"],
"features": {
"data_check": "0.1.0",
"normalize": "0.1.0",
"smooth": "0.1.0",
"differentiate": "0.1.0",
"integrate": "0.1.0",
"linear_fit": "0.1.0"
},
"summary": {
"title_path": ["operation", "analysis", "title"]
},
"legacy_runtime": null,
"workspace": null,
"request_schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"x-maxBytes": 65536,
"required": ["schema_version", "inputs", "operation", "outputs"],
"additionalProperties": false,
"properties": {
"schema_version": {"const": 1},
"inputs": {
"type": "array",
"minItems": 1,
"maxItems": 1,
"items": {
"type": "object",
"required": ["key", "artifact_id"],
"additionalProperties": false,
"properties": {
"key": {"type": "string", "pattern": "^[a-z][a-z0-9_]{0,31}$"},
"artifact_id": {"type": "string", "format": "uuid"},
"selector": {
"type": "object",
"required": ["sheet"],
"additionalProperties": false,
"properties": {
"sheet": {"type": "string", "minLength": 1, "maxLength": 128}
}
}
}
}
},
"operation": {
"type": "object",
"required": ["analysis"],
"additionalProperties": false,
"properties": {
"analysis": {
"type": "object",
"required": ["type", "title", "data", "parameters"],
"additionalProperties": false,
"properties": {
"type": {
"enum": [
"data_check",
"normalize",
"smooth",
"differentiate",
"integrate",
"linear_fit"
]
},
"title": {"type": "string", "minLength": 1, "maxLength": 200},
"data": {"$ref": "#/$defs/data_binding"},
"parameters": {"type": "object"}
},
"allOf": [
{
"if": {"properties": {"type": {"const": "data_check"}}},
"then": {
"properties": {
"parameters": {"$ref": "#/$defs/data_check_parameters"}
}
}
},
{
"if": {"properties": {"type": {"const": "normalize"}}},
"then": {
"properties": {
"data": {"$ref": "#/$defs/xy_binding"},
"parameters": {"$ref": "#/$defs/normalize_parameters"}
}
}
},
{
"if": {"properties": {"type": {"const": "smooth"}}},
"then": {
"properties": {
"data": {"$ref": "#/$defs/xy_binding"},
"parameters": {"$ref": "#/$defs/smooth_parameters"}
}
}
},
{
"if": {"properties": {"type": {"const": "differentiate"}}},
"then": {
"properties": {
"data": {"$ref": "#/$defs/xy_binding"},
"parameters": {"$ref": "#/$defs/differentiate_parameters"}
}
}
},
{
"if": {"properties": {"type": {"const": "integrate"}}},
"then": {
"properties": {
"data": {"$ref": "#/$defs/xy_binding"},
"parameters": {"$ref": "#/$defs/integrate_parameters"}
}
}
},
{
"if": {"properties": {"type": {"const": "linear_fit"}}},
"then": {
"properties": {
"data": {"$ref": "#/$defs/xy_binding"},
"parameters": {"$ref": "#/$defs/linear_fit_parameters"}
}
}
}
]
}
}
},
"outputs": {
"type": "array",
"maxItems": 1,
"uniqueItems": true,
"items": {
"type": "object",
"required": ["key", "type", "format"],
"additionalProperties": false,
"properties": {
"key": {"const": "result_workbook"},
"type": {"const": "table"},
"format": {"const": "xlsx"}
}
}
}
},
"$defs": {
"data_binding": {
"type": "object",
"required": ["input"],
"additionalProperties": false,
"properties": {
"input": {"type": "string", "pattern": "^[a-z][a-z0-9_]{0,31}$"},
"x": {"type": "string", "minLength": 1, "maxLength": 128},
"y": {"type": "string", "minLength": 1, "maxLength": 128}
}
},
"xy_binding": {
"type": "object",
"required": ["input", "x", "y"],
"additionalProperties": false,
"properties": {
"input": {"type": "string", "pattern": "^[a-z][a-z0-9_]{0,31}$"},
"x": {"type": "string", "minLength": 1, "maxLength": 128},
"y": {"type": "string", "minLength": 1, "maxLength": 128}
}
},
"data_check_parameters": {
"type": "object",
"description": "数据体检始终返回诊断产物;命中 fail_on 时 diagnostics.passed=false但 Job 本身成功完成,供调用方决定是否继续分析。",
"required": ["fail_on"],
"additionalProperties": false,
"properties": {
"fail_on": {
"type": "array",
"uniqueItems": true,
"items": {
"enum": [
"missing",
"non_finite",
"duplicate_x",
"non_monotonic_x",
"uneven_spacing"
]
}
}
}
},
"normalize_parameters": {
"type": "object",
"description": "max_abs 使用最大绝对值area 使用有符号梯形积分reference 在数据范围内按 X 线性插值得到归一化除数。",
"required": ["method"],
"additionalProperties": false,
"properties": {
"method": {"enum": ["max_abs", "area", "reference"]},
"reference_x": {"type": "number"}
},
"allOf": [
{
"if": {"properties": {"method": {"const": "reference"}}},
"then": {"required": ["reference_x"]},
"else": {"not": {"required": ["reference_x"]}}
}
]
},
"smooth_parameters": {
"type": "object",
"description": "固定局部多项式平滑;窗口必须为奇数,边界使用平移后的完整窗口,不补造数据点。",
"required": ["method", "window", "polynomial_order"],
"additionalProperties": false,
"properties": {
"method": {"const": "savitzky_golay"},
"window": {"type": "integer", "minimum": 5, "maximum": 101},
"polynomial_order": {"type": "integer", "minimum": 2, "maximum": 5}
}
},
"differentiate_parameters": {
"type": "object",
"description": "使用支持非等间距 X 的二阶边界数值梯度,按 order 重复求导。",
"required": ["order"],
"additionalProperties": false,
"properties": {
"order": {"type": "integer", "enum": [1, 2]}
}
},
"integrate_parameters": {
"type": "object",
"description": "使用梯形积分输出从首个 X 开始的累计积分from/to 省略时报告完整范围积分。",
"additionalProperties": false,
"properties": {
"from": {"type": "number"},
"to": {"type": "number"}
}
},
"linear_fit_parameters": {
"type": "object",
"description": "固定最小二乘线性拟合并输出 95% t 置信区间、R²、RMSE 和残差;同时在 OPJU 中生成 Origin 原生线性拟合报告。",
"required": ["include_intercept", "confidence_level"],
"additionalProperties": false,
"properties": {
"include_intercept": {"type": "boolean"},
"confidence_level": {"const": 0.95}
}
}
}
}
}

View File

@ -0,0 +1,309 @@
from __future__ import annotations
import importlib.util
import json
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
import numpy as np
from core.software_contracts import get_contract
ROOT = Path(__file__).resolve().parents[1]
ADAPTER_ROOT = ROOT / "windows-node" / "adapters" / "origin.analysis@v1"
WORKER_PATH = ADAPTER_ROOT / "worker.py"
SPEC = importlib.util.spec_from_file_location("zcbot_origin_analysis_worker", WORKER_PATH)
assert SPEC and SPEC.loader
worker = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(worker)
ACCEPTANCE_PATH = ADAPTER_ROOT / "acceptance.py"
ACCEPTANCE_SPEC = importlib.util.spec_from_file_location(
"zcbot_origin_analysis_acceptance", ACCEPTANCE_PATH
)
assert ACCEPTANCE_SPEC and ACCEPTANCE_SPEC.loader
acceptance = importlib.util.module_from_spec(ACCEPTANCE_SPEC)
ACCEPTANCE_SPEC.loader.exec_module(acceptance)
def _request(operation: str, parameters: dict, *, data: dict | None = None) -> dict:
return {
"schema_version": 1,
"inputs": [
{
"key": "sample",
"artifact_id": "f4186347-65cc-4f07-9c26-bf11992beef8",
}
],
"operation": {
"analysis": {
"type": operation,
"title": f"Test {operation}",
"data": data or {"input": "sample", "x": "x", "y": "y"},
"parameters": parameters,
}
},
"outputs": [],
}
class OriginAnalysisContractTests(unittest.TestCase):
def test_contract_exposes_six_guarded_operations_and_fixed_outputs(self) -> None:
contract = get_contract("origin.analysis@v1")
self.assertFalse(contract.default_enrollment)
self.assertEqual(
set(contract.features),
{
"data_check",
"normalize",
"smooth",
"differentiate",
"integrate",
"linear_fit",
},
)
request = _request("normalize", {"method": "max_abs"})
normalized, digest = contract.normalize_request(request)
self.assertEqual(normalized, request)
self.assertEqual(len(digest), 64)
self.assertEqual(
set(contract.expected_outputs(request)),
{"project", "result_table", "diagnostics", "analysis_spec", "provenance"},
)
workbook_request = json.loads(json.dumps(request))
workbook_request["outputs"] = [
{"key": "result_workbook", "type": "table", "format": "xlsx"}
]
normalized, _ = contract.normalize_request(workbook_request)
self.assertEqual(normalized, workbook_request)
self.assertIn("result_workbook", contract.expected_outputs(workbook_request))
def test_contract_rejects_scripts_and_operation_specific_parameter_drift(self) -> None:
contract = get_contract("origin.analysis@v1")
request = _request("smooth", {
"method": "savitzky_golay",
"window": 7,
"polynomial_order": 3,
})
contract.normalize_request(request)
request["operation"]["analysis"]["parameters"]["script"] = "anything"
with self.assertRaises(ValueError):
contract.normalize_request(request)
reference = _request("normalize", {"method": "reference"})
with self.assertRaises(ValueError):
contract.normalize_request(reference)
def test_worker_and_manifest_versions_match(self) -> None:
manifest = json.loads((ADAPTER_ROOT / "adapter.json").read_text(encoding="utf-8"))
self.assertEqual(worker.ADAPTER_VERSION, manifest["adapter_version"])
class OriginAnalysisNumericalTests(unittest.TestCase):
def test_data_check_profiles_columns_and_reports_x_issues(self) -> None:
headers = ["x", "y", "label"]
rows = [[1, 2, "a"], [1, None, "b"], [3.5, float("inf"), "c"]]
spec = _request(
"data_check",
{"fail_on": ["missing", "non_finite", "duplicate_x", "uneven_spacing"]},
)["operation"]["analysis"]
result_headers, result_rows, diagnostics = worker.analyze(headers, rows, spec)
self.assertEqual(result_headers[0], "column")
self.assertEqual(len(result_rows), 3)
self.assertFalse(diagnostics["passed"])
self.assertEqual(
set(diagnostics["failed_checks"]),
{"missing", "non_finite", "duplicate_x"},
)
def test_normalize_supports_max_area_and_interpolated_reference(self) -> None:
x = np.array([0.0, 1.0, 2.0])
y = np.array([0.0, 2.0, 4.0])
_, rows, diagnostics = worker._normalize(x, y, {"method": "max_abs"})
self.assertEqual([item[2] for item in rows], [0.0, 0.5, 1.0])
self.assertEqual(diagnostics["divisor"], 4.0)
_, _, diagnostics = worker._normalize(x, y, {"method": "area"})
self.assertEqual(diagnostics["divisor"], 4.0)
_, rows, diagnostics = worker._normalize(
x, y, {"method": "reference", "reference_x": 1.5}
)
self.assertEqual(diagnostics["divisor"], 3.0)
self.assertAlmostEqual(rows[-1][2], 4 / 3)
def test_smooth_reproduces_quadratic_and_rejects_even_window(self) -> None:
x = np.arange(7, dtype=float)
y = x**2 + 2 * x + 3
_, rows, diagnostics = worker._smooth(
x,
y,
{"method": "savitzky_golay", "window": 5, "polynomial_order": 2},
)
np.testing.assert_allclose([item[2] for item in rows], y, atol=1e-10)
self.assertAlmostEqual(diagnostics["rmse"], 0.0, places=10)
with self.assertRaisesRegex(ValueError, "SMOOTH_WINDOW_MUST_BE_ODD"):
worker._smooth(
x,
y,
{"method": "savitzky_golay", "window": 6, "polynomial_order": 2},
)
def test_differentiate_and_integrate_match_polynomial_oracles(self) -> None:
x = np.arange(5, dtype=float)
y = x**2
_, derivative_rows, _ = worker._differentiate(x, y, {"order": 1})
np.testing.assert_allclose([item[2] for item in derivative_rows], 2 * x)
_, integral_rows, diagnostics = worker._integrate(x, 2 * x, {})
np.testing.assert_allclose([item[2] for item in integral_rows], x**2)
self.assertEqual(diagnostics["full_integral"], 16.0)
_, _, diagnostics = worker._integrate(x, 2 * x, {"from": 1.0, "to": 3.0})
self.assertEqual(diagnostics["integral"], 8.0)
def test_linear_fit_returns_parameters_intervals_and_residuals(self) -> None:
x = np.arange(1, 7, dtype=float)
y = 2.5 * x - 1.25
headers, rows, diagnostics = worker._linear_fit(
x,
y,
{"include_intercept": True, "confidence_level": 0.95},
)
self.assertEqual(headers, ["x", "y", "fitted_y", "residual"])
self.assertAlmostEqual(diagnostics["slope"], 2.5)
self.assertAlmostEqual(diagnostics["intercept"], -1.25)
self.assertAlmostEqual(diagnostics["r_squared"], 1.0)
np.testing.assert_allclose([item[3] for item in rows], 0, atol=1e-12)
self.assertLessEqual(
diagnostics["slope_confidence_interval"][0], diagnostics["slope"]
)
self.assertGreaterEqual(
diagnostics["slope_confidence_interval"][1], diagnostics["slope"]
)
def test_xy_values_require_finite_strictly_increasing_x(self) -> None:
binding = {"input": "sample", "x": "x", "y": "y"}
with self.assertRaisesRegex(ValueError, "X_VALUES_DUPLICATED"):
worker._xy_values(["x", "y"], [[1, 2], [1, 3]], binding)
with self.assertRaisesRegex(ValueError, "Y_VALUE_NOT_FINITE"):
worker._xy_values(["x", "y"], [[1, 2], [2, float("nan")]], binding)
def test_csv_json_and_xlsx_outputs_are_deterministically_readable(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
csv_path = root / "input.csv"
csv_path.write_text("x,y\n1,2\n3,4\n", encoding="utf-8")
self.assertEqual(
worker._read_rows(csv_path, None),
(["x", "y"], [["1", "2"], ["3", "4"]]),
)
json_path = root / "input.json"
json_path.write_text(
json.dumps([{"x": 1, "y": 2}, {"x": 3, "y": 4}]),
encoding="utf-8",
)
self.assertEqual(
worker._read_rows(json_path, None), (["x", "y"], [[1, 2], [3, 4]])
)
result_path = root / "result-table.csv"
worker._atomic_csv(result_path, ["x", "y"], [[1, 2], [3, 4]])
self.assertTrue(result_path.read_bytes().startswith(b"\xef\xbb\xbf"))
workbook_path = root / "result-table.xlsx"
worker._atomic_xlsx(workbook_path, ["x", "y"], [[1, 2], [3, 4]])
self.assertTrue(workbook_path.is_file())
def test_run_builds_fixed_manifest_without_origin_side_effects_in_request(self) -> None:
with tempfile.TemporaryDirectory() as directory:
job_dir = Path(directory)
(job_dir / "request").mkdir()
(job_dir / "input" / "sample").mkdir(parents=True)
(job_dir / "input" / "sample" / "sample.csv").write_text(
"x,y\n0,0\n1,2\n2,4\n", encoding="utf-8"
)
request = _request("normalize", {"method": "max_abs"})
record = {
"job_id": "job",
"lease_id": "lease",
"request_digest": "a" * 64,
"request": request,
}
worker._atomic_json(job_dir / "request" / "request.json", record)
def fake_project(path, *_args):
path.write_bytes(b"origin-project" * 16)
with patch.object(worker, "_write_origin_project", side_effect=fake_project):
artifacts = worker.run(job_dir)
self.assertEqual(
{item["artifact_id"] for item in artifacts},
{"project", "result_table", "diagnostics", "analysis_spec", "provenance"},
)
diagnostics = json.loads(
(job_dir / "output" / "diagnostics.json").read_text(encoding="utf-8")
)
self.assertEqual(diagnostics["divisor"], 4.0)
provenance = json.loads(
(job_dir / "output" / "provenance.json").read_text(encoding="utf-8")
)
self.assertEqual(provenance["request_digest"], "a" * 64)
class OriginAnalysisAcceptanceTests(unittest.TestCase):
def test_fixed_acceptance_cases_are_contract_valid(self) -> None:
contract = get_contract("origin.analysis@v1")
cases = acceptance._cases()
self.assertEqual(
list(cases),
[
"data_check",
"normalize",
"smooth",
"differentiate",
"integrate",
"linear_fit",
],
)
for name, case in cases.items():
normalized, digest = contract.normalize_request(case["request"])
self.assertEqual(normalized, case["request"], name)
self.assertEqual(len(digest), 64)
def test_acceptance_oracles_are_independently_recomputed(self) -> None:
for name, case in acceptance._cases().items():
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "sample.csv"
path.write_text(case["csv"], encoding="utf-8", newline="")
headers, rows = worker._read_rows(path, None)
result_headers, result_rows, diagnostics = worker.analyze(
headers, rows, case["request"]["operation"]["analysis"]
)
validation = acceptance._validate_oracle(
name,
diagnostics,
result_headers,
result_rows,
case["oracle"],
)
self.assertTrue(validation["passed"], name)
def test_stage_writes_keyed_input_and_stable_request_digest(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
case = acceptance._cases()["linear_fit"]
job_dir = acceptance._stage(root, "linear_fit", case)
record = json.loads(
(job_dir / "request" / "request.json").read_text(encoding="utf-8")
)
_, digest = acceptance._canonical_request(case["request"])
self.assertEqual(record["request_digest"], digest)
self.assertTrue((job_dir / "input" / "sample" / "sample.csv").is_file())
if __name__ == "__main__":
unittest.main()

View File

@ -82,6 +82,7 @@ class SoftwareNodeSecurityTests(unittest.TestCase):
self.assertEqual(
set(enrollment.allowed_capabilities),
{
"origin.analysis@v1",
"origin.plot@v2",
"ansys.geometry.inspect@v1",
"ansys.mechanical.static_structural@v2",

View File

@ -304,6 +304,8 @@ class WindowsNodeSourceTests(unittest.TestCase):
self.assertIn("adapters\\%(RecursiveDir)%(Filename)%(Extension)", project)
self.assertIn("..\\..\\software-contracts\\origin.plot.v2.json", project)
self.assertIn("adapters\\origin.plot@v2\\origin.plot.v2.json", project)
self.assertIn("..\\..\\software-contracts\\origin.analysis.v1.json", project)
self.assertIn("adapters\\origin.analysis@v1\\origin.analysis.v1.json", project)
self.assertIn(
"..\\..\\software-contracts\\ansys.geometry.inspect.v1.json",
project,
@ -541,6 +543,24 @@ class WindowsNodeSourceTests(unittest.TestCase):
tray,
)
def test_origin_analysis_adapter_can_be_packaged_without_building_node(self) -> None:
script = (ROOT / "package-origin-analysis-adapter.bat").read_text(encoding="utf-8")
self.assertIn('"adapters\\origin.analysis@v1\\adapter.json"', script)
self.assertIn('"adapters\\origin.analysis@v1\\worker.py"', script)
self.assertIn('"adapters\\origin.analysis@v1\\acceptance.py"', script)
self.assertIn('"..\\software-contracts\\origin.analysis.v1.json"', script)
self.assertNotIn("dotnet", script.lower())
worker = (
ROOT / "adapters" / "origin.analysis@v1" / "worker.py"
).read_text(encoding="utf-8")
for forbidden in ("eval(", "exec(", "os.system"):
self.assertNotIn(forbidden, worker)
self.assertIn("op.LinearFit()", worker)
self.assertIn("op.save", worker)
self.assertIn('if op.oext:', worker)
self.assertIn("op.exit()", worker)
def test_blender_adapter_can_be_packaged_without_building_node(self) -> None:
script = (ROOT / "package-blender-adapter.bat").read_text(encoding="utf-8")
self.assertIn("adapters\\blender.scene.author@v3", script)

View File

@ -25,6 +25,11 @@
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
</Content>
<Content Include="..\..\software-contracts\origin.analysis.v1.json">
<Link>adapters\origin.analysis@v1\origin.analysis.v1.json</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
</Content>
<Content Include="..\..\software-contracts\ansys.geometry.inspect.v1.json">
<Link>adapters\ansys.geometry.inspect@v1\ansys.geometry.inspect.v1.json</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>

View File

@ -0,0 +1,397 @@
"""Run fixed origin.analysis@v1 acceptance cases on a dedicated Windows node."""
from __future__ import annotations
import argparse
import csv
import hashlib
import importlib.util
import json
import locale
import os
import platform
import re
import subprocess
import sys
import time
from datetime import datetime, timezone
from importlib.metadata import PackageNotFoundError, version
from pathlib import Path
from typing import Any
from uuid import NAMESPACE_URL, uuid5
ADAPTER_DIR = Path(__file__).resolve().parent
WORKER_PATH = ADAPTER_DIR / "worker.py"
_WORKER_SPEC = importlib.util.spec_from_file_location(
"zcbot_origin_analysis_acceptance_worker", WORKER_PATH
)
if _WORKER_SPEC is None or _WORKER_SPEC.loader is None:
raise RuntimeError("ORIGIN_ANALYSIS_WORKER_IMPORT_FAILED")
worker = importlib.util.module_from_spec(_WORKER_SPEC)
_WORKER_SPEC.loader.exec_module(worker)
REPORT_SCHEMA_VERSION = 1
ORIGIN_PROCESS_PATTERN = r"^origin(?:\d+)?(?:_?\d+)?(?:64)?\.exe$"
def _artifact_id(case_name: str) -> str:
return str(uuid5(NAMESPACE_URL, f"zcbot-origin-analysis-acceptance:{case_name}"))
def _request(case_name: str, operation: str, parameters: dict[str, Any]) -> dict[str, Any]:
return {
"schema_version": 1,
"inputs": [{"key": "sample", "artifact_id": _artifact_id(case_name)}],
"operation": {
"analysis": {
"type": operation,
"title": f"Origin analysis acceptance: {operation}",
"data": {"input": "sample", "x": "x", "y": "y"},
"parameters": parameters,
}
},
"outputs": (
[{"key": "result_workbook", "type": "table", "format": "xlsx"}]
if operation == "linear_fit"
else []
),
}
def _cases() -> dict[str, dict[str, Any]]:
data_check = _request(
"data_check",
"data_check",
{"fail_on": ["missing", "non_finite", "duplicate_x", "uneven_spacing"]},
)
return {
"data_check": {
"csv": "x,y\n0,1\n1,\n1,inf\n3,4\n",
"request": data_check,
"oracle": {
"passed": False,
"failed_checks": ["duplicate_x", "missing", "non_finite", "uneven_spacing"],
},
},
"normalize": {
"csv": "x,y\n0,0\n1,2\n2,4\n",
"request": _request(
"normalize", "normalize", {"method": "reference", "reference_x": 1.5}
),
"oracle": {"divisor": 3.0, "last_value": 4 / 3},
},
"smooth": {
"csv": "x,y\n" + "\n".join(f"{x},{x*x + 2*x + 3}" for x in range(7)) + "\n",
"request": _request(
"smooth",
"smooth",
{"method": "savitzky_golay", "window": 5, "polynomial_order": 2},
),
"oracle": {"rmse": 0.0},
},
"differentiate": {
"csv": "x,y\n" + "\n".join(f"{x},{x*x}" for x in range(5)) + "\n",
"request": _request("differentiate", "differentiate", {"order": 1}),
"oracle": {"derived": [0.0, 2.0, 4.0, 6.0, 8.0]},
},
"integrate": {
"csv": "x,y\n" + "\n".join(f"{x},{2*x}" for x in range(5)) + "\n",
"request": _request("integrate", "integrate", {"from": 1.0, "to": 3.0}),
"oracle": {"integral": 8.0, "full_integral": 16.0},
},
"linear_fit": {
"csv": "x,y\n" + "\n".join(f"{x},{2.5*x - 1.25}" for x in range(1, 7)) + "\n",
"request": _request(
"linear_fit",
"linear_fit",
{"include_intercept": True, "confidence_level": 0.95},
),
"oracle": {"slope": 2.5, "intercept": -1.25, "r_squared": 1.0},
},
}
def _canonical_request(request: dict[str, Any]) -> tuple[str, str]:
encoded = json.dumps(request, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
return encoded, hashlib.sha256(encoded.encode("utf-8")).hexdigest()
def _stage(root: Path, case_name: str, case: dict[str, Any]) -> Path:
job_dir = root / case_name
request_dir = job_dir / "request"
input_dir = job_dir / "input" / "sample"
request_dir.mkdir(parents=True)
input_dir.mkdir(parents=True)
(input_dir / "sample.csv").write_text(case["csv"], encoding="utf-8", newline="")
_, digest = _canonical_request(case["request"])
record = {
"job_id": str(uuid5(NAMESPACE_URL, f"zcbot-origin-analysis-job:{case_name}")),
"lease_id": str(uuid5(NAMESPACE_URL, f"zcbot-origin-analysis-lease:{case_name}")),
"request_digest": digest,
"request": case["request"],
}
worker._atomic_json(request_dir / "request.json", record)
return job_dir
def _read_result(path: Path) -> tuple[list[str], list[list[float | str | None]]]:
with path.open("r", encoding="utf-8-sig", newline="") as handle:
rows = list(csv.reader(handle))
converted: list[list[float | str | None]] = []
for row in rows[1:]:
values: list[float | str | None] = []
for value in row:
if value == "":
values.append(None)
continue
try:
values.append(float(value))
except ValueError:
values.append(value)
converted.append(values)
return rows[0], converted
def _assert_close(actual: float, expected: float, tolerance: float = 1e-9) -> None:
if abs(float(actual) - float(expected)) > tolerance:
raise RuntimeError(f"NUMERICAL_ORACLE_MISMATCH:{actual}:{expected}")
def _validate_oracle(
case_name: str,
diagnostics: dict[str, Any],
result_headers: list[str],
result_rows: list[list[Any]],
oracle: dict[str, Any],
) -> dict[str, Any]:
if case_name == "data_check":
if diagnostics["passed"] is not oracle["passed"]:
raise RuntimeError("DATA_CHECK_PASS_STATE_MISMATCH")
if diagnostics["failed_checks"] != oracle["failed_checks"]:
raise RuntimeError("DATA_CHECK_CODES_MISMATCH")
elif case_name == "normalize":
_assert_close(diagnostics["divisor"], oracle["divisor"])
_assert_close(result_rows[-1][result_headers.index("normalized_y")], oracle["last_value"])
elif case_name == "smooth":
_assert_close(diagnostics["rmse"], oracle["rmse"], 1e-10)
elif case_name == "differentiate":
values = [row[result_headers.index("derivative_order_1")] for row in result_rows]
for actual, expected in zip(values, oracle["derived"], strict=True):
_assert_close(actual, expected)
elif case_name == "integrate":
_assert_close(diagnostics["integral"], oracle["integral"])
_assert_close(diagnostics["full_integral"], oracle["full_integral"])
elif case_name == "linear_fit":
for name in ("slope", "intercept", "r_squared"):
_assert_close(diagnostics[name], oracle[name])
return {"passed": True, "oracle": oracle}
def _validate_manifest(job_dir: Path, case: dict[str, Any]) -> dict[str, Any]:
terminal = json.loads((job_dir / "terminal.json").read_text(encoding="utf-8"))
if terminal.get("status") != "succeeded":
raise RuntimeError(f"WORKER_FAILED:{terminal.get('error')}")
expected = {"project", "result_table", "diagnostics", "analysis_spec", "provenance"}
if case["request"]["outputs"]:
expected.add("result_workbook")
manifest = terminal.get("artifact_manifest") or []
by_id = {item["artifact_id"]: item for item in manifest}
if set(by_id) != expected:
raise RuntimeError(f"ARTIFACT_MANIFEST_MISMATCH:{sorted(by_id)}")
output = job_dir / "output"
paths = {artifact_id: output / item["filename"] for artifact_id, item in by_id.items()}
for artifact_id, path in paths.items():
if not path.is_file() or path.stat().st_size != by_id[artifact_id]["size_bytes"]:
raise RuntimeError(f"ARTIFACT_INVALID:{artifact_id}")
if worker._file_sha256(path) != by_id[artifact_id]["sha256"]:
raise RuntimeError(f"ARTIFACT_DIGEST_MISMATCH:{artifact_id}")
diagnostics = json.loads(paths["diagnostics"].read_text(encoding="utf-8"))
headers, rows = _read_result(paths["result_table"])
oracle = _validate_oracle(case["request"]["operation"]["analysis"]["type"], diagnostics, headers, rows, case["oracle"])
return {
"artifacts": {
key: {"size_bytes": path.stat().st_size, "sha256": worker._file_sha256(path)}
for key, path in sorted(paths.items())
},
"oracle_validation": oracle,
}
def _reopen_project(job_dir: Path) -> dict[str, Any]:
import originpro as op
project = job_dir / "output" / "analysis.opju"
op.set_show(False)
try:
if not op.open(str(project), readonly=True):
raise RuntimeError("OPJU_REOPEN_FAILED")
books = list(op.pages("w"))
sheets = [sheet for book in books for sheet in book]
names = [
str(sheet.lname or getattr(book, "lname", "") or getattr(book, "name", "") or "")
for book in books
for sheet in book
]
for required in ("RawData", "Result", "Diagnostics"):
if required not in names:
raise RuntimeError(f"OPJU_SHEET_MISSING:{required}")
return {"worksheet_count": len(sheets), "worksheet_names": names}
finally:
if op.oext:
op.exit()
def _environment_fingerprint() -> dict[str, Any]:
try:
originpro_version = version("originpro")
except PackageNotFoundError:
originpro_version = "unknown"
return {
"adapter_version": worker.ADAPTER_VERSION,
"algorithm_version": worker.ALGORITHM_VERSION,
"originpro_version": originpro_version,
"numpy_version": worker.np.__version__,
"python_version": platform.python_version(),
"platform": platform.platform(),
"locale": locale.getlocale(),
"execution_mode": "hidden",
"configured_origin_executable": bool(os.environ.get("ZCBOT_ORIGIN_EXE")),
}
def _origin_processes() -> dict[int, str]:
completed = None
for attempt in range(3):
completed = subprocess.run(
["tasklist.exe", "/fo", "csv", "/nh"],
check=False,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
)
if completed.returncode == 0:
break
if attempt < 2:
time.sleep(1)
if completed is None or completed.returncode != 0:
detail = completed.stderr[-500:] if completed is not None else "not started"
raise RuntimeError(f"TASKLIST_FAILED:{detail}")
result: dict[int, str] = {}
for row in csv.reader(completed.stdout.splitlines()):
name = row[0].casefold() if row else ""
if len(row) >= 2 and re.fullmatch(ORIGIN_PROCESS_PATTERN, name):
result[int(row[1])] = row[0]
return result
def _wait_for_origin_release(baseline: dict[int, str], timeout_seconds: int) -> None:
deadline = time.monotonic() + timeout_seconds
while True:
remaining = {
pid: name for pid, name in _origin_processes().items() if pid not in baseline
}
if not remaining:
return
if time.monotonic() >= deadline:
raise RuntimeError(f"ORIGIN_PROCESS_REMAINS:{remaining}")
time.sleep(2)
def _run_case(
root: Path,
case_name: str,
case: dict[str, Any],
baseline: dict[int, str],
release_wait: int,
) -> dict[str, Any]:
job_dir = _stage(root, case_name, case)
started = time.monotonic()
completed = subprocess.run(
[sys.executable, str(WORKER_PATH), str(job_dir)],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
check=False,
)
if completed.returncode != 0:
raise RuntimeError(f"WORKER_PROCESS_FAILED:{completed.stderr[-500:]}")
validation = _validate_manifest(job_dir, case)
reopened = _reopen_project(job_dir)
_wait_for_origin_release(baseline, release_wait)
_, digest = _canonical_request(case["request"])
return {
"case": case_name,
"elapsed_seconds": round(time.monotonic() - started, 3),
"request_digest": digest,
"validation": validation,
"reopen": reopened,
"origin_processes_released": True,
}
def run_suite(
root: Path,
selected: list[str] | None = None,
*,
release_wait: int = 60,
) -> dict[str, Any]:
cases = _cases()
names = selected or list(cases)
unknown = sorted(set(names) - cases.keys())
if unknown:
raise ValueError(f"UNKNOWN_ACCEPTANCE_CASES:{','.join(unknown)}")
root.mkdir(parents=True, exist_ok=False)
baseline = _origin_processes()
report = {
"schema_version": REPORT_SCHEMA_VERSION,
"started_at": datetime.now(timezone.utc).isoformat(),
"environment": _environment_fingerprint(),
"selected_cases": names,
"cases": [],
"passed": False,
}
report_path = root / "acceptance-report.json"
try:
for index, name in enumerate(names, start=1):
report["cases"].append(
_run_case(root, name, cases[name], baseline, release_wait)
)
worker._atomic_json(report_path, report)
print(f"[OK] Origin analysis acceptance {index}/{len(names)}: {name}")
report["passed"] = True
return report
except Exception as exc:
report["failure"] = {
"type": type(exc).__name__,
"detail": str(exc)[:1000],
"completed_cases": len(report["cases"]),
}
raise
finally:
report["completed_at"] = datetime.now(timezone.utc).isoformat()
worker._atomic_json(report_path, report)
def main() -> int:
parser = argparse.ArgumentParser(description="Run fixed Origin analysis acceptance cases.")
parser.add_argument("--work-root", type=Path, required=True)
parser.add_argument("--release-wait", type=int, default=60)
parser.add_argument(
"--case", action="append", choices=tuple(_cases()), dest="cases"
)
args = parser.parse_args()
if sys.platform != "win32":
raise RuntimeError("Origin analysis acceptance requires Windows")
if args.release_wait < 1:
raise ValueError("release-wait must be positive")
root = args.work_root.resolve()
report = run_suite(root, args.cases, release_wait=args.release_wait)
print(f"[OK] Origin analysis acceptance passed. Report: {root / 'acceptance-report.json'}")
print(f"[INFO] Cases: {len(report['cases'])}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,10 @@
{
"capability": "origin.analysis@v1",
"adapter_version": "0.1.0",
"runtime": "python",
"runtime_id": "origin",
"entrypoint": "worker.py",
"contract": "origin.analysis.v1.json",
"worker_timeout_minutes": 30,
"running_detail": "Origin 正在执行科研分析"
}

View File

@ -0,0 +1,3 @@
originpro==1.1.15
openpyxl==3.1.5
numpy==2.2.6

View File

@ -0,0 +1,792 @@
"""Fixed Origin adapter for origin.analysis@v1.
The worker accepts one Node-created job directory. It performs only versioned,
declarative analysis operations, writes the raw data and results into an Origin
project, and never evaluates user code or accepts user-controlled paths.
"""
from __future__ import annotations
import csv
import hashlib
import json
import math
import os
import sys
from datetime import datetime, timezone
from importlib.metadata import PackageNotFoundError, version
from itertools import pairwise
from pathlib import Path
from typing import Any
import numpy as np
ADAPTER_VERSION = "0.1.0"
ALGORITHM_VERSION = "origin-analysis-mvp-1"
OPERATIONS = {
"data_check",
"normalize",
"smooth",
"differentiate",
"integrate",
"linear_fit",
}
OUTPUT_MEDIA = {
"analysis.opju": ("project", "application/x-origin-project"),
"result-table.csv": ("result_table", "text/csv"),
"result-table.xlsx": (
"result_workbook",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
),
"diagnostics.json": ("diagnostics", "application/json"),
"analysis-spec.json": ("analysis_spec", "application/json"),
"provenance.json": ("provenance", "application/json"),
}
def _probe() -> int:
health = "ready"
detail = "Origin COM 与科研分析运行时可用"
software_version = None
try:
if sys.platform != "win32":
raise RuntimeError("Origin analysis adapter requires Windows")
import winreg
import originpro
with winreg.OpenKey(winreg.HKEY_CLASSES_ROOT, r"Origin.ApplicationSI\CLSID"):
pass
originpro_version = version("originpro")
numpy_version = version("numpy")
detail = (
"Origin COM 与科研分析运行时可用"
f"originpro {originpro_version}numpy {numpy_version}"
)
del originpro
except (
FileNotFoundError,
ImportError,
OSError,
PackageNotFoundError,
RuntimeError,
) as exc:
health = "unavailable"
detail = str(exc)
print(
json.dumps(
{
"adapter_version": ADAPTER_VERSION,
"software": "OriginPro",
"software_version": software_version,
"health": health,
"detail": detail,
},
ensure_ascii=False,
)
)
return 0
def _atomic_json(path: Path, value: Any) -> None:
temporary = path.with_name(path.name + ".tmp-" + os.urandom(8).hex())
try:
with temporary.open("w", encoding="utf-8", newline="\n") as handle:
json.dump(value, handle, ensure_ascii=False, indent=2, allow_nan=False)
handle.flush()
os.fsync(handle.fileno())
os.replace(temporary, path)
finally:
temporary.unlink(missing_ok=True)
def _atomic_csv(path: Path, headers: list[str], rows: list[list[Any]]) -> None:
temporary = path.with_name(path.name + ".tmp-" + os.urandom(8).hex())
try:
with temporary.open("w", encoding="utf-8-sig", newline="") as handle:
writer = csv.writer(handle, lineterminator="\n")
writer.writerow(headers)
writer.writerows(rows)
handle.flush()
os.fsync(handle.fileno())
os.replace(temporary, path)
finally:
temporary.unlink(missing_ok=True)
def _atomic_xlsx(path: Path, headers: list[str], rows: list[list[Any]]) -> None:
from openpyxl import Workbook
temporary = path.with_name(path.name + ".tmp-" + os.urandom(8).hex() + ".xlsx")
try:
workbook = Workbook(write_only=True)
worksheet = workbook.create_sheet("Result")
worksheet.append(headers)
for row in rows:
worksheet.append(row)
workbook.save(temporary)
os.replace(temporary, path)
finally:
temporary.unlink(missing_ok=True)
def _read_rows(path: Path, sheet: str | None) -> tuple[list[str], list[list[Any]]]:
suffix = path.suffix.lower()
if suffix == ".csv":
with path.open("r", encoding="utf-8-sig", newline="") as handle:
rows = list(csv.reader(handle))
if len(rows) < 2:
raise ValueError("CSV_INPUT_EMPTY")
return [str(item) for item in rows[0]], rows[1:]
if suffix == ".json":
value = json.loads(path.read_text(encoding="utf-8"))
if isinstance(value, list) and value and all(isinstance(item, dict) for item in value):
headers = list(value[0])
if any(set(item) != set(headers) for item in value):
raise ValueError("JSON_OBJECT_COLUMNS_MISMATCH")
return headers, [[item.get(name) for name in headers] for item in value]
if (
isinstance(value, dict)
and value
and all(isinstance(item, list) for item in value.values())
):
headers = list(value)
length = max(len(value[name]) for name in headers)
return headers, [
[value[name][index] if index < len(value[name]) else None for name in headers]
for index in range(length)
]
raise ValueError("JSON_INPUT_SHAPE_UNSUPPORTED")
if suffix == ".xlsx":
from openpyxl import load_workbook
workbook = load_workbook(path, read_only=True, data_only=True)
try:
worksheet = workbook[sheet] if sheet else workbook.active
rows = list(worksheet.iter_rows(values_only=True))
finally:
workbook.close()
if len(rows) < 2:
raise ValueError("XLSX_INPUT_EMPTY")
return [str(item or "") for item in rows[0]], [list(row) for row in rows[1:]]
raise ValueError("INPUT_TYPE_UNSUPPORTED")
def _input_file(job_dir: Path, key: str) -> Path:
directory = job_dir / "input" / key
files = [
path for path in directory.iterdir() if path.is_file() and not path.name.startswith(".")
]
if len(files) != 1:
raise ValueError(f"INPUT_FILE_COUNT_INVALID:{key}")
return files[0]
def _file_sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def _manifest(path: Path) -> dict[str, Any]:
artifact_id, media_type = OUTPUT_MEDIA[path.name]
return {
"artifact_id": artifact_id,
"filename": path.name,
"media_type": media_type,
"size_bytes": path.stat().st_size,
"sha256": _file_sha256(path),
}
def _missing(value: Any) -> bool:
return value is None or (isinstance(value, str) and not value.strip())
def _finite_number(value: Any, role: str, row_number: int) -> float:
if isinstance(value, bool) or _missing(value):
raise ValueError(f"{role.upper()}_VALUE_NOT_NUMERIC:row={row_number}")
try:
result = float(value)
except (TypeError, ValueError) as exc:
raise ValueError(f"{role.upper()}_VALUE_NOT_NUMERIC:row={row_number}") from exc
if not math.isfinite(result):
raise ValueError(f"{role.upper()}_VALUE_NOT_FINITE:row={row_number}")
return result
def _column_index(headers: list[str], name: Any, role: str) -> int:
if not isinstance(name, str) or name not in headers:
raise ValueError(f"{role.upper()}_COLUMN_NOT_FOUND")
return headers.index(name)
def _xy_values(
headers: list[str], rows: list[list[Any]], binding: dict[str, Any]
) -> tuple[np.ndarray, np.ndarray]:
x_index = _column_index(headers, binding.get("x"), "x")
y_index = _column_index(headers, binding.get("y"), "y")
x = np.array(
[
_finite_number(row[x_index] if x_index < len(row) else None, "x", index)
for index, row in enumerate(rows, start=2)
],
dtype=float,
)
y = np.array(
[
_finite_number(row[y_index] if y_index < len(row) else None, "y", index)
for index, row in enumerate(rows, start=2)
],
dtype=float,
)
if len(x) < 2:
raise ValueError("ANALYSIS_REQUIRES_AT_LEAST_TWO_ROWS")
differences = np.diff(x)
if np.any(differences == 0):
raise ValueError("X_VALUES_DUPLICATED")
if np.any(differences < 0):
raise ValueError("X_VALUES_NOT_STRICTLY_INCREASING")
return x, y
def _json_number(value: float) -> float | None:
return float(value) if math.isfinite(float(value)) else None
def _column_profile(name: str, values: list[Any]) -> dict[str, Any]:
missing = sum(_missing(item) for item in values)
numeric: list[float] = []
non_numeric = 0
non_finite = 0
for item in values:
if _missing(item):
continue
if isinstance(item, bool):
non_numeric += 1
continue
try:
number = float(item)
except (TypeError, ValueError):
non_numeric += 1
continue
if not math.isfinite(number):
non_finite += 1
continue
numeric.append(number)
return {
"column": name,
"row_count": len(values),
"non_missing": len(values) - missing,
"missing": missing,
"numeric": len(numeric),
"non_numeric": non_numeric,
"non_finite": non_finite,
"minimum": min(numeric) if numeric else None,
"maximum": max(numeric) if numeric else None,
"mean": float(np.mean(numeric)) if numeric else None,
}
def _data_check(
headers: list[str], rows: list[list[Any]], spec: dict[str, Any]
) -> tuple[list[str], list[list[Any]], dict[str, Any]]:
profiles = [
_column_profile(
header,
[row[index] if index < len(row) else None for row in rows],
)
for index, header in enumerate(headers)
]
issues: list[dict[str, Any]] = []
if any(item["missing"] for item in profiles):
issues.append({"code": "missing", "detail": "输入包含缺失值。"})
if any(item["non_finite"] for item in profiles):
issues.append({"code": "non_finite", "detail": "输入包含非有限数值。"})
binding = spec["data"]
if "x" in binding:
x_index = _column_index(headers, binding["x"], "x")
x_values: list[float] = []
x_complete = True
for row_number, row in enumerate(rows, start=2):
try:
x_values.append(
_finite_number(
row[x_index] if x_index < len(row) else None,
"x",
row_number,
)
)
except ValueError:
x_complete = False
break
if x_complete and len(x_values) >= 2:
differences = np.diff(np.array(x_values, dtype=float))
if np.any(differences == 0):
issues.append({"code": "duplicate_x", "detail": "X 列包含重复值。"})
if np.any(differences < 0):
issues.append(
{"code": "non_monotonic_x", "detail": "X 列不是严格递增。"}
)
positive = differences[differences > 0]
if len(positive) >= 2:
relative_span = float(np.ptp(positive) / np.mean(positive))
if relative_span > 1e-6:
issues.append(
{
"code": "uneven_spacing",
"detail": "X 列采样间隔不均匀。",
"relative_spacing_span": relative_span,
}
)
fail_on = set(spec["parameters"]["fail_on"])
failed_codes = sorted({item["code"] for item in issues} & fail_on)
result_headers = [
"column",
"row_count",
"non_missing",
"missing",
"numeric",
"non_numeric",
"non_finite",
"minimum",
"maximum",
"mean",
]
result_rows = [[item.get(header) for header in result_headers] for item in profiles]
diagnostics = {
"operation": "data_check",
"passed": not failed_codes,
"failed_checks": failed_codes,
"issues": issues,
"row_count": len(rows),
"column_count": len(headers),
}
return result_headers, result_rows, diagnostics
def _normalize(
x: np.ndarray, y: np.ndarray, parameters: dict[str, Any]
) -> tuple[list[str], list[list[Any]], dict[str, Any]]:
method = parameters["method"]
selected_x = None
if method == "max_abs":
divisor = float(np.max(np.abs(y)))
elif method == "area":
divisor = float(np.trapezoid(y, x))
elif method == "reference":
reference_x = float(parameters["reference_x"])
if reference_x < x[0] or reference_x > x[-1]:
raise ValueError("REFERENCE_X_OUTSIDE_DATA_RANGE")
divisor = float(np.interp(reference_x, x, y))
selected_x = reference_x
else:
raise ValueError("NORMALIZE_METHOD_UNSUPPORTED")
scale = max(1.0, float(np.max(np.abs(y))))
if abs(divisor) <= np.finfo(float).eps * scale:
raise ValueError("NORMALIZE_DIVISOR_IS_ZERO")
normalized = y / divisor
rows = [[float(a), float(b), float(c)] for a, b, c in zip(x, y, normalized, strict=True)]
return ["x", "y", "normalized_y"], rows, {
"operation": "normalize",
"method": method,
"divisor": divisor,
"reference_x": selected_x,
"row_count": len(rows),
}
def _smooth(
x: np.ndarray, y: np.ndarray, parameters: dict[str, Any]
) -> tuple[list[str], list[list[Any]], dict[str, Any]]:
window = int(parameters["window"])
polynomial_order = int(parameters["polynomial_order"])
if window % 2 == 0:
raise ValueError("SMOOTH_WINDOW_MUST_BE_ODD")
if polynomial_order >= window:
raise ValueError("SMOOTH_POLYNOMIAL_ORDER_INVALID")
if window > len(x):
raise ValueError("SMOOTH_WINDOW_EXCEEDS_ROW_COUNT")
half = window // 2
smoothed = np.empty_like(y)
for index in range(len(x)):
start = min(max(index - half, 0), len(x) - window)
stop = start + window
local_x = x[start:stop] - x[index]
coefficients = np.polynomial.polynomial.polyfit(
local_x, y[start:stop], polynomial_order
)
smoothed[index] = coefficients[0]
residual = y - smoothed
rows = [
[float(a), float(b), float(c), float(d)]
for a, b, c, d in zip(x, y, smoothed, residual, strict=True)
]
return ["x", "y", "smoothed_y", "residual"], rows, {
"operation": "smooth",
"method": "savitzky_golay",
"window": window,
"polynomial_order": polynomial_order,
"rmse": float(np.sqrt(np.mean(residual**2))),
"boundary_handling": "shifted_full_window_local_polynomial",
"row_count": len(rows),
}
def _differentiate(
x: np.ndarray, y: np.ndarray, parameters: dict[str, Any]
) -> tuple[list[str], list[list[Any]], dict[str, Any]]:
if len(x) < 3:
raise ValueError("DIFFERENTIATE_REQUIRES_AT_LEAST_THREE_ROWS")
order = int(parameters["order"])
derivative = y.copy()
for _ in range(order):
derivative = np.gradient(derivative, x, edge_order=2)
rows = [
[float(a), float(b), float(c)]
for a, b, c in zip(x, y, derivative, strict=True)
]
return ["x", "y", f"derivative_order_{order}"], rows, {
"operation": "differentiate",
"order": order,
"method": "numpy_gradient_nonuniform",
"edge_order": 2,
"row_count": len(rows),
}
def _cumulative_trapezoid(x: np.ndarray, y: np.ndarray) -> np.ndarray:
cumulative = np.zeros_like(y)
cumulative[1:] = np.cumsum(np.diff(x) * (y[:-1] + y[1:]) / 2)
return cumulative
def _integrate(
x: np.ndarray, y: np.ndarray, parameters: dict[str, Any]
) -> tuple[list[str], list[list[Any]], dict[str, Any]]:
cumulative = _cumulative_trapezoid(x, y)
lower = float(parameters.get("from", x[0]))
upper = float(parameters.get("to", x[-1]))
if lower >= upper:
raise ValueError("INTEGRATE_RANGE_INVALID")
if lower < x[0] or upper > x[-1]:
raise ValueError("INTEGRATE_RANGE_OUTSIDE_DATA")
integral = float(np.interp(upper, x, cumulative) - np.interp(lower, x, cumulative))
rows = [
[float(a), float(b), float(c)]
for a, b, c in zip(x, y, cumulative, strict=True)
]
return ["x", "y", "cumulative_integral"], rows, {
"operation": "integrate",
"method": "trapezoid",
"from": lower,
"to": upper,
"integral": integral,
"full_integral": float(cumulative[-1]),
"row_count": len(rows),
}
_T_CRITICAL_95 = (
12.706,
4.303,
3.182,
2.776,
2.571,
2.447,
2.365,
2.306,
2.262,
2.228,
2.201,
2.179,
2.160,
2.145,
2.131,
2.120,
2.110,
2.101,
2.093,
2.086,
2.080,
2.074,
2.069,
2.064,
2.060,
2.056,
2.052,
2.048,
2.045,
2.042,
)
def _t_critical_95(degrees_of_freedom: int) -> float:
if degrees_of_freedom <= 0:
raise ValueError("LINEAR_FIT_DEGREES_OF_FREEDOM_INVALID")
if degrees_of_freedom <= len(_T_CRITICAL_95):
return _T_CRITICAL_95[degrees_of_freedom - 1]
anchors = ((30, 2.042), (40, 2.021), (60, 2.000), (120, 1.980), (10**9, 1.960))
for (left_df, left_value), (right_df, right_value) in pairwise(anchors):
if degrees_of_freedom <= right_df:
ratio = (degrees_of_freedom - left_df) / (right_df - left_df)
return left_value + ratio * (right_value - left_value)
return 1.960
def _linear_fit(
x: np.ndarray, y: np.ndarray, parameters: dict[str, Any]
) -> tuple[list[str], list[list[Any]], dict[str, Any]]:
include_intercept = bool(parameters["include_intercept"])
design = np.column_stack((x, np.ones_like(x))) if include_intercept else x[:, None]
coefficients, _, rank, _ = np.linalg.lstsq(design, y, rcond=None)
if rank != design.shape[1]:
raise ValueError("LINEAR_FIT_DESIGN_RANK_DEFICIENT")
fitted = design @ coefficients
residual = y - fitted
parameter_count = design.shape[1]
degrees_of_freedom = len(x) - parameter_count
if degrees_of_freedom <= 0:
raise ValueError("LINEAR_FIT_INSUFFICIENT_ROWS")
sum_squared_error = float(residual @ residual)
mean_squared_error = sum_squared_error / degrees_of_freedom
covariance = mean_squared_error * np.linalg.inv(design.T @ design)
standard_errors = np.sqrt(np.diag(covariance))
slope = float(coefficients[0])
intercept = float(coefficients[1]) if include_intercept else 0.0
slope_se = float(standard_errors[0])
intercept_se = float(standard_errors[1]) if include_intercept else 0.0
t_critical = _t_critical_95(degrees_of_freedom)
slope_ci = [slope - t_critical * slope_se, slope + t_critical * slope_se]
intercept_ci = (
[intercept - t_critical * intercept_se, intercept + t_critical * intercept_se]
if include_intercept
else [0.0, 0.0]
)
total_sum_squares = (
float(np.sum((y - np.mean(y)) ** 2))
if include_intercept
else float(y @ y)
)
r_squared = 1 - sum_squared_error / total_sum_squares if total_sum_squares else 1.0
rows = [
[float(a), float(b), float(c), float(d)]
for a, b, c, d in zip(x, y, fitted, residual, strict=True)
]
return ["x", "y", "fitted_y", "residual"], rows, {
"operation": "linear_fit",
"include_intercept": include_intercept,
"confidence_level": 0.95,
"slope": slope,
"intercept": intercept,
"slope_standard_error": slope_se,
"intercept_standard_error": intercept_se,
"slope_confidence_interval": slope_ci,
"intercept_confidence_interval": intercept_ci,
"t_critical": t_critical,
"degrees_of_freedom": degrees_of_freedom,
"r_squared": r_squared,
"rmse": math.sqrt(mean_squared_error),
"sum_squared_error": sum_squared_error,
"row_count": len(rows),
}
def analyze(
headers: list[str], rows: list[list[Any]], analysis: dict[str, Any]
) -> tuple[list[str], list[list[Any]], dict[str, Any]]:
operation = analysis["type"]
if operation not in OPERATIONS:
raise ValueError("ANALYSIS_TYPE_NOT_IMPLEMENTED")
if operation == "data_check":
return _data_check(headers, rows, analysis)
x, y = _xy_values(headers, rows, analysis["data"])
parameters = analysis["parameters"]
functions = {
"normalize": _normalize,
"smooth": _smooth,
"differentiate": _differentiate,
"integrate": _integrate,
"linear_fit": _linear_fit,
}
return functions[operation](x, y, parameters)
def _origin_value(value: Any) -> Any:
if value is None or isinstance(value, (str, int, float, bool)):
return value
return json.dumps(value, ensure_ascii=False, sort_keys=True)
def _write_origin_project(
project: Path,
headers: list[str],
rows: list[list[Any]],
result_headers: list[str],
result_rows: list[list[Any]],
diagnostics: dict[str, Any],
analysis: dict[str, Any],
) -> None:
import originpro as op
op.set_show(False)
try:
op.new()
raw_sheet = op.new_sheet("w", lname="RawData")
for index, header in enumerate(headers):
raw_sheet.from_list(
index,
[row[index] if index < len(row) else None for row in rows],
lname=header,
)
result_sheet = op.new_sheet("w", lname="Result")
for index, header in enumerate(result_headers):
result_sheet.from_list(
index,
[row[index] if index < len(row) else None for row in result_rows],
lname=header,
)
diagnostic_sheet = op.new_sheet("w", lname="Diagnostics")
diagnostic_items = list(diagnostics.items())
diagnostic_sheet.from_list(0, [item[0] for item in diagnostic_items], lname="metric")
diagnostic_sheet.from_list(
1,
[_origin_value(item[1]) for item in diagnostic_items],
lname="value",
)
if analysis["type"] == "linear_fit":
binding = analysis["data"]
x_index = _column_index(headers, binding["x"], "x")
y_index = _column_index(headers, binding["y"], "y")
fit = op.LinearFit()
fit.set_data(raw_sheet, x_index, y_index)
if not analysis["parameters"]["include_intercept"]:
fit.fix_intercept(0)
fit.report(3)
op.save(str(project))
finally:
if op.oext:
op.exit()
def _validate_project(path: Path) -> None:
if not path.is_file() or path.stat().st_size < 128:
raise RuntimeError("ORIGIN_PROJECT_INVALID")
def run(job_dir: Path) -> list[dict[str, Any]]:
job_dir = job_dir.resolve(strict=True)
request_record = json.loads(
(job_dir / "request" / "request.json").read_text(encoding="utf-8")
)
request = request_record["request"]
input_spec = request["inputs"][0]
analysis = request["operation"]["analysis"]
if analysis["data"]["input"] != input_spec["key"]:
raise ValueError("ANALYSIS_INPUT_KEY_NOT_BOUND")
input_path = _input_file(job_dir, input_spec["key"])
headers, rows = _read_rows(input_path, (input_spec.get("selector") or {}).get("sheet"))
if not headers or len(set(headers)) != len(headers) or any(not item for item in headers):
raise ValueError("INPUT_HEADERS_INVALID")
result_headers, result_rows, diagnostics = analyze(headers, rows, analysis)
output = job_dir / "output"
output.mkdir(exist_ok=True)
result_table = output / "result-table.csv"
diagnostics_path = output / "diagnostics.json"
analysis_spec_path = output / "analysis-spec.json"
provenance_path = output / "provenance.json"
project_path = output / "analysis.opju"
_atomic_csv(result_table, result_headers, result_rows)
_atomic_json(diagnostics_path, diagnostics)
_atomic_json(analysis_spec_path, request)
_write_origin_project(
project_path,
headers,
rows,
result_headers,
result_rows,
diagnostics,
analysis,
)
_validate_project(project_path)
try:
originpro_version = version("originpro")
except PackageNotFoundError:
originpro_version = "embedded"
provenance = {
"adapter_version": ADAPTER_VERSION,
"algorithm_version": ALGORITHM_VERSION,
"originpro_version": originpro_version,
"numpy_version": np.__version__,
"request_digest": request_record["request_digest"],
"input": {
"key": input_spec["key"],
"filename": input_path.name,
"sha256": _file_sha256(input_path),
"sheet": (input_spec.get("selector") or {}).get("sheet"),
},
"operation": analysis["type"],
"origin_linear_fit_report": analysis["type"] == "linear_fit",
}
_atomic_json(provenance_path, provenance)
artifacts = [
_manifest(project_path),
_manifest(result_table),
_manifest(diagnostics_path),
_manifest(analysis_spec_path),
_manifest(provenance_path),
]
if any(item["key"] == "result_workbook" for item in request["outputs"]):
workbook_path = output / "result-table.xlsx"
_atomic_xlsx(workbook_path, result_headers, result_rows)
artifacts.append(_manifest(workbook_path))
return artifacts
def main() -> int:
if sys.argv[1:] == ["--probe"]:
return _probe()
if len(sys.argv) != 2:
print("[ERR] Usage: worker.py <job-directory>", file=sys.stderr)
return 2
job_dir = Path(sys.argv[1])
request_record: dict[str, Any] = {}
try:
request_record = json.loads(
(job_dir / "request" / "request.json").read_text(encoding="utf-8")
)
artifacts = run(job_dir)
terminal = {
"job_id": request_record["job_id"],
"lease_id": request_record["lease_id"],
"request_digest": request_record["request_digest"],
"status": "succeeded",
"error": {},
"artifact_manifest": artifacts,
"terminal_at": datetime.now(timezone.utc).isoformat(),
}
_atomic_json(job_dir / "artifacts.json", artifacts)
_atomic_json(job_dir / "terminal.json", terminal)
print("[OK] Origin analysis job completed.")
return 0
except Exception as exception: # noqa: BLE001 - terminalize every worker failure
terminal = {
"job_id": request_record.get("job_id", ""),
"lease_id": request_record.get("lease_id", ""),
"request_digest": request_record.get("request_digest", ""),
"status": "failed",
"error": {"code": type(exception).__name__, "detail": str(exception)[:500]},
"artifact_manifest": [],
"terminal_at": datetime.now(timezone.utc).isoformat(),
}
_atomic_json(job_dir / "terminal.json", terminal)
print(f"[ERR] {type(exception).__name__}: {exception}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,47 @@
@echo off
setlocal EnableExtensions EnableDelayedExpansion
cd /d "%~dp0"
set "OUTPUT_ROOT=%~f1"
if "%~1"=="" set "OUTPUT_ROOT=%~dp0dist"
set "ADAPTER_DIR_NAME=origin.analysis@v1"
set "ARCHIVE_NAME=origin.analysis@v1-adapter.zip"
set "PACKAGE_DIR=!OUTPUT_ROOT!\!ADAPTER_DIR_NAME!"
set "ARCHIVE_PATH=!OUTPUT_ROOT!\!ARCHIVE_NAME!"
set "HASH_PATH=!ARCHIVE_PATH!.sha256"
where.exe tar.exe >nul 2>&1
if errorlevel 1 goto :missing_tool
where.exe certutil.exe >nul 2>&1
if errorlevel 1 goto :missing_tool
if not exist "!OUTPUT_ROOT!" mkdir "!OUTPUT_ROOT!"
if exist "!PACKAGE_DIR!" rmdir /s /q "!PACKAGE_DIR!"
if exist "!ARCHIVE_PATH!" del /f /q "!ARCHIVE_PATH!"
if exist "!HASH_PATH!" del /f /q "!HASH_PATH!"
mkdir "!PACKAGE_DIR!"
copy /y "adapters\origin.analysis@v1\adapter.json" "!PACKAGE_DIR!\adapter.json" >nul
copy /y "adapters\origin.analysis@v1\worker.py" "!PACKAGE_DIR!\worker.py" >nul
copy /y "adapters\origin.analysis@v1\acceptance.py" "!PACKAGE_DIR!\acceptance.py" >nul
copy /y "adapters\origin.analysis@v1\requirements.txt" "!PACKAGE_DIR!\requirements.txt" >nul
copy /y "..\software-contracts\origin.analysis.v1.json" "!PACKAGE_DIR!\origin.analysis.v1.json" >nul
if errorlevel 1 goto :failed
tar.exe -a -c -f "!ARCHIVE_PATH!" -C "!OUTPUT_ROOT!" "!ADAPTER_DIR_NAME!"
if errorlevel 1 goto :failed
set "SHA256="
for /f "tokens=*" %%H in ('certutil.exe -hashfile "!ARCHIVE_PATH!" SHA256 ^| findstr.exe /R /C:"^[0-9A-Fa-f][0-9A-Fa-f ]*[0-9A-Fa-f]$"') do set "SHA256=%%H"
set "SHA256=!SHA256: =!"
if not defined SHA256 goto :failed
>"!HASH_PATH!" echo !SHA256! !ARCHIVE_NAME!
echo [OK] Adapter package: !ARCHIVE_PATH!
echo [OK] SHA256: !SHA256!
exit /b 0
:missing_tool
echo [ERR] Windows tar.exe and certutil.exe are required.
exit /b 1
:failed
echo [ERR] Origin analysis adapter packaging failed.
exit /b 1