diff --git a/CHANGELOG.md b/CHANGELOG.md index 059ccc2..7f19cf5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,11 @@ This project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html) and changed-file `uv run ruff check ...`, and `uv run python figops_mcp_server.py --smoke`. - Maintainers tag releases after merge; implementers open PRs but do not merge or tag. +## [0.20.2] - 2026-07-29 + +Post-0.20.1 release-candidate metadata entry. This records the next patch +candidate only; merge, tag, and package publication remain maintainer actions. + ## [0.20.1] - 2026-07-25 Post-0.20.0 release-candidate metadata entry. This records the next patch diff --git a/docs/internal/protocols/05_mcp_tool_playbook.md b/docs/internal/protocols/05_mcp_tool_playbook.md index 2697395..81bd17e 100644 --- a/docs/internal/protocols/05_mcp_tool_playbook.md +++ b/docs/internal/protocols/05_mcp_tool_playbook.md @@ -109,6 +109,20 @@ fixed and change only style/output controls. - `manual_review_needed=false` is not human or venue approval. - Preview resources are lazy, manifest-bound, MIME-checked, and size-bounded. +### Audit policy names and unavailable geometry + +`figops.audit_artifact` accepts the stable public policy-pack name +`publication-readiness-v1`. The adapter projects that public name to the +internal `publication-readiness-v2` policy projection before evaluating the +evidence. `publication-readiness-v2` is intentionally not a public enum value; +callers must continue sending `publication-readiness-v1`. + +For required geometry, `not_applicable` means that the required observation is +unavailable or cannot be measured. It is not a passing result: the measurement +must include a non-empty `reason`, and the resulting policy projection remains +`needs_review` until the required evidence is available. Optional unavailable +geometry may remain informational under the selected policy. + When writes are disabled, `figops.inspect_data`, `figops.audit_artifact`, and manifest/preview reads remain available. Render, scaffold, normalize, and batch write handlers are omitted from discovery and fail closed without side effects diff --git a/docs/packaging/public-release-status.md b/docs/packaging/public-release-status.md index fdd338c..efa31b4 100644 --- a/docs/packaging/public-release-status.md +++ b/docs/packaging/public-release-status.md @@ -2,14 +2,14 @@ - Inventory valid: yes - Package distribution allowed: yes -- Repository technically eligible for public release: yes +- Repository technically eligible for public release: no - Repository publication authorized: yes -- Repository release allowed: yes +- Repository release allowed: no - Authorization evidence references: 1 -- Technical release gate: ok -- Technical blockers: 0 +- Technical release gate: blocked +- Technical blockers: 1 - Auto-fixable technical blockers: 0 -- Confirmation-required technical blockers: 0 +- Confirmation-required technical blockers: 1 Repository publication authorization is recorded in the authoritative inventory approval fields with validated HTTPS evidence references. The technical gate remains independent evidence; a release is allowed only when both authorization and technical eligibility are yes. @@ -19,3 +19,4 @@ Decision record: [public-release-decision-record.md](./public-release-decision-r | Family | Count | Status | Confirmation | Action | | --- | ---: | --- | --- | --- | +| private_marker | 1 | requires_decision | yes | Sanitize or relocate files that contain real project identifiers or private style names. | diff --git a/docs/tools-compatibility.md b/docs/tools-compatibility.md index bf0d92b..462399d 100644 --- a/docs/tools-compatibility.md +++ b/docs/tools-compatibility.md @@ -79,7 +79,10 @@ Return FigOps server health and discovery status. "type": "integer" }, "failure_stage": { - "type": "string" + "type": [ + "string", + "null" + ] }, "hub_path": { "type": "string" @@ -160,7 +163,10 @@ Return FigOps server health and discovery status. "type": "string" }, "resolution_hint": { - "type": "string" + "type": [ + "string", + "null" + ] }, "runtime_root": { "type": "string" @@ -198,6 +204,7 @@ Return FigOps server health and discovery status. "type": "string" }, "version": { + "description": "Installed FigOps package version. This is independent of audit policy projection identifiers.", "type": "string" }, "warnings": { @@ -271,7 +278,10 @@ Describe registered FigOps tools, plot types, semantic checks, and render exampl "type": "array" }, "failure_stage": { - "type": "string" + "type": [ + "string", + "null" + ] }, "is_dry_run": { "type": "boolean" @@ -307,7 +317,10 @@ Describe registered FigOps tools, plot types, semantic checks, and render exampl "type": "array" }, "resolution_hint": { - "type": "string" + "type": [ + "string", + "null" + ] }, "script_output": { "items": { @@ -415,7 +428,10 @@ Return canonical FigOps target formats, output formats, profiles, and aliases. "type": "array" }, "failure_stage": { - "type": "string" + "type": [ + "string", + "null" + ] }, "is_dry_run": { "type": "boolean" @@ -460,7 +476,10 @@ Return canonical FigOps target formats, output formats, profiles, and aliases. "type": "array" }, "resolution_hint": { - "type": "string" + "type": [ + "string", + "null" + ] }, "script_output": { "items": { @@ -589,7 +608,10 @@ Discover FigOps project configs without executing scripts or writing files. "type": "array" }, "failure_stage": { - "type": "string" + "type": [ + "string", + "null" + ] }, "is_dry_run": { "type": "boolean" @@ -688,7 +710,10 @@ Discover FigOps project configs without executing scripts or writing files. "type": "array" }, "resolution_hint": { - "type": "string" + "type": [ + "string", + "null" + ] }, "script_output": { "items": { @@ -828,7 +853,10 @@ Summarize one project config without running analysis, plotting, or report write "type": "object" }, "failure_stage": { - "type": "string" + "type": [ + "string", + "null" + ] }, "figure_outputs": { "items": { @@ -931,7 +959,10 @@ Summarize one project config without running analysis, plotting, or report write "type": "object" }, "resolution_hint": { - "type": "string" + "type": [ + "string", + "null" + ] }, "sample_registry_summary": { "type": "object" @@ -1126,7 +1157,10 @@ Run read-only config, data contract, style, and lockfile checks without executin "type": "array" }, "failure_stage": { - "type": "string" + "type": [ + "string", + "null" + ] }, "is_dry_run": { "type": "boolean" @@ -1178,7 +1212,10 @@ Run read-only config, data contract, style, and lockfile checks without executin "type": "string" }, "resolution_hint": { - "type": "string" + "type": [ + "string", + "null" + ] }, "script_output": { "items": { @@ -2583,7 +2620,10 @@ Render a CSV-backed graph in an isolated runtime-root MCP job workspace. "type": "object" }, "failure_stage": { - "type": "string" + "type": [ + "string", + "null" + ] }, "geometry_diagnostics": { "additionalProperties": false, @@ -2800,7 +2840,10 @@ Render a CSV-backed graph in an isolated runtime-root MCP job workspace. "type": "array" }, "resolution_hint": { - "type": "string" + "type": [ + "string", + "null" + ] }, "script_output": { "items": { @@ -4276,7 +4319,10 @@ Render a multi-panel CSV-backed composite figure in an isolated runtime-root MCP "type": "object" }, "failure_stage": { - "type": "string" + "type": [ + "string", + "null" + ] }, "geometry_diagnostics": { "additionalProperties": false, @@ -4496,7 +4542,10 @@ Render a multi-panel CSV-backed composite figure in an isolated runtime-root MCP "type": "object" }, "resolution_hint": { - "type": "string" + "type": [ + "string", + "null" + ] }, "script_output": { "items": { @@ -4603,6 +4652,7 @@ Render one configured project figure in an isolated runtime-root MCP job workspa }, "overwrite": { "default": false, + "description": "Replace the existing isolated MCP job workspace only; never overwrite the durable project output.", "type": "boolean" }, "profile": { @@ -4682,6 +4732,43 @@ Render one configured project figure in an isolated runtime-root MCP job workspa }, "type": "array" }, + "durable_result": { + "additionalProperties": false, + "properties": { + "reason": { + "type": [ + "string", + "null" + ] + }, + "reason_code": { + "type": [ + "string", + "null" + ] + }, + "relative_path": { + "type": [ + "string", + "null" + ] + }, + "source": { + "enum": [ + "durable_project_result" + ], + "type": "string" + }, + "status": { + "enum": [ + "promoted", + "not_promoted" + ], + "type": "string" + } + }, + "type": "object" + }, "error_category": { "enum": [ "validation", @@ -4704,7 +4791,10 @@ Render one configured project figure in an isolated runtime-root MCP job workspa "type": "object" }, "failure_stage": { - "type": "string" + "type": [ + "string", + "null" + ] }, "figure_metadata": { "type": "object" @@ -4905,6 +4995,12 @@ Render one configured project figure in an isolated runtime-root MCP job workspa "output_path": { "type": "string" }, + "overwrite_scope": { + "enum": [ + "job_workspace_only" + ], + "type": "string" + }, "policy_context": { "additionalProperties": false, "properties": { @@ -5008,6 +5104,19 @@ Render one configured project figure in an isolated runtime-root MCP job workspa "promotion_eligible": { "type": "boolean" }, + "promotion_reason": { + "type": [ + "string", + "null" + ] + }, + "promotion_status": { + "enum": [ + "promoted", + "not_promoted" + ], + "type": "string" + }, "provenance": { "type": "object" }, @@ -5019,7 +5128,54 @@ Render one configured project figure in an isolated runtime-root MCP job workspa "type": "string" }, "resolution_hint": { - "type": "string" + "type": [ + "string", + "null" + ] + }, + "runtime_artifact": { + "additionalProperties": false, + "properties": { + "project_relative_path": { + "type": [ + "string", + "null" + ] + }, + "relative_path": { + "type": [ + "string", + "null" + ] + }, + "sha256": { + "pattern": "^[0-9a-fA-F]{64}$", + "type": [ + "string", + "null" + ] + }, + "source": { + "enum": [ + "runtime_snapshot" + ], + "type": "string" + }, + "status": { + "enum": [ + "created", + "unavailable" + ], + "type": "string" + }, + "uri": { + "type": [ + "string", + "null" + ] + } + }, + "type": "object" }, "script_output": { "items": { @@ -5042,6 +5198,9 @@ Render one configured project figure in an isolated runtime-root MCP job workspa "source_project_path": { "type": "string" }, + "source_unchanged": { + "type": "boolean" + }, "status": { "enum": [ "ok", @@ -5293,7 +5452,10 @@ Return artifact metadata for a completed MCP render job. "type": "array" }, "failure_stage": { - "type": "string" + "type": [ + "string", + "null" + ] }, "figure_metadata": { "type": "object" @@ -5402,7 +5564,10 @@ Return artifact metadata for a completed MCP render job. "type": "object" }, "resolution_hint": { - "type": "string" + "type": [ + "string", + "null" + ] }, "script_output": { "items": { @@ -5542,7 +5707,10 @@ Plan or create a standard FigOps project scaffold. "type": "array" }, "failure_stage": { - "type": "string" + "type": [ + "string", + "null" + ] }, "is_dry_run": { "type": "boolean" @@ -5587,7 +5755,10 @@ Plan or create a standard FigOps project scaffold. "type": "string" }, "resolution_hint": { - "type": "string" + "type": [ + "string", + "null" + ] }, "script_output": { "items": { @@ -5761,7 +5932,10 @@ Propose migration mappings or apply an explicitly reviewed copy-only structure p "type": "array" }, "failure_stage": { - "type": "string" + "type": [ + "string", + "null" + ] }, "is_dry_run": { "type": "boolean" @@ -5818,7 +5992,10 @@ Propose migration mappings or apply an explicitly reviewed copy-only structure p "type": "object" }, "resolution_hint": { - "type": "string" + "type": [ + "string", + "null" + ] }, "rollback_journal": { "type": "object" @@ -5982,7 +6159,10 @@ Run a bounded project discovery and validation batch check with optional runtime "type": "array" }, "failure_stage": { - "type": "string" + "type": [ + "string", + "null" + ] }, "is_dry_run": { "type": "boolean" @@ -6018,7 +6198,10 @@ Run a bounded project discovery and validation batch check with optional runtime "type": "string" }, "resolution_hint": { - "type": "string" + "type": [ + "string", + "null" + ] }, "resumed_from": { "type": "string" @@ -6126,7 +6309,10 @@ Evaluate an existing render job manifest into a read-only publication-readiness "type": "array" }, "failure_stage": { - "type": "string" + "type": [ + "string", + "null" + ] }, "is_dry_run": { "type": "boolean" @@ -6159,7 +6345,10 @@ Evaluate an existing render job manifest into a read-only publication-readiness "type": "object" }, "resolution_hint": { - "type": "string" + "type": [ + "string", + "null" + ] }, "script_output": { "items": { diff --git a/docs/tools-v2.md b/docs/tools-v2.md index de9b506..fcbc228 100644 --- a/docs/tools-v2.md +++ b/docs/tools-v2.md @@ -79,7 +79,10 @@ Return FigOps server health and discovery status. "type": "integer" }, "failure_stage": { - "type": "string" + "type": [ + "string", + "null" + ] }, "hub_path": { "type": "string" @@ -160,7 +163,10 @@ Return FigOps server health and discovery status. "type": "string" }, "resolution_hint": { - "type": "string" + "type": [ + "string", + "null" + ] }, "runtime_root": { "type": "string" @@ -198,6 +204,7 @@ Return FigOps server health and discovery status. "type": "string" }, "version": { + "description": "Installed FigOps package version. This is independent of audit policy projection identifiers.", "type": "string" }, "warnings": { @@ -386,7 +393,10 @@ Return canonical FigOps target formats, output formats, profiles, and aliases. "type": "array" }, "failure_stage": { - "type": "string" + "type": [ + "string", + "null" + ] }, "is_dry_run": { "type": "boolean" @@ -431,7 +441,10 @@ Return canonical FigOps target formats, output formats, profiles, and aliases. "type": "array" }, "resolution_hint": { - "type": "string" + "type": [ + "string", + "null" + ] }, "script_output": { "items": { @@ -699,6 +712,7 @@ Render one quick CSV chart with raw labels and no statistics DSL. }, "overwrite": { "default": false, + "description": "Replace the existing isolated MCP job workspace only; never overwrite the durable project output.", "type": "boolean" }, "plot_type": { @@ -787,6 +801,12 @@ Render one quick CSV chart with raw labels and no statistics DSL. "null" ] }, + "failure_stage": { + "type": [ + "string", + "null" + ] + }, "job_id": { "type": "string" }, @@ -805,6 +825,12 @@ Render one quick CSV chart with raw labels and no statistics DSL. "null" ] }, + "resolution_hint": { + "type": [ + "string", + "null" + ] + }, "schema_version": { "type": "string" }, @@ -872,6 +898,7 @@ Render one configured project-local .py or .R figure; code and command strings a }, "overwrite": { "default": false, + "description": "Replace the existing isolated MCP job workspace only; never overwrite the durable project output.", "type": "boolean" }, "project_id": { @@ -927,6 +954,50 @@ Render one configured project-local .py or .R figure; code and command strings a "null" ] }, + "durable_result": { + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "reason": { + "type": [ + "string", + "null" + ] + }, + "reason_code": { + "type": [ + "string", + "null" + ] + }, + "relative_path": { + "type": [ + "string", + "null" + ] + }, + "source": { + "enum": [ + "durable_project_result" + ], + "type": "string" + }, + "status": { + "enum": [ + "promoted", + "not_promoted" + ], + "type": "string" + } + }, + "type": "object" + }, + { + "type": "null" + } + ] + }, "errors": { "items": { "type": "string" @@ -939,6 +1010,12 @@ Render one configured project-local .py or .R figure; code and command strings a "null" ] }, + "failure_stage": { + "type": [ + "string", + "null" + ] + }, "job_id": { "type": "string" }, @@ -951,6 +1028,12 @@ Render one configured project-local .py or .R figure; code and command strings a "manual_review_needed": { "type": "boolean" }, + "overwrite_scope": { + "enum": [ + "job_workspace_only" + ], + "type": "string" + }, "policy_context": { "additionalProperties": false, "properties": { @@ -1045,12 +1128,92 @@ Render one configured project-local .py or .R figure; code and command strings a "null" ] }, + "promotion_eligible": { + "type": "boolean" + }, + "promotion_reason": { + "type": [ + "string", + "null" + ] + }, + "promotion_status": { + "enum": [ + "promoted", + "not_promoted", + null + ], + "type": [ + "string", + "null" + ] + }, + "resolution_hint": { + "type": [ + "string", + "null" + ] + }, + "runtime_artifact": { + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "project_relative_path": { + "type": [ + "string", + "null" + ] + }, + "relative_path": { + "type": [ + "string", + "null" + ] + }, + "sha256": { + "pattern": "^[0-9a-fA-F]{64}$", + "type": [ + "string", + "null" + ] + }, + "source": { + "enum": [ + "runtime_snapshot" + ], + "type": "string" + }, + "status": { + "enum": [ + "created", + "unavailable" + ], + "type": "string" + }, + "uri": { + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + { + "type": "null" + } + ] + }, "runtime_availability": { "type": "object" }, "schema_version": { "type": "string" }, + "source_unchanged": { + "type": "boolean" + }, "status": { "enum": [ "ok", @@ -1217,7 +1380,7 @@ Render one configured project-local .py or .R figure; code and command strings a ### `figops.audit_artifact` -Audit validated completed-job evidence with zero or more explicit policy packs. +Audit validated completed-job evidence with zero or more explicit policy packs. The public publication-readiness-v1 pack projects internally to publication-readiness-v2; v2 is not a public enum value. Required geometry marked not_applicable remains unresolved and requires review. **Input schema** @@ -1232,6 +1395,7 @@ Audit validated completed-job evidence with zero or more explicit policy packs. }, "policy_packs": { "default": [], + "description": "Public policy-pack identifiers only. publication-readiness-v1 is projected internally to publication-readiness-v2; the internal id must not be supplied by callers.", "items": { "enum": [ "publication-readiness-v1" diff --git a/docs/tools.md b/docs/tools.md index ba6a7e8..cc69534 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -79,7 +79,10 @@ Return FigOps server health and discovery status. "type": "integer" }, "failure_stage": { - "type": "string" + "type": [ + "string", + "null" + ] }, "hub_path": { "type": "string" @@ -160,7 +163,10 @@ Return FigOps server health and discovery status. "type": "string" }, "resolution_hint": { - "type": "string" + "type": [ + "string", + "null" + ] }, "runtime_root": { "type": "string" @@ -198,6 +204,7 @@ Return FigOps server health and discovery status. "type": "string" }, "version": { + "description": "Installed FigOps package version. This is independent of audit policy projection identifiers.", "type": "string" }, "warnings": { @@ -271,7 +278,10 @@ Describe registered FigOps tools, plot types, semantic checks, and render exampl "type": "array" }, "failure_stage": { - "type": "string" + "type": [ + "string", + "null" + ] }, "is_dry_run": { "type": "boolean" @@ -307,7 +317,10 @@ Describe registered FigOps tools, plot types, semantic checks, and render exampl "type": "array" }, "resolution_hint": { - "type": "string" + "type": [ + "string", + "null" + ] }, "script_output": { "items": { @@ -415,7 +428,10 @@ Return canonical FigOps target formats, output formats, profiles, and aliases. "type": "array" }, "failure_stage": { - "type": "string" + "type": [ + "string", + "null" + ] }, "is_dry_run": { "type": "boolean" @@ -460,7 +476,10 @@ Return canonical FigOps target formats, output formats, profiles, and aliases. "type": "array" }, "resolution_hint": { - "type": "string" + "type": [ + "string", + "null" + ] }, "script_output": { "items": { @@ -589,7 +608,10 @@ Discover FigOps project configs without executing scripts or writing files. "type": "array" }, "failure_stage": { - "type": "string" + "type": [ + "string", + "null" + ] }, "is_dry_run": { "type": "boolean" @@ -688,7 +710,10 @@ Discover FigOps project configs without executing scripts or writing files. "type": "array" }, "resolution_hint": { - "type": "string" + "type": [ + "string", + "null" + ] }, "script_output": { "items": { @@ -828,7 +853,10 @@ Summarize one project config without running analysis, plotting, or report write "type": "object" }, "failure_stage": { - "type": "string" + "type": [ + "string", + "null" + ] }, "figure_outputs": { "items": { @@ -931,7 +959,10 @@ Summarize one project config without running analysis, plotting, or report write "type": "object" }, "resolution_hint": { - "type": "string" + "type": [ + "string", + "null" + ] }, "sample_registry_summary": { "type": "object" @@ -1126,7 +1157,10 @@ Run read-only config, data contract, style, and lockfile checks without executin "type": "array" }, "failure_stage": { - "type": "string" + "type": [ + "string", + "null" + ] }, "is_dry_run": { "type": "boolean" @@ -1178,7 +1212,10 @@ Run read-only config, data contract, style, and lockfile checks without executin "type": "string" }, "resolution_hint": { - "type": "string" + "type": [ + "string", + "null" + ] }, "script_output": { "items": { @@ -2583,7 +2620,10 @@ Render a CSV-backed graph in an isolated runtime-root MCP job workspace. "type": "object" }, "failure_stage": { - "type": "string" + "type": [ + "string", + "null" + ] }, "geometry_diagnostics": { "additionalProperties": false, @@ -2800,7 +2840,10 @@ Render a CSV-backed graph in an isolated runtime-root MCP job workspace. "type": "array" }, "resolution_hint": { - "type": "string" + "type": [ + "string", + "null" + ] }, "script_output": { "items": { @@ -4276,7 +4319,10 @@ Render a multi-panel CSV-backed composite figure in an isolated runtime-root MCP "type": "object" }, "failure_stage": { - "type": "string" + "type": [ + "string", + "null" + ] }, "geometry_diagnostics": { "additionalProperties": false, @@ -4496,7 +4542,10 @@ Render a multi-panel CSV-backed composite figure in an isolated runtime-root MCP "type": "object" }, "resolution_hint": { - "type": "string" + "type": [ + "string", + "null" + ] }, "script_output": { "items": { @@ -4603,6 +4652,7 @@ Render one configured project figure in an isolated runtime-root MCP job workspa }, "overwrite": { "default": false, + "description": "Replace the existing isolated MCP job workspace only; never overwrite the durable project output.", "type": "boolean" }, "profile": { @@ -4682,6 +4732,43 @@ Render one configured project figure in an isolated runtime-root MCP job workspa }, "type": "array" }, + "durable_result": { + "additionalProperties": false, + "properties": { + "reason": { + "type": [ + "string", + "null" + ] + }, + "reason_code": { + "type": [ + "string", + "null" + ] + }, + "relative_path": { + "type": [ + "string", + "null" + ] + }, + "source": { + "enum": [ + "durable_project_result" + ], + "type": "string" + }, + "status": { + "enum": [ + "promoted", + "not_promoted" + ], + "type": "string" + } + }, + "type": "object" + }, "error_category": { "enum": [ "validation", @@ -4704,7 +4791,10 @@ Render one configured project figure in an isolated runtime-root MCP job workspa "type": "object" }, "failure_stage": { - "type": "string" + "type": [ + "string", + "null" + ] }, "figure_metadata": { "type": "object" @@ -4905,6 +4995,12 @@ Render one configured project figure in an isolated runtime-root MCP job workspa "output_path": { "type": "string" }, + "overwrite_scope": { + "enum": [ + "job_workspace_only" + ], + "type": "string" + }, "policy_context": { "additionalProperties": false, "properties": { @@ -5008,6 +5104,19 @@ Render one configured project figure in an isolated runtime-root MCP job workspa "promotion_eligible": { "type": "boolean" }, + "promotion_reason": { + "type": [ + "string", + "null" + ] + }, + "promotion_status": { + "enum": [ + "promoted", + "not_promoted" + ], + "type": "string" + }, "provenance": { "type": "object" }, @@ -5019,7 +5128,54 @@ Render one configured project figure in an isolated runtime-root MCP job workspa "type": "string" }, "resolution_hint": { - "type": "string" + "type": [ + "string", + "null" + ] + }, + "runtime_artifact": { + "additionalProperties": false, + "properties": { + "project_relative_path": { + "type": [ + "string", + "null" + ] + }, + "relative_path": { + "type": [ + "string", + "null" + ] + }, + "sha256": { + "pattern": "^[0-9a-fA-F]{64}$", + "type": [ + "string", + "null" + ] + }, + "source": { + "enum": [ + "runtime_snapshot" + ], + "type": "string" + }, + "status": { + "enum": [ + "created", + "unavailable" + ], + "type": "string" + }, + "uri": { + "type": [ + "string", + "null" + ] + } + }, + "type": "object" }, "script_output": { "items": { @@ -5042,6 +5198,9 @@ Render one configured project figure in an isolated runtime-root MCP job workspa "source_project_path": { "type": "string" }, + "source_unchanged": { + "type": "boolean" + }, "status": { "enum": [ "ok", @@ -5293,7 +5452,10 @@ Return artifact metadata for a completed MCP render job. "type": "array" }, "failure_stage": { - "type": "string" + "type": [ + "string", + "null" + ] }, "figure_metadata": { "type": "object" @@ -5402,7 +5564,10 @@ Return artifact metadata for a completed MCP render job. "type": "object" }, "resolution_hint": { - "type": "string" + "type": [ + "string", + "null" + ] }, "script_output": { "items": { @@ -5504,7 +5669,10 @@ Evaluate an existing render job manifest into a read-only publication-readiness "type": "array" }, "failure_stage": { - "type": "string" + "type": [ + "string", + "null" + ] }, "is_dry_run": { "type": "boolean" @@ -5537,7 +5705,10 @@ Evaluate an existing render job manifest into a read-only publication-readiness "type": "object" }, "resolution_hint": { - "type": "string" + "type": [ + "string", + "null" + ] }, "script_output": { "items": { @@ -5674,7 +5845,10 @@ Plan or create a standard FigOps project scaffold. "type": "array" }, "failure_stage": { - "type": "string" + "type": [ + "string", + "null" + ] }, "is_dry_run": { "type": "boolean" @@ -5719,7 +5893,10 @@ Plan or create a standard FigOps project scaffold. "type": "string" }, "resolution_hint": { - "type": "string" + "type": [ + "string", + "null" + ] }, "script_output": { "items": { @@ -5893,7 +6070,10 @@ Propose migration mappings or apply an explicitly reviewed copy-only structure p "type": "array" }, "failure_stage": { - "type": "string" + "type": [ + "string", + "null" + ] }, "is_dry_run": { "type": "boolean" @@ -5950,7 +6130,10 @@ Propose migration mappings or apply an explicitly reviewed copy-only structure p "type": "object" }, "resolution_hint": { - "type": "string" + "type": [ + "string", + "null" + ] }, "rollback_journal": { "type": "object" @@ -6114,7 +6297,10 @@ Run a bounded project discovery and validation batch check with optional runtime "type": "array" }, "failure_stage": { - "type": "string" + "type": [ + "string", + "null" + ] }, "is_dry_run": { "type": "boolean" @@ -6150,7 +6336,10 @@ Run a bounded project discovery and validation batch check with optional runtime "type": "string" }, "resolution_hint": { - "type": "string" + "type": [ + "string", + "null" + ] }, "resumed_from": { "type": "string" @@ -6415,6 +6604,7 @@ Render one quick CSV chart with raw labels and no statistics DSL. }, "overwrite": { "default": false, + "description": "Replace the existing isolated MCP job workspace only; never overwrite the durable project output.", "type": "boolean" }, "plot_type": { @@ -6503,6 +6693,12 @@ Render one quick CSV chart with raw labels and no statistics DSL. "null" ] }, + "failure_stage": { + "type": [ + "string", + "null" + ] + }, "job_id": { "type": "string" }, @@ -6521,6 +6717,12 @@ Render one quick CSV chart with raw labels and no statistics DSL. "null" ] }, + "resolution_hint": { + "type": [ + "string", + "null" + ] + }, "schema_version": { "type": "string" }, @@ -6588,6 +6790,7 @@ Render one configured project-local .py or .R figure; code and command strings a }, "overwrite": { "default": false, + "description": "Replace the existing isolated MCP job workspace only; never overwrite the durable project output.", "type": "boolean" }, "project_id": { @@ -6643,6 +6846,50 @@ Render one configured project-local .py or .R figure; code and command strings a "null" ] }, + "durable_result": { + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "reason": { + "type": [ + "string", + "null" + ] + }, + "reason_code": { + "type": [ + "string", + "null" + ] + }, + "relative_path": { + "type": [ + "string", + "null" + ] + }, + "source": { + "enum": [ + "durable_project_result" + ], + "type": "string" + }, + "status": { + "enum": [ + "promoted", + "not_promoted" + ], + "type": "string" + } + }, + "type": "object" + }, + { + "type": "null" + } + ] + }, "errors": { "items": { "type": "string" @@ -6655,6 +6902,12 @@ Render one configured project-local .py or .R figure; code and command strings a "null" ] }, + "failure_stage": { + "type": [ + "string", + "null" + ] + }, "job_id": { "type": "string" }, @@ -6667,6 +6920,12 @@ Render one configured project-local .py or .R figure; code and command strings a "manual_review_needed": { "type": "boolean" }, + "overwrite_scope": { + "enum": [ + "job_workspace_only" + ], + "type": "string" + }, "policy_context": { "additionalProperties": false, "properties": { @@ -6761,12 +7020,92 @@ Render one configured project-local .py or .R figure; code and command strings a "null" ] }, + "promotion_eligible": { + "type": "boolean" + }, + "promotion_reason": { + "type": [ + "string", + "null" + ] + }, + "promotion_status": { + "enum": [ + "promoted", + "not_promoted", + null + ], + "type": [ + "string", + "null" + ] + }, + "resolution_hint": { + "type": [ + "string", + "null" + ] + }, + "runtime_artifact": { + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "project_relative_path": { + "type": [ + "string", + "null" + ] + }, + "relative_path": { + "type": [ + "string", + "null" + ] + }, + "sha256": { + "pattern": "^[0-9a-fA-F]{64}$", + "type": [ + "string", + "null" + ] + }, + "source": { + "enum": [ + "runtime_snapshot" + ], + "type": "string" + }, + "status": { + "enum": [ + "created", + "unavailable" + ], + "type": "string" + }, + "uri": { + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + { + "type": "null" + } + ] + }, "runtime_availability": { "type": "object" }, "schema_version": { "type": "string" }, + "source_unchanged": { + "type": "boolean" + }, "status": { "enum": [ "ok", @@ -6933,7 +7272,7 @@ Render one configured project-local .py or .R figure; code and command strings a ### `figops.audit_artifact` -Audit validated completed-job evidence with zero or more explicit policy packs. +Audit validated completed-job evidence with zero or more explicit policy packs. The public publication-readiness-v1 pack projects internally to publication-readiness-v2; v2 is not a public enum value. Required geometry marked not_applicable remains unresolved and requires review. **Input schema** @@ -6948,6 +7287,7 @@ Audit validated completed-job evidence with zero or more explicit policy packs. }, "policy_packs": { "default": [], + "description": "Public policy-pack identifiers only. publication-readiness-v1 is projected internally to publication-readiness-v2; the internal id must not be supplied by callers.", "items": { "enum": [ "publication-readiness-v1" diff --git a/hub_core/artifact_audit.py b/hub_core/artifact_audit.py index d7cad98..184eb47 100644 --- a/hub_core/artifact_audit.py +++ b/hub_core/artifact_audit.py @@ -4,6 +4,12 @@ always evaluates immutable integrity facts, while geometry, presentation, and other readiness judgments are evaluated only when the caller selects a closed policy pack. Automatic audit results always require human review. + +The stable public policy-pack identifier is "publication-readiness-v1". The +audit adapter projects that public name to the internal +"publication-readiness-v2" policy projection before delegating evaluation; the +v2 identifier is an implementation detail and is not accepted as a public +policy_packs value. """ from __future__ import annotations diff --git a/hub_core/geometry_artist_overlaps.py b/hub_core/geometry_artist_overlaps.py index db65ca4..ea792d5 100644 --- a/hub_core/geometry_artist_overlaps.py +++ b/hub_core/geometry_artist_overlaps.py @@ -101,6 +101,221 @@ def _line_overlap_boxes(ax: Axes, line: Any) -> list[Bbox]: return boxes +def _segment_bbox_intersection_length( + start: np.ndarray, + end: np.ndarray, + box: Bbox, +) -> float: + """Return the positive-length intersection of a segment and a box. + + ``_line_overlap_boxes`` intentionally models a line as a padded AABB for + the legacy IoU check. That approximation is too coarse for a thin line: + its box has a tiny area relative to a text bbox even when the line runs + directly through the text. This helper instead clips the *centerline* + against the text bbox (Liang--Barsky), so corner/edge contacts do not + become crossings and a line only counts when it spends measurable length + inside the text box. + """ + + x0, y0 = float(start[0]), float(start[1]) + x1, y1 = float(end[0]), float(end[1]) + dx = x1 - x0 + dy = y1 - y0 + segment_length = float(np.hypot(dx, dy)) + if not np.isfinite(segment_length) or segment_length <= GEOM_EPS_PX: + return 0.0 + + # Parametric clipping against x >= box.x0, x <= box.x1, + # y >= box.y0, and y <= box.y1. Keeping the interval in [0, 1] + # avoids constructing an expanded bbox (which would reintroduce + # linewidth-dependent false positives). + lower = 0.0 + upper = 1.0 + for p, q in ( + (-dx, x0 - float(box.x0)), + (dx, float(box.x1) - x0), + (-dy, y0 - float(box.y0)), + (dy, float(box.y1) - y0), + ): + if abs(p) <= np.finfo(float).eps: + if q < 0: + return 0.0 + continue + ratio = q / p + if p < 0: + if ratio > upper: + return 0.0 + lower = max(lower, ratio) + else: + if ratio < lower: + return 0.0 + upper = min(upper, ratio) + if upper <= lower: + return 0.0 + clipped_length = segment_length * (upper - lower) + if clipped_length <= GEOM_EPS_PX: + return 0.0 + return float(clipped_length) + + +def _line_display_segments(ax: Axes, line: Any, *, max_segments: int) -> list[tuple[int, np.ndarray, np.ndarray]]: + """Return finite display-space line segments with stable source indices. + + NaN-separated paths are not joined. A finite segment cap keeps this + metric bounded for a pathological polyline while retaining the original + segment index in every reported fact. + """ + + if max_segments <= 0: + return [] + try: + xy = np.asarray(line.get_xydata(), dtype=float) + except (TypeError, ValueError): + return [] + if xy.ndim != 2 or xy.shape[1] < 2 or xy.shape[0] < 2: + return [] + # The path cap also bounds the transform itself; do not materialize a + # million-vertex polyline merely to retain the first bounded candidates. + xy = xy[: max_segments + 1, :2] + + try: + transform = line.get_transform() + except (AttributeError, RuntimeError): + transform = ax.transData + if transform is None or not hasattr(transform, "transform"): + transform = ax.transData + try: + # ``axhline``/``axvline`` use blended axis/data transforms; using the + # line's own transform keeps those reference lines correct when the + # data limits are not the default 0..1 range. + display = np.asarray(transform.transform(xy[:, :2]), dtype=float) + except (TypeError, ValueError, OverflowError, RuntimeError): + return [] + if display.shape != (xy.shape[0], 2): + return [] + + segments: list[tuple[int, np.ndarray, np.ndarray]] = [] + for segment_index, (start, end) in enumerate(zip(display[:-1], display[1:])): + if not (np.all(np.isfinite(start)) and np.all(np.isfinite(end))): + continue + segments.append((int(segment_index), start, end)) + if len(segments) >= max_segments: + break + return segments + + +def _line_text_crossings( + ax: Axes, + renderer: Any, + *, + is_paintable: PaintablePredicate, + candidate_cap: int, + reported_cap: int, +) -> dict[str, Any]: + """Measure high-confidence line-segment/text-bbox crossings. + + The result is deliberately a policy-neutral fact object: it reports what + was measured and bounded, but never a threshold, verdict, or severity. + Lines and text are considered in stable Matplotlib artist order. A + segment counts only when its centerline has more than ``GEOM_EPS_PX`` of + positive-length intersection with a visible, non-empty text bbox. + """ + + if candidate_cap <= 0 or reported_cap <= 0: + raise ValueError("candidate_cap and reported_cap must be positive") + + from matplotlib.text import Text + + line_entries: list[tuple[int, str, Any]] = [] + for line_index, line in enumerate(ax.get_lines()): + if is_paintable(line): + line_entries.append((int(line_index), f"line:{line_index}", line)) + text_entries: list[tuple[int, str, Bbox]] = [] + + def add_text(text: Any, fallback: str, text_index: int) -> None: + if not isinstance(text, Text) or not is_paintable(text) or not text.get_text(): + return + bb = _extent(text, renderer) + if bb is None or _box_area(bb) <= 0: + return + text_entries.append((int(text_index), _artist_label(text, fallback), bb)) + + add_text(ax.title, "title", 0) + for text_index, text in enumerate(ax.texts, start=1): + add_text(text, f"text:{text_index - 1}", text_index) + + evaluated_lines = line_entries[:candidate_cap] + evaluated_texts = text_entries[:candidate_cap] + text_bounds = ( + np.asarray( + [ + [float(text_box.x0), float(text_box.y0), float(text_box.x1), float(text_box.y1)] + for _index, _label, text_box in evaluated_texts + ], + dtype=float, + ) + if evaluated_texts + else np.empty((0, 4), dtype=float) + ) + crossing_count = 0 + crossings: list[dict[str, Any]] = [] + # A line with millions of vertices must not turn a diagnostics call into + # an unbounded walk. Use the same candidate cap for vertices and retain + # the original segment index for deterministic evidence. + for line_index, line_label, line in evaluated_lines: + segments = _line_display_segments(ax, line, max_segments=candidate_cap) + for segment_index, start, end in segments: + segment_length = float(np.hypot(*(end - start))) + if text_bounds.size == 0: + continue + segment_x0 = min(float(start[0]), float(end[0])) + segment_x1 = max(float(start[0]), float(end[0])) + segment_y0 = min(float(start[1]), float(end[1])) + segment_y1 = max(float(start[1]), float(end[1])) + # Most segments are nowhere near most text bboxes. Keep the + # exact Liang--Barsky predicate below, but cheaply cull disjoint + # AABBs in NumPy first to avoid an O(lines*segments*texts) Python + # loop for dense figures. + possible_texts = np.flatnonzero( + (text_bounds[:, 2] > segment_x0) + & (text_bounds[:, 0] < segment_x1) + & (text_bounds[:, 3] > segment_y0) + & (text_bounds[:, 1] < segment_y1) + ) + for text_position in possible_texts: + text_index, text_label, text_box = evaluated_texts[int(text_position)] + intersection_length = _segment_bbox_intersection_length(start, end, text_box) + if intersection_length <= GEOM_EPS_PX: + continue + crossing_count += 1 + if len(crossings) >= reported_cap: + continue + crossings.append( + { + "line": line_label, + "text": text_label, + "line_index": int(line_index), + "segment_index": int(segment_index), + "text_index": int(text_index), + "intersection_length_px": round(float(intersection_length), 6), + "segment_length_px": round(float(segment_length), 6), + } + ) + + return { + "line_count": int(len(line_entries)), + "evaluated_line_count": int(len(evaluated_lines)), + "lines_truncated": bool(len(line_entries) > len(evaluated_lines)), + "text_count": int(len(text_entries)), + "evaluated_text_count": int(len(evaluated_texts)), + "texts_truncated": bool(len(text_entries) > len(evaluated_texts)), + "crossing_count": int(crossing_count), + "reported_crossing_count": int(len(crossings)), + "crossings": crossings, + "crossings_truncated": bool(crossing_count > len(crossings)), + } + + def _artist_overlap_candidates( ax: Axes, renderer: Any, diff --git a/hub_core/geometry_raw_contract.py b/hub_core/geometry_raw_contract.py index 6807792..4e7e2d6 100644 --- a/hub_core/geometry_raw_contract.py +++ b/hub_core/geometry_raw_contract.py @@ -148,6 +148,7 @@ def threshold_neutral_geometry_measurements( from .geometry_artist_overlaps import ( _artist_overlap_candidate_items, _is_reportable_artist_overlap, + _line_text_crossings, ) from .geometry_overlay_contrast import ( _artist_rgb, @@ -238,6 +239,27 @@ def threshold_neutral_geometry_measurements( ) ) + # IoU is intentionally retained above for the legacy artist-overlap + # projection. A thin line has negligible area relative to a text + # bbox, though, so the raw v2 surface also carries an exact + # centerline-vs-bbox segment intersection fact. This measurement is + # policy-neutral: it contains bounded geometry evidence only and does + # not alter the legacy ``artist_overlaps`` verdict. + measurements.append( + _available_measurement( + "line_text_crossings", + axis_index, + "structured", + _line_text_crossings( + ax, + renderer, + is_paintable=is_paintable, + candidate_cap=candidate_cap, + reported_cap=reported_cap, + ), + ) + ) + texts = [ text for text in ax.texts diff --git a/hub_core/geometry_tick_labels.py b/hub_core/geometry_tick_labels.py index c1f9f1e..b1fcb22 100644 --- a/hub_core/geometry_tick_labels.py +++ b/hub_core/geometry_tick_labels.py @@ -17,6 +17,42 @@ def _visible_tick_labels(labels: list[Any], *, is_paintable: PaintablePredicate) return [label for label in labels if label.get_text() and is_paintable(label)] +def _axis_tick_labels(ax: Any, axis: str, *, minor: bool = False) -> list[Any]: + """Return major or minor labels without changing the public major index space. + + ``Axes.get_*ticklabels()`` defaults to major ticks. Keeping this small + compatibility helper here lets the overlap metric inspect labels that are + actually painted by a minor formatter while preserving the historical + ``x_overlap_pairs``/``y_overlap_pairs`` indices for major labels. + """ + + getter = ax.get_xticklabels if axis == "x" else ax.get_yticklabels + try: + return list(getter(minor=minor)) + except TypeError: + # A minimal test double (or an older Matplotlib facade) may not expose + # the ``minor`` keyword. Its default remains the major-label path. + return list(getter()) + + +def _offset_text_evidence(axis_obj: Any, *, is_paintable: PaintablePredicate) -> dict[str, Any]: + """Expose the rendered offset text as policy-neutral, deterministic evidence.""" + + getter = getattr(axis_obj, "get_offset_text", None) + if getter is None: + return {"text": "", "visible": False, "paintable": False, "displayed": False} + offset = getter() + if offset is None: + return {"text": "", "visible": False, "paintable": False, "displayed": False} + text = str(offset.get_text() or "") + return { + "text": text, + "visible": bool(offset.get_visible()), + "paintable": bool(is_paintable(offset)), + "displayed": bool(text and is_paintable(offset)), + } + + def _truncate_pairs(pairs: list[list[int]], *, max_reported_pairs: int) -> tuple[list[list[int]], bool]: if len(pairs) > max_reported_pairs: return pairs[:max_reported_pairs], True @@ -33,14 +69,38 @@ def _tick_label_overlaps( max_reported_pairs: int, ) -> dict[str, Any]: name = "tick_label_overlaps" + x_major_labels = _visible_tick_labels( + _axis_tick_labels(ax, "x", minor=False), is_paintable=is_paintable + ) + y_major_labels = _visible_tick_labels( + _axis_tick_labels(ax, "y", minor=False), is_paintable=is_paintable + ) + x_minor_labels = _visible_tick_labels( + _axis_tick_labels(ax, "x", minor=True), is_paintable=is_paintable + ) + y_minor_labels = _visible_tick_labels( + _axis_tick_labels(ax, "y", minor=True), is_paintable=is_paintable + ) x_pairs = _axis_tick_overlaps( - _visible_tick_labels(list(ax.get_xticklabels()), is_paintable=is_paintable), + x_major_labels, renderer, "x", max_text_artists=max_text_artists, ) y_pairs = _axis_tick_overlaps( - _visible_tick_labels(list(ax.get_yticklabels()), is_paintable=is_paintable), + y_major_labels, + renderer, + "y", + max_text_artists=max_text_artists, + ) + x_minor_pairs = _axis_tick_overlaps( + x_minor_labels, + renderer, + "x", + max_text_artists=max_text_artists, + ) + y_minor_pairs = _axis_tick_overlaps( + y_minor_labels, renderer, "y", max_text_artists=max_text_artists, @@ -51,9 +111,26 @@ def _tick_label_overlaps( "passed": None, "detail": f"skipped: text artist count exceeds cap {max_text_artists}", "data": {"axis_index": int(axis_index)}, - } + } x_pairs, x_truncated = _truncate_pairs(x_pairs, max_reported_pairs=max_reported_pairs) y_pairs, y_truncated = _truncate_pairs(y_pairs, max_reported_pairs=max_reported_pairs) + if x_minor_pairs is not None: + x_minor_pairs, x_minor_truncated = _truncate_pairs( + x_minor_pairs, max_reported_pairs=max_reported_pairs + ) + else: + x_minor_truncated = False + if y_minor_pairs is not None: + y_minor_pairs, y_minor_truncated = _truncate_pairs( + y_minor_pairs, max_reported_pairs=max_reported_pairs + ) + else: + y_minor_truncated = False + x_minor_label_texts = [str(label.get_text()) for label in x_minor_labels[:max_text_artists]] + y_minor_label_texts = [str(label.get_text()) for label in y_minor_labels[:max_text_artists]] + # Deliberately keep ``passed`` and its count major-only for compatibility. + # Minor evidence is additive and policy-neutral; callers can decide whether + # a journal policy should suppress it without changing generic diagnostics. count = len(x_pairs) + len(y_pairs) return { "name": name, @@ -65,6 +142,18 @@ def _tick_label_overlaps( "y_overlap_pairs": y_pairs, "x_overlap_pairs_truncated": bool(x_truncated), "y_overlap_pairs_truncated": bool(y_truncated), + "x_minor_labels": x_minor_label_texts, + "y_minor_labels": y_minor_label_texts, + "x_minor_label_count": len(x_minor_labels), + "y_minor_label_count": len(y_minor_labels), + "x_minor_labels_truncated": len(x_minor_labels) > len(x_minor_label_texts), + "y_minor_labels_truncated": len(y_minor_labels) > len(y_minor_label_texts), + "x_minor_overlap_pairs": x_minor_pairs, + "y_minor_overlap_pairs": y_minor_pairs, + "x_minor_overlap_pairs_truncated": bool(x_minor_truncated), + "y_minor_overlap_pairs_truncated": bool(y_minor_truncated), + "x_offset_text": _offset_text_evidence(ax.xaxis, is_paintable=is_paintable), + "y_offset_text": _offset_text_evidence(ax.yaxis, is_paintable=is_paintable), }, } diff --git a/hub_core/journal_geometry_policy.py b/hub_core/journal_geometry_policy.py index 2502598..1c34017 100644 --- a/hub_core/journal_geometry_policy.py +++ b/hub_core/journal_geometry_policy.py @@ -100,6 +100,13 @@ def _positive_number(value: Any) -> float | None: def _unavailable_results(reason: str) -> list[dict[str, Any]]: + """Represent missing required geometry as review-needed, never as a pass. + + not_applicable is an availability state, not a successful check. The + artifact-policy projection uses the required enforcement marker and its + aggregate status to keep these results at needs_review until a reasoned + observation is available. + """ return [ _result(check_id, "style_geometry_observations", "not_applicable", None, expected, reason) for check_id, expected in ( diff --git a/hub_core/mcp/render_geometry_schemas.py b/hub_core/mcp/render_geometry_schemas.py index b71dd63..ab4ca68 100644 --- a/hub_core/mcp/render_geometry_schemas.py +++ b/hub_core/mcp/render_geometry_schemas.py @@ -13,6 +13,7 @@ "style_geometry_observations", "text_axis_edge_distances", "artist_pair_iou", + "line_text_crossings", "annotation_overlay_contrast_ratios", ) diff --git a/hub_core/mcp/render_response.py b/hub_core/mcp/render_response.py index 68f3c98..bb9adda 100644 --- a/hub_core/mcp/render_response.py +++ b/hub_core/mcp/render_response.py @@ -3,7 +3,9 @@ from __future__ import annotations import json +import re from collections.abc import Mapping, Sequence +from pathlib import PurePosixPath from typing import Any, Final from urllib.parse import quote @@ -14,6 +16,7 @@ MAX_RESPONSE_WARNINGS: Final = 24 MAX_RESPONSE_TEXT: Final = 512 _RASTER_MEDIA: Final = {"image/png", "image/jpeg", "image/webp"} +_SHA256: Final = re.compile(r"^[0-9a-fA-F]{64}$") def one_render_response(tool_name: str, result: Mapping[str, Any]) -> dict[str, Any]: @@ -45,6 +48,7 @@ def one_render_response(tool_name: str, result: Mapping[str, Any]) -> dict[str, "reason": _text(runtime_availability.get("reason")), } _add_project_render_context(response, result) + _add_project_result_contract(response, result) return _bounded(response) evidence = normalize_evidence_envelope(evidence_raw) @@ -66,6 +70,7 @@ def one_render_response(tool_name: str, result: Mapping[str, Any]) -> dict[str, "resolution_hint": None, } _add_project_render_context(response, result) + _add_project_result_contract(response, result) return _bounded(response) @@ -170,6 +175,86 @@ def _add_project_render_context(response: dict[str, Any], result: Mapping[str, A response[key] = dict(value) +def _safe_relative_path(value: Any) -> str | None: + """Return a project/runtime-relative path, rejecting host paths and traversal.""" + + if not isinstance(value, str) or not value.strip(): + return None + normalized = value.replace("\\", "/") + path = PurePosixPath(normalized) + if path.is_absolute() or any(part in {"", ".", ".."} for part in path.parts): + return None + # Windows drive-qualified paths are not relative even when parsed as POSIX. + if len(normalized) >= 2 and normalized[1] == ":": + return None + return path.as_posix() + + +def _project_result_contract(value: Any) -> dict[str, Any] | None: + if not isinstance(value, Mapping): + return None + status = value.get("status") + if status not in {"created", "unavailable"}: + return None + uri = value.get("uri") + if not isinstance(uri, str) or not uri.startswith("runtime://"): + uri = None + elif _safe_relative_path(uri.removeprefix("runtime://")) is None: + uri = None + digest = value.get("sha256") + if not isinstance(digest, str) or _SHA256.fullmatch(digest) is None: + digest = None + return { + "status": status, + "uri": uri, + "relative_path": _safe_relative_path(value.get("relative_path")), + "project_relative_path": _safe_relative_path(value.get("project_relative_path")), + "sha256": digest.lower() if digest else None, + "source": "runtime_snapshot", + } + + +def _durable_result_contract(value: Any) -> dict[str, Any] | None: + if not isinstance(value, Mapping): + return None + status = value.get("status") + if status not in {"promoted", "not_promoted"}: + return None + reason_code = value.get("reason_code") + if reason_code is not None: + reason_code = _text(reason_code) + reason = value.get("reason") + if reason is not None: + reason = _text(reason) + return { + "status": status, + "relative_path": _safe_relative_path(value.get("relative_path")), + "reason_code": reason_code, + "reason": reason, + "source": "durable_project_result", + } + + +def _add_project_result_contract(response: dict[str, Any], result: Mapping[str, Any]) -> None: + """Preserve the explicit runtime/durable distinction on the compact v2 surface.""" + + runtime_artifact = _project_result_contract(result.get("runtime_artifact")) + durable_result = _durable_result_contract(result.get("durable_result")) + if runtime_artifact is not None: + response["runtime_artifact"] = runtime_artifact + if durable_result is not None: + response["durable_result"] = durable_result + if isinstance(result.get("promotion_eligible"), bool): + response["promotion_eligible"] = result["promotion_eligible"] + if isinstance(result.get("source_unchanged"), bool): + response["source_unchanged"] = result["source_unchanged"] + if result.get("overwrite_scope") == "job_workspace_only": + response["overwrite_scope"] = "job_workspace_only" + if durable_result is not None: + response["promotion_status"] = durable_result["status"] + response["promotion_reason"] = durable_result["reason"] + + def _bounded(response: dict[str, Any]) -> dict[str, Any]: encoded = json.dumps(response, ensure_ascii=False, separators=(",", ":")).encode("utf-8") if len(encoded) > MAX_RENDER_RESPONSE_BYTES: diff --git a/hub_core/mcp/schemas.py b/hub_core/mcp/schemas.py index 6f85282..d66b5e0 100644 --- a/hub_core/mcp/schemas.py +++ b/hub_core/mcp/schemas.py @@ -88,6 +88,30 @@ MCP_BATCH_MAX_PROJECTS = 50 +_RUNTIME_ARTIFACT_CONTRACT_SCHEMA = { + "type": "object", + "properties": { + "status": {"type": "string", "enum": ["created", "unavailable"]}, + "uri": {"type": ["string", "null"]}, + "relative_path": {"type": ["string", "null"]}, + "project_relative_path": {"type": ["string", "null"]}, + "sha256": {"type": ["string", "null"], "pattern": "^[0-9a-fA-F]{64}$"}, + "source": {"type": "string", "enum": ["runtime_snapshot"]}, + }, + "additionalProperties": False, +} +_DURABLE_RESULT_CONTRACT_SCHEMA = { + "type": "object", + "properties": { + "status": {"type": "string", "enum": ["promoted", "not_promoted"]}, + "relative_path": {"type": ["string", "null"]}, + "reason_code": {"type": ["string", "null"]}, + "reason": {"type": ["string", "null"]}, + "source": {"type": "string", "enum": ["durable_project_result"]}, + }, + "additionalProperties": False, +} + def describe_figops_surface() -> dict[str, Any]: return { @@ -158,7 +182,13 @@ def list_tool_definitions( _standard_output_schema( { "hub_path": {"type": "string"}, - "version": {"type": "string"}, + "version": { + "type": "string", + "description": ( + "Installed FigOps package version. This is independent of audit policy " + "projection identifiers." + ), + }, "python_executable": {"type": "string"}, "runtime_root": {"type": "string"}, "style_format_count": {"type": "integer"}, @@ -538,7 +568,14 @@ def list_tool_definitions( }, "output_format": {"type": "string", "enum": sorted(ALLOWED_OUTPUT_FORMATS)}, "dry_run": {"type": "boolean", "default": False}, - "overwrite": {"type": "boolean", "default": False}, + "overwrite": { + "type": "boolean", + "default": False, + "description": ( + "Replace the existing isolated MCP job workspace only; " + "never overwrite the durable project output." + ), + }, "job_id": job_id_arg, "max_depth": {"type": "integer", "minimum": 1, "maximum": 12, "default": 4}, "baseline_path": baseline_path_arg, @@ -566,6 +603,12 @@ def list_tool_definitions( "claim_inventory": {"type": "object"}, "publication_status": {"type": "string", "enum": ["verified", "unverified"]}, "promotion_eligible": {"type": "boolean"}, + "runtime_artifact": _RUNTIME_ARTIFACT_CONTRACT_SCHEMA, + "durable_result": _DURABLE_RESULT_CONTRACT_SCHEMA, + "promotion_status": {"type": "string", "enum": ["promoted", "not_promoted"]}, + "promotion_reason": {"type": ["string", "null"]}, + "source_unchanged": {"type": "boolean"}, + "overwrite_scope": {"type": "string", "enum": ["job_workspace_only"]}, "artifact_status": {"type": "string"}, "baseline_comparison": {"type": "object"}, "provenance": {"type": "object"}, diff --git a/hub_core/mcp/tool_schema_common.py b/hub_core/mcp/tool_schema_common.py index 25cef6e..1a11c96 100644 --- a/hub_core/mcp/tool_schema_common.py +++ b/hub_core/mcp/tool_schema_common.py @@ -144,8 +144,8 @@ def standard_output_schema(extra_properties: dict[str, Any] | None = None) -> di "error_category": {"type": "string", "enum": ["validation", "not_found", "internal", "disabled"]}, "error_code": {"type": "string"}, "jsonrpc_code": {"type": "integer"}, - "failure_stage": {"type": "string"}, - "resolution_hint": {"type": "string"}, + "failure_stage": {"type": ["string", "null"]}, + "resolution_hint": {"type": ["string", "null"]}, "manifest_path": {"type": "string"}, "status_path": {"type": "string"}, "latest_alias": {"type": "string"}, diff --git a/hub_core/mcp/tools/render_project.py b/hub_core/mcp/tools/render_project.py index 2e6626f..6fe131d 100644 --- a/hub_core/mcp/tools/render_project.py +++ b/hub_core/mcp/tools/render_project.py @@ -22,6 +22,7 @@ from hub_core.mcp import render_orchestration as render_helpers from hub_core.mcp import render_project_integrity_context as integrity_context from hub_core.mcp.errors import PROJECT_DECLARATION_PATH_INVALID, has_unsafe_declared_path +from hub_core.mcp.tools.render_project_contract import project_render_result_contract from hub_core.project_paths import ProjectPathError, resolve_project_input, resolve_project_output from hub_core.provenance_inputs import expand_project_input_files, resolved_research_ops_evidence from hub_core.render_evidence import build_render_evidence @@ -32,6 +33,8 @@ class McpRenderProjectMixin: """Project-figure rendering MCP tool handlers.""" + _project_render_result_contract = project_render_result_contract + def render_project_figure(self, arguments: dict[str, Any]) -> dict[str, Any]: arguments = dict(arguments) guarded = self._authorize_write_tool("figops.render_project_figure", arguments) @@ -156,7 +159,6 @@ def render_project_figure(self, arguments: dict[str, Any]) -> dict[str, Any]: preflight_valid = validate_data_contract_preflight( project_path, config, - # Full validation below performs the single guarded prefetch/read. require_existing=False, prefetcher=adapters.prefetcher, raise_path_contract_errors=True, @@ -215,8 +217,7 @@ def render_project_figure(self, arguments: dict[str, Any]) -> dict[str, Any]: job_id=job_id, job_root=job_root, summary="Project data contract failed before rendering.", - # Preserve the public failure distinction while the guarded - # full validator owns the single prefetch/read operation. + # Preserve the public failure distinction; the guarded validator owns the single prefetch/read. errors=[ "Data contract preflight failed for project render." if input_unavailable @@ -344,10 +345,11 @@ def render_project_figure(self, arguments: dict[str, Any]) -> dict[str, Any]: latest_dir = self.runtime_root / "_latest" / "mcp_project_render" project_id = self._stable_project_id_for_path(project_path) if dry_run: + result_contract = self._project_render_result_contract(job_root=job_root, snapshot_project_path=snapshot_project_path, output_path=output_path, output_relpath=output_relpath, promotion_eligible=False, workflow_intent=workflow_intent, dry_run=True) # noqa: E501 return self._envelope( "figops.render_project_figure", arguments, - summary="Project figure render validated in dry-run mode; no files were created.", + summary="Project figure render validated in dry-run mode; no files were created. The source project is unchanged; overwrite applies to the job workspace only.", # noqa: E501 is_dry_run=True, job_id=job_id, project_id=project_id, @@ -366,6 +368,8 @@ def render_project_figure(self, arguments: dict[str, Any]) -> dict[str, Any]: geometry_diagnostics=render_helpers._geometry_stub("dry_run"), layout_report=render_helpers._layout_report_from_geometry(render_helpers._geometry_stub("dry_run")), artifact_status="validated", + promotion_eligible=False, + **result_contract, baseline_comparison=self._baseline_comparison(None, arguments.get("baseline_path")), provenance={"attempt": attempt}, policy_context=policy_context, @@ -375,13 +379,6 @@ def render_project_figure(self, arguments: dict[str, Any]) -> dict[str, Any]: ) claim_inventory = self._project_claim_inventory(project_path, selected) claim_warnings = [f"Claim inventory: {message}" for message in claim_inventory["errors"]] - job_root = self._mcp_project_jobs_root() / job_id - snapshot_project_path = job_root / "project" - output_path = snapshot_project_path / output_relpath - config_path = snapshot_project_path / config_relpath - manifest_path = job_root / "manifest.json" - status_path = job_root / "status.json" - latest_dir = self.runtime_root / "_latest" / "mcp_project_render" if job_root.exists() and not overwrite: return self._project_render_error( arguments, @@ -391,10 +388,14 @@ def render_project_figure(self, arguments: dict[str, Any]) -> dict[str, Any]: summary="Project render job already exists.", errors=[ f"Project render job already exists: {self._runtime_uri(job_root)}. " - "Set overwrite=true to replace it." + "Set overwrite=true to replace the job workspace only; " + "the durable project output is never overwritten." ], failure_stage="EXPORT", - resolution_hint="Set overwrite=true to replace the existing MCP project render job.", + resolution_hint=( + "Set overwrite=true to replace the existing MCP job workspace; " + "this flag never overwrites the durable project output." + ), project_id=project_id, source_project_path=source_project_path, snapshot_project_path=str(snapshot_project_path), @@ -467,9 +468,7 @@ def render_project_figure(self, arguments: dict[str, Any]) -> dict[str, Any]: require_matches=True, ) external_inputs = materialize_external_raw_inputs( - # Authority/boundary checks bind to the durable source project. - # The snapshot is itself disposable runtime state and must not - # be treated as a durable project root overlapping runtime. + # Authority checks bind to the durable source; the disposable snapshot must not overlap runtime. project_root=project_path, config=config, declarations=input_declarations, @@ -669,6 +668,7 @@ def render_project_figure(self, arguments: dict[str, Any]) -> dict[str, Any]: ) from exc if promoted is not None: created_paths.extend(str(item.path) for item in promoted) + result_contract = self._project_render_result_contract(job_root=job_root, snapshot_project_path=snapshot_project_path, output_path=output_path, output_relpath=output_relpath, promotion_eligible=bool(manifest["promotion_eligible"]), promoted=promoted, workflow_intent=workflow_intent) # noqa: E501 except Exception as exc: if isinstance(exc, TimeoutError): failure_stage = "TIMEOUT" @@ -753,9 +753,7 @@ def render_project_figure(self, arguments: dict[str, Any]) -> dict[str, Any]: "figops.render_project_figure", arguments, status=status, - summary=( - "Rendered project figure." if status == "ok" else "Rendered project figure with preflight warnings." - ), + summary=("Rendered project figure." if status == "ok" else "Rendered project figure with preflight warnings.") + " The source project is unchanged; overwrite applies to the job workspace only.", # noqa: E501 created_paths=created_paths, artifact_resources=preview_references["artifact_resources"], preview_resources=preview_references["preview_resources"], @@ -794,6 +792,7 @@ def render_project_figure(self, arguments: dict[str, Any]) -> dict[str, Any]: claim_inventory=claim_inventory, publication_status=manifest["publication_status"], promotion_eligible=manifest["promotion_eligible"], + **result_contract, failure_stage="", resolution_hint="", ) diff --git a/hub_core/mcp/tools/render_project_contract.py b/hub_core/mcp/tools/render_project_contract.py new file mode 100644 index 0000000..bfe81c2 --- /dev/null +++ b/hub_core/mcp/tools/render_project_contract.py @@ -0,0 +1,97 @@ +"""Result-contract construction for project render responses. + +The render-project façade owns orchestration and authorization. This module +owns only the path-safe projection of runtime and durable-result state. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any, Callable + + +def project_render_result_contract(mixin: Any, **kwargs: Any) -> dict[str, Any]: + return build_project_render_result_contract( + **kwargs, + public_runtime_path=mixin._public_runtime_path, + file_sha256=mixin._file_sha256, + ) + + +def build_project_render_result_contract( + *, + job_root: Path, + snapshot_project_path: Path, + output_path: Path, + output_relpath: str, + promotion_eligible: bool, + public_runtime_path: Callable[[Path], str], + file_sha256: Callable[[Path], str], + promoted: Any = None, + workflow_intent: dict[str, Any] | None = None, + dry_run: bool = False, +) -> dict[str, Any]: + """Describe runtime output and durable-result state without host paths. + + ``output_path`` is represented by a runtime URI. The project-relative + path remains a non-authoritative label so consumers cannot mistake a + disposable snapshot for the durable project result. + """ + + del snapshot_project_path # retained in the façade call contract for compatibility + try: + project_relative_path = Path(output_relpath).as_posix() + runtime_relative_path = output_path.absolute().relative_to(job_root.absolute()).as_posix() + except (OSError, ValueError): + project_relative_path = str(output_relpath).replace("\\", "/") + runtime_relative_path = "" + + runtime_uri = public_runtime_path(output_path) + artifact_exists = output_path.is_file() + artifact_sha256: str | None = None + if artifact_exists: + try: + artifact_sha256 = file_sha256(output_path) + except OSError: + artifact_exists = False + runtime_artifact = { + "status": "created" if artifact_exists else "unavailable", + "uri": runtime_uri or None, + "relative_path": runtime_relative_path or f"project/{project_relative_path}", + "project_relative_path": project_relative_path, + "sha256": artifact_sha256, + "source": "runtime_snapshot", + } + + durable_status = "promoted" if promoted is not None else "not_promoted" + if durable_status == "promoted": + durable_reason_code = None + durable_reason = None + elif dry_run: + durable_reason_code = "DRY_RUN" + durable_reason = "Dry-run validation creates no runtime bytes or durable project result." + elif isinstance(workflow_intent, dict) and workflow_intent.get("intent") == "exploration": + durable_reason_code = "EXPLORATION_NON_PROMOTABLE" + durable_reason = "Exploration renders remain runtime-only and are never promotable." + elif promotion_eligible: + durable_reason_code = "PROMOTION_NOT_PERFORMED" + durable_reason = "The result passed eligibility but no durable promotion was performed." + else: + durable_reason_code = "PROMOTION_NOT_ELIGIBLE" + durable_reason = "Promotion eligibility was not satisfied; the durable project result was unchanged." + + durable_result = { + "status": durable_status, + "relative_path": project_relative_path if durable_status == "promoted" else None, + "reason_code": durable_reason_code, + "reason": durable_reason, + "source": "durable_project_result", + } + return { + "runtime_artifact": runtime_artifact, + "durable_result": durable_result, + "promotion_status": durable_status, + "promotion_reason": durable_reason, + "source_unchanged": True, + "overwrite_scope": "job_workspace_only", + } diff --git a/hub_core/mcp/v2_tool_schemas.py b/hub_core/mcp/v2_tool_schemas.py index 15ec77d..d9fedf3 100644 --- a/hub_core/mcp/v2_tool_schemas.py +++ b/hub_core/mcp/v2_tool_schemas.py @@ -17,6 +17,25 @@ def build_v2_tool_definitions( project_path_arg: dict[str, Any], selector_one_of: list[dict[str, Any]], ) -> list[ToolDefinition]: + runtime_artifact = object_schema( + { + "status": {"type": "string", "enum": ["created", "unavailable"]}, + "uri": {"type": ["string", "null"]}, + "relative_path": {"type": ["string", "null"]}, + "project_relative_path": {"type": ["string", "null"]}, + "sha256": {"type": ["string", "null"], "pattern": "^[0-9a-fA-F]{64}$"}, + "source": {"type": "string", "enum": ["runtime_snapshot"]}, + } + ) + durable_result = object_schema( + { + "status": {"type": "string", "enum": ["promoted", "not_promoted"]}, + "relative_path": {"type": ["string", "null"]}, + "reason_code": {"type": ["string", "null"]}, + "reason": {"type": ["string", "null"]}, + "source": {"type": "string", "enum": ["durable_project_result"]}, + } + ) render_output = object_schema( { "schema_version": {"type": "string"}, @@ -31,6 +50,8 @@ def build_v2_tool_definitions( "warnings": {"type": "array", "items": {"type": "string"}}, "errors": {"type": "array", "items": {"type": "string"}}, "manual_review_needed": {"type": "boolean"}, + "failure_stage": {"type": ["string", "null"]}, + "resolution_hint": {"type": ["string", "null"]}, } ) project_render_output = { @@ -38,6 +59,16 @@ def build_v2_tool_definitions( "properties": { **render_output["properties"], "runtime_availability": {"type": "object"}, + "promotion_eligible": {"type": "boolean"}, + "runtime_artifact": {"anyOf": [runtime_artifact, {"type": "null"}]}, + "durable_result": {"anyOf": [durable_result, {"type": "null"}]}, + "promotion_status": { + "type": ["string", "null"], + "enum": ["promoted", "not_promoted", None], + }, + "promotion_reason": {"type": ["string", "null"]}, + "source_unchanged": {"type": "boolean"}, + "overwrite_scope": {"type": "string", "enum": ["job_workspace_only"]}, "policy_context": RENDER_POLICY_CONTEXT_SCHEMA, "workflow_intent": WORKFLOW_INTENT_SCHEMA, }, @@ -148,7 +179,14 @@ def build_v2_tool_definitions( "default": "png", }, "job_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$", "maxLength": 80}, - "overwrite": {"type": "boolean", "default": False}, + "overwrite": { + "type": "boolean", + "default": False, + "description": ( + "Replace the existing isolated MCP job workspace only; " + "never overwrite the durable project output." + ), + }, }, required=["data_path", "x", "y"], ), @@ -176,7 +214,14 @@ def build_v2_tool_definitions( ), }, "job_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$", "maxLength": 80}, - "overwrite": {"type": "boolean", "default": False}, + "overwrite": { + "type": "boolean", + "default": False, + "description": ( + "Replace the existing isolated MCP job workspace only; " + "never overwrite the durable project output." + ), + }, } ), "oneOf": selector_one_of, @@ -185,12 +230,22 @@ def build_v2_tool_definitions( ), ToolDefinition( "figops.audit_artifact", - "Audit validated completed-job evidence with zero or more explicit policy packs.", + ( + "Audit validated completed-job evidence with zero or more explicit policy packs. " + "The public publication-readiness-v1 pack projects internally to " + "publication-readiness-v2; v2 is not a public enum value. " + "Required geometry marked not_applicable remains unresolved and requires review." + ), object_schema( { "job_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$", "maxLength": 80}, "policy_packs": { "type": "array", + "description": ( + "Public policy-pack identifiers only. publication-readiness-v1 is " + "projected internally to publication-readiness-v2; the internal id " + "must not be supplied by callers." + ), "items": {"type": "string", "enum": sorted(SUPPORTED_POLICY_PACKS)}, "maxItems": len(SUPPORTED_POLICY_PACKS), "uniqueItems": True, diff --git a/pyproject.toml b/pyproject.toml index a352a25..609f34b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "figops" -version = "0.20.1" +version = "0.20.2" description = "Reproducible plotting and analysis pipeline for research projects" readme = "README.md" requires-python = ">=3.12" diff --git a/scripts/check_geometry_rubric_map.py b/scripts/check_geometry_rubric_map.py index 975374c..393f017 100644 --- a/scripts/check_geometry_rubric_map.py +++ b/scripts/check_geometry_rubric_map.py @@ -7,14 +7,18 @@ from __future__ import annotations import json +import sys from dataclasses import dataclass from enum import StrEnum, unique from pathlib import Path from typing import Final, Mapping, assert_never -from hub_core.mcp.render_geometry_schemas import LEGACY_GEOMETRY_METRIC_NAMES as GEOMETRY_METRIC_NAMES - HUB_ROOT: Final = Path(__file__).resolve().parent.parent +if str(HUB_ROOT) not in sys.path: + sys.path.insert(0, str(HUB_ROOT)) + +from hub_core.mcp.render_geometry_schemas import LEGACY_GEOMETRY_METRIC_NAMES as GEOMETRY_METRIC_NAMES # noqa: E402 + MAP_PATH: Final = HUB_ROOT / "docs" / "specs" / "geometry-diagnostic-rubric-map.json" QA_PATH: Final = HUB_ROOT / "docs" / "QA.md" RUBRIC_PATH: Final = HUB_ROOT / "docs" / "specs" / "2026-06-30-figure-quality-rubric.md" diff --git a/tests/fixtures/ai_native_agent_eval/final-v2.json b/tests/fixtures/ai_native_agent_eval/final-v2.json index 55cf049..1adf502 100644 --- a/tests/fixtures/ai_native_agent_eval/final-v2.json +++ b/tests/fixtures/ai_native_agent_eval/final-v2.json @@ -19,9 +19,9 @@ "figops.audit_artifact" ], "emitted_tool_definition_count": 7, - "tools_list_response_bytes": 14477, - "tools_array_bytes": 14433, - "maximum_input_schema_bytes": 1102, + "tools_list_response_bytes": 16451, + "tools_array_bytes": 16407, + "maximum_input_schema_bytes": 1216, "maximum_input_schema_tool": "figops.render_basic_csv", "render_basic_csv_top_level_properties": 12 }, @@ -34,8 +34,8 @@ "figops.audit_artifact" ], "emitted_tool_definition_count": 5, - "tools_list_response_bytes": 8334, - "tools_array_bytes": 8290 + "tools_list_response_bytes": 8861, + "tools_array_bytes": 8817 }, "compatibility_surface": { "canonical_tool_count": 14, @@ -45,18 +45,18 @@ "generated_references": { "default_full": { "path": "docs/tools.md", - "bytes": 167680, - "lines": 8161 + "bytes": 174368, + "lines": 8501 }, "v2": { "path": "docs/tools-v2.md", - "bytes": 23642, - "lines": 1298 + "bytes": 27435, + "lines": 1462 }, "compatibility": { "path": "docs/tools-compatibility.md", - "bytes": 136939, - "lines": 6219 + "bytes": 140088, + "lines": 6408 } }, "guided_call_contract": { diff --git a/tests/fixtures/visual_regression/m4_1_multiseries_broken_axis.png b/tests/fixtures/visual_regression/m4_1_multiseries_broken_axis.png index c75fa1d..6ecce11 100644 Binary files a/tests/fixtures/visual_regression/m4_1_multiseries_broken_axis.png and b/tests/fixtures/visual_regression/m4_1_multiseries_broken_axis.png differ diff --git a/tests/fixtures/visual_regression/m4_2_box_plot.png b/tests/fixtures/visual_regression/m4_2_box_plot.png index d72beb9..1d51662 100644 Binary files a/tests/fixtures/visual_regression/m4_2_box_plot.png and b/tests/fixtures/visual_regression/m4_2_box_plot.png differ diff --git a/tests/fixtures/visual_regression/m4_2_facet_plot.png b/tests/fixtures/visual_regression/m4_2_facet_plot.png index a243f18..40c492c 100644 Binary files a/tests/fixtures/visual_regression/m4_2_facet_plot.png and b/tests/fixtures/visual_regression/m4_2_facet_plot.png differ diff --git a/tests/fixtures/visual_regression/m4_2_grouped_bar_aggregate.png b/tests/fixtures/visual_regression/m4_2_grouped_bar_aggregate.png index 8c2bb65..be45235 100644 Binary files a/tests/fixtures/visual_regression/m4_2_grouped_bar_aggregate.png and b/tests/fixtures/visual_regression/m4_2_grouped_bar_aggregate.png differ diff --git a/tests/fixtures/visual_regression/m4_2_violin_plot.png b/tests/fixtures/visual_regression/m4_2_violin_plot.png index f463d1f..468d520 100644 Binary files a/tests/fixtures/visual_regression/m4_2_violin_plot.png and b/tests/fixtures/visual_regression/m4_2_violin_plot.png differ diff --git a/tests/test_issue230_geometry.py b/tests/test_issue230_geometry.py new file mode 100644 index 0000000..f0e63b0 --- /dev/null +++ b/tests/test_issue230_geometry.py @@ -0,0 +1,157 @@ +import json +import unittest + +import matplotlib + +matplotlib.use("Agg") +import matplotlib.pyplot as plt # noqa: E402 +import numpy as np # noqa: E402 +from matplotlib.transforms import Bbox # noqa: E402 + +from hub_core.geometry_artist_overlaps import ( # noqa: E402 + _line_text_crossings, + _segment_bbox_intersection_length, +) +from hub_core.geometry_diagnostics import ( + _is_paintable, # noqa: E402 + diagnose_figure_geometry, # noqa: E402 +) + + +class Issue230GeometryTests(unittest.TestCase): + def tearDown(self): + plt.close("all") + + def test_thin_line_crossing_is_available_in_raw_v2_without_changing_legacy_artist_overlap(self): + fig, ax = plt.subplots(figsize=(8, 2)) + # Only the final few pixels enter the text bbox. The padded line AABB + # therefore stays below the legacy relative-overlap cutoff while the + # centerline crossing remains a real, positive-length fact. + ax.plot([0.1, 0.49], [0.5, 0.5], linewidth=0.5) + ax.text(0.5, 0.5, "on line", ha="center", va="center") + fig.canvas.draw() + + legacy = diagnose_figure_geometry(fig, [ax], layout_locked=False) + artist_check = next(item for item in legacy["checks"] if item["name"] == "artist_overlaps") + self.assertTrue(artist_check["passed"]) + self.assertEqual(artist_check["data"]["overlaps"], []) + self.assertNotIn("line_text_crossings", artist_check["data"]) + + raw = diagnose_figure_geometry(fig, [ax], layout_locked=False, contract_version="raw") + measurement = next( + item for item in raw["measurements"] if item["metric_id"] == "line_text_crossings[axis=0]" + ) + self.assertEqual(measurement["availability"], "available") + self.assertEqual(measurement["value"]["crossing_count"], 1) + self.assertEqual(measurement["value"]["reported_crossing_count"], 1) + self.assertEqual(measurement["value"]["crossings"][0]["line"], "line:0") + self.assertTrue(measurement["value"]["crossings"][0]["text"].startswith("text:")) + self.assertNotIn("threshold", json.dumps(measurement)) + self.assertNotIn("passed", json.dumps(measurement)) + + def test_bbox_clipping_rejects_edge_contact_and_short_numerical_touch(self): + box = Bbox.from_extents(10, 10, 20, 20) + self.assertEqual( + _segment_bbox_intersection_length(np.array((0.0, 0.0)), np.array((10.0, 10.0)), box), + 0.0, + ) + self.assertEqual( + _segment_bbox_intersection_length(np.array((0.0, 15.0)), np.array((10.5, 15.0)), box), + 0.0, + ) + self.assertAlmostEqual( + _segment_bbox_intersection_length(np.array((0.0, 15.0)), np.array((30.0, 15.0)), box), + 10.0, + ) + + def test_nan_separated_paths_do_not_join_across_gap(self): + fig, ax = plt.subplots() + ax.plot([0.1, 0.4, np.nan, 0.6, 0.9], [0.5, 0.5, np.nan, 0.5, 0.5]) + ax.text(0.5, 0.5, "gap", ha="center", va="center") + fig.canvas.draw() + + facts = _line_text_crossings( + ax, + fig.canvas.get_renderer(), + is_paintable=_is_paintable, + candidate_cap=200, + reported_cap=50, + ) + self.assertEqual(facts["crossing_count"], 0) + + def test_reference_lines_use_their_blended_transform_with_nondefault_limits(self): + for reference_kind in ("h", "v"): + with self.subTest(reference_kind=reference_kind): + fig, ax = plt.subplots() + ax.set_xlim(10, 20) + ax.set_ylim(100, 200) + if reference_kind == "h": + ax.axhline(150, linewidth=0.5) + else: + ax.axvline(15, linewidth=0.5) + ax.text(15, 150, "reference", ha="center", va="center") + fig.canvas.draw() + + facts = _line_text_crossings( + ax, + fig.canvas.get_renderer(), + is_paintable=_is_paintable, + candidate_cap=200, + reported_cap=50, + ) + self.assertEqual(facts["crossing_count"], 1) + + def test_crossings_are_stable_and_bounded_by_caps(self): + fig, ax = plt.subplots() + for y in (0.3, 0.5, 0.7): + ax.plot([0.1, 0.9], [y, y]) + for y in (0.3, 0.5, 0.7): + ax.text(0.5, y, f"line-{y}", ha="center", va="center") + fig.canvas.draw() + renderer = fig.canvas.get_renderer() + + first = _line_text_crossings( + ax, + renderer, + is_paintable=_is_paintable, + candidate_cap=2, + reported_cap=1, + ) + second = _line_text_crossings( + ax, + renderer, + is_paintable=_is_paintable, + candidate_cap=2, + reported_cap=1, + ) + self.assertEqual(first, second) + self.assertEqual(first["line_count"], 3) + self.assertEqual(first["evaluated_line_count"], 2) + self.assertTrue(first["lines_truncated"]) + self.assertEqual(first["text_count"], 3) + self.assertEqual(first["evaluated_text_count"], 2) + self.assertTrue(first["texts_truncated"]) + self.assertLessEqual(first["reported_crossing_count"], 1) + self.assertTrue(first["crossings_truncated"]) + json.dumps(first) + + def test_invisible_text_is_not_a_crossing_candidate(self): + fig, ax = plt.subplots() + ax.plot([0.1, 0.9], [0.5, 0.5]) + text = ax.text(0.5, 0.5, "hidden", ha="center", va="center") + text.set_alpha(0.0) + fig.canvas.draw() + + facts = _line_text_crossings( + ax, + fig.canvas.get_renderer(), + is_paintable=_is_paintable, + candidate_cap=200, + reported_cap=50, + ) + self.assertEqual(facts["text_count"], 0) + self.assertEqual(facts["crossing_count"], 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_issue232_layout.py b/tests/test_issue232_layout.py new file mode 100644 index 0000000..2c94c05 --- /dev/null +++ b/tests/test_issue232_layout.py @@ -0,0 +1,140 @@ +"""Regression coverage for issue #232's fixed-width Nature exports.""" + +from __future__ import annotations + +import matplotlib + +matplotlib.use("Agg") +import matplotlib.pyplot as plt +import pytest +from PIL import Image + +import themes.journal_theme as journal_theme +from themes.journal_theme import apply_journal_theme, mm_to_inch, save_journal_fig + + +@pytest.fixture(autouse=True) +def _restore_journal_state(): + saved_rc = plt.rcParams.copy() + saved_state = { + name: getattr(journal_theme, name) + for name in ( + "_ACTIVE_TARGET_FORMAT", + "_ACTIVE_FONT_TOKENS", + "_ACTIVE_COMPLIANCE_TOKENS", + "_ACTIVE_COMPLIANCE_MODE", + ) + } + yield + plt.close("all") + plt.rcParams.update(saved_rc) + for name, value in saved_state.items(): + setattr(journal_theme, name, value) + + +def _render_labeled_figure(path, label: str, **save_kwargs) -> tuple[int, int]: + """Render an unlocked 180 mm figure with an artist outside the axes box.""" + fig, ax = plt.subplots(figsize=(mm_to_inch(180.0), mm_to_inch(100.0))) + fig.subplots_adjust(left=0.12, right=0.97, bottom=0.14, top=0.92) + ax.plot([0.0, 1.0], [0.0, 1.0]) + ax.text(1.2, 0.5, label, transform=ax.transAxes, clip_on=False) + save_journal_fig(fig, path, dpi=600, tiff_companion=False, **save_kwargs) + with Image.open(path) as image: + return image.size + + +def _render_direct_labeled_figure(path, label: str, **save_kwargs) -> tuple[int, int]: + """Render through Matplotlib directly, exercising the theme rcParams.""" + fig, ax = plt.subplots(figsize=(mm_to_inch(180.0), mm_to_inch(100.0))) + fig.subplots_adjust(left=0.12, right=0.97, bottom=0.14, top=0.92) + ax.plot([0.0, 1.0], [0.0, 1.0]) + ax.text(1.2, 0.5, label, transform=ax.transAxes, clip_on=False) + fig.savefig(path, dpi=600, **save_kwargs) + with Image.open(path) as image: + return image.size + + +def test_nature_auto_policy_keeps_png_canvas_width_when_label_changes(tmp_path): + apply_journal_theme("nature") + + short_size = _render_labeled_figure(tmp_path / "short.png", "A") + long_size = _render_labeled_figure( + tmp_path / "long.png", + "A very long label that would expand a tight bounding box substantially", + ) + + expected_width_px = int(mm_to_inch(180.0) * 600) + assert short_size[0] == long_size[0] == expected_width_px + assert short_size[1] == long_size[1] + assert short_size[0] * 25.4 / 600 == pytest.approx(180.0, abs=0.1) + + +def test_nature_rc_default_keeps_direct_png_canvas_width_when_label_changes(tmp_path): + apply_journal_theme("nature") + + short_size = _render_direct_labeled_figure(tmp_path / "direct-short.png", "A") + long_size = _render_direct_labeled_figure( + tmp_path / "direct-long.png", + "A very long label that would expand a tight bounding box substantially", + ) + + expected_width_px = int(mm_to_inch(180.0) * 600) + assert short_size[0] == long_size[0] == expected_width_px + + +def test_tight_bbox_remains_an_explicit_opt_in_for_nature(tmp_path): + apply_journal_theme("nature") + + short_size = _render_labeled_figure(tmp_path / "tight-short.png", "A", bbox_policy="tight") + long_size = _render_labeled_figure( + tmp_path / "tight-long.png", + "A very long label that would expand a tight bounding box substantially", + bbox_inches="tight", + ) + + assert long_size[0] > short_size[0] + + +def test_direct_tight_bbox_remains_an_explicit_opt_in_for_nature(tmp_path): + apply_journal_theme("nature") + + short_size = _render_direct_labeled_figure(tmp_path / "direct-tight-short.png", "A", bbox_inches="tight") + long_size = _render_direct_labeled_figure( + tmp_path / "direct-tight-long.png", + "A very long label that would expand a tight bounding box substantially", + bbox_inches="tight", + ) + + assert long_size[0] > short_size[0] + + +def test_fixed_bbox_policy_can_opt_in_for_non_nature_tracks(tmp_path): + apply_journal_theme("science") + + size = _render_labeled_figure( + tmp_path / "science-fixed.png", + "A very long label that would otherwise expand a tight bounding box", + bbox_policy="fixed", + ) + + assert size[0] == int(mm_to_inch(180.0) * 600) + + +def test_non_nature_theme_keeps_legacy_tight_rc_default(tmp_path): + apply_journal_theme("science") + assert plt.rcParams["savefig.bbox"] == "tight" + + short_size = _render_direct_labeled_figure(tmp_path / "science-tight-short.png", "A") + long_size = _render_direct_labeled_figure( + tmp_path / "science-tight-long.png", + "A very long label that expands the tight bounding box", + ) + + assert long_size[0] > short_size[0] + + +def test_bbox_policy_rejects_unknown_values(tmp_path): + apply_journal_theme("nature") + + with pytest.raises(ValueError, match="bbox_policy"): + _render_labeled_figure(tmp_path / "invalid.png", "A", bbox_policy="crop") diff --git a/tests/test_issue232_runtime_contract.py b/tests/test_issue232_runtime_contract.py new file mode 100644 index 0000000..4b72fa2 --- /dev/null +++ b/tests/test_issue232_runtime_contract.py @@ -0,0 +1,201 @@ +from __future__ import annotations + +import hashlib +from pathlib import Path +from types import SimpleNamespace + +from hub_core.mcp.render_response import _project_result_contract +from hub_core.mcp.schemas import list_tool_definitions +from hub_core.mcp.server import FigOpsMCPServer + + +def _write_project(root: Path, *, workflow_intent: str | None = None) -> Path: + project = root / "01_contract" + (project / "hub_scripts").mkdir(parents=True) + (project / "results" / "data").mkdir(parents=True) + (project / "results" / "data" / "summary.csv").write_text("x,y\n0,1\n1,2\n", encoding="utf-8") + workflow = f"workflow:\n intent: {workflow_intent}\n" if workflow_intent else "" + (project / "hub_scripts" / "plot.py").write_text( + "from pathlib import Path\n" + "from PIL import Image\n" + "Path('results/figures').mkdir(parents=True, exist_ok=True)\n" + "Image.new('RGB', (32, 24), 'navy').save('results/figures/Fig1.png', format='PNG')\n", + encoding="utf-8", + ) + (project / "project_config.yaml").write_text( + f""" +project: + name: Contract Fixture +{workflow}visual_style: + target_format: nature + profile: baseline +sample_registry: + - sample_id: S1 +experimental_conditions: + conditions: + - id: condition_a +data_contract: + csv_checks: + - path: results/data/summary.csv + required_columns: [x, y] + dtypes: {{x: number, y: number}} +figures: + - id: Fig1 + script: hub_scripts/plot.py + inputs: [results/data/summary.csv] + output: results/figures/Fig1.png + claim: Contract fixture render completes. + samples: [S1] + conditions: [condition_a] +""", + encoding="utf-8", + ) + return project + + +def _render(server: FigOpsMCPServer, project: Path, job_id: str) -> dict: + return server.render_project_script( + { + "project_path": str(project), + "figure_id": "Fig1", + "style_policy": "nature", + "job_id": job_id, + } + ) + + +def test_v2_render_response_distinguishes_runtime_only_result_and_preserves_source(tmp_path: Path) -> None: + project = _write_project(tmp_path / "ResearchOS") + before = hashlib.sha256((project / "project_config.yaml").read_bytes()).hexdigest() + runtime = tmp_path / "runtime" + server = FigOpsMCPServer( + research_root=tmp_path / "ResearchOS", + runtime_root=runtime, + write_tools_enabled=True, + surface_profile="v2", + ) + + result = _render(server, project, "contract-runtime-only") + + assert result["status"] in {"ok", "warning"} + assert result["promotion_eligible"] is False + assert result["source_unchanged"] is True + assert result["overwrite_scope"] == "job_workspace_only" + runtime_artifact = result["runtime_artifact"] + assert runtime_artifact["status"] == "created" + assert runtime_artifact["source"] == "runtime_snapshot" + assert runtime_artifact["uri"].startswith("runtime://") + assert str(runtime) not in runtime_artifact["uri"] + assert len(runtime_artifact["sha256"]) == 64 + durable = result["durable_result"] + assert durable["status"] == "not_promoted" + assert durable["relative_path"] is None + assert durable["reason_code"] == "PROMOTION_NOT_ELIGIBLE" + assert hashlib.sha256((project / "project_config.yaml").read_bytes()).hexdigest() == before + + +def test_exploration_render_is_explicitly_non_promotable(tmp_path: Path) -> None: + project = _write_project(tmp_path / "ResearchOS", workflow_intent="exploration") + server = FigOpsMCPServer( + research_root=tmp_path / "ResearchOS", + runtime_root=tmp_path / "runtime", + write_tools_enabled=True, + surface_profile="v2", + ) + + result = _render(server, project, "contract-exploration") + + assert result["status"] in {"ok", "warning"} + assert result["promotion_eligible"] is False + assert result["durable_result"]["status"] == "not_promoted" + assert result["durable_result"]["reason_code"] == "EXPLORATION_NON_PROMOTABLE" + assert result["promotion_status"] == "not_promoted" + + +def test_eligible_render_reports_durable_result_without_host_path(monkeypatch, tmp_path: Path) -> None: + project = _write_project(tmp_path / "ResearchOS") + runtime = tmp_path / "runtime" + server = FigOpsMCPServer( + research_root=tmp_path / "ResearchOS", + runtime_root=runtime, + write_tools_enabled=True, + surface_profile="v2", + ) + monkeypatch.setattr( + "hub_core.mcp.tools.render_project.integrity_context.decide_project_render_promotion_eligibility", + lambda **_kwargs: { + "manual_review_needed": False, + "policy_review_needed": False, + "projection_ready": True, + "promotion_eligible": True, + "workflow_execution_allowed": True, + "workflow_review_needed": False, + }, + ) + durable_output = project / "results" / "figures" / "Fig1.png" + durable_receipt = project / "results" / "evidence" / "Fig1.receipt.json" + monkeypatch.setattr( + "hub_core.mcp.tools.render_project.promote_eligible_project_result", + lambda **_kwargs: ( + SimpleNamespace(path=durable_output), + SimpleNamespace(path=durable_receipt), + ), + ) + + result = _render(server, project, "contract-promoted") + + assert result["promotion_eligible"] is True + assert result["runtime_artifact"]["source"] == "runtime_snapshot" + assert result["durable_result"] == { + "status": "promoted", + "relative_path": "results/figures/Fig1.png", + "reason_code": None, + "reason": None, + "source": "durable_project_result", + } + assert result["source_unchanged"] is True + assert str(tmp_path) not in str(result["durable_result"]) + + +def test_project_render_schema_documents_workspace_only_overwrite() -> None: + definitions = {item["name"]: item for item in list_tool_definitions()} + legacy = definitions["figops.render_project_figure"] + v2 = definitions["figops.render_project_script"] + basic_output = definitions["figops.render_basic_csv"]["outputSchema"]["properties"] + assert "runtime_artifact" not in basic_output + assert "durable_result" not in basic_output + assert basic_output["failure_stage"]["type"] == ["string", "null"] + assert basic_output["resolution_hint"]["type"] == ["string", "null"] + for definition in (legacy, v2): + description = definition["inputSchema"]["properties"]["overwrite"]["description"] + assert "job workspace only" in description + assert "never overwrite" in description + output = legacy["outputSchema"]["properties"] + assert {"runtime_artifact", "durable_result", "source_unchanged", "overwrite_scope"} <= set(output) + assert output["failure_stage"]["type"] == ["string", "null"] + assert output["resolution_hint"]["type"] == ["string", "null"] + + +def test_runtime_uri_sanitization_rejects_host_and_traversal_paths() -> None: + normal = _project_result_contract( + { + "status": "created", + "uri": "runtime://jobs/x/artifact.png", + "relative_path": "project/artifact.png", + "source": "runtime_snapshot", + } + ) + assert normal is not None + assert normal["uri"] == "runtime://jobs/x/artifact.png" + + for malicious in ("runtime://../host", "runtime:///absolute", "runtime://C:/host/file.png"): + result = _project_result_contract( + { + "status": "created", + "uri": malicious, + "relative_path": "project/artifact.png", + "source": "runtime_snapshot", + } + ) + assert result is not None + assert result["uri"] is None diff --git a/tests/test_issue232_ticks.py b/tests/test_issue232_ticks.py new file mode 100644 index 0000000..f11d138 --- /dev/null +++ b/tests/test_issue232_ticks.py @@ -0,0 +1,129 @@ +import matplotlib + +matplotlib.use("Agg") + +import matplotlib.pyplot as plt +from matplotlib.ticker import FuncFormatter, LogFormatterSciNotation + +from hub_core.geometry_diagnostics import diagnose_figure_geometry +from themes.journal_theme import ( + apply_journal_theme, + apply_narrow_log_minor_tick_policy, + save_journal_fig, +) + + +def _draw_narrow_log(target="nature"): + apply_journal_theme(target) + fig, ax = plt.subplots(figsize=(3, 2), dpi=100) + ax.set_xscale("log") + ax.set_xlim(28, 338) + ax.plot([30, 300], [1, 2]) + fig.canvas.draw() + return fig, ax + + +def test_journal_auto_suppresses_only_minor_labels_in_narrow_log_range(): + fig, ax = _draw_narrow_log() + try: + major_before = [label.get_text() for label in ax.get_xticklabels() if label.get_text()] + minor_before = [label.get_text() for label in ax.get_xticklabels(minor=True) if label.get_text()] + + evidence = apply_narrow_log_minor_tick_policy(fig) + fig.canvas.draw() + + assert major_before + # Matplotlib 3.10 does not render default minor labels for this + # one-decade range, while newer releases do. The policy contract is + # the journal evidence and post-policy suppression; pre-policy label + # presence is renderer-version dependent. + assert [label.get_text() for label in ax.get_xticklabels() if label.get_text()] == major_before + if minor_before: + assert [label.get_text() for label in ax.get_xticklabels(minor=True) if label.get_text()] == [] + assert evidence["axes"] == [ + { + "axis_index": 0, + "axis": "x", + "decades": evidence["axes"][0]["decades"], + "applied": True, + "restored": False, + "reason": "narrow_log_range", + } + ] + finally: + plt.close(fig) + + +def test_narrow_log_policy_is_target_scoped_and_preserves_custom_formatter(): + fig, ax = _draw_narrow_log("ppt") + try: + before = ax.xaxis.get_minor_formatter() + before_thresholds = tuple(before.minor_thresholds) + apply_narrow_log_minor_tick_policy(fig, mode=True) + assert ax.xaxis.get_minor_formatter() is before + assert tuple(before.minor_thresholds) == before_thresholds + finally: + plt.close(fig) + + fig, ax = _draw_narrow_log("nature") + try: + custom = FuncFormatter(lambda value, position: f"custom-{value:g}") + ax.xaxis.set_minor_formatter(custom) + evidence = apply_narrow_log_minor_tick_policy(fig) + assert ax.xaxis.get_minor_formatter() is custom + assert evidence["axes"][0]["reason"] == "custom_minor_formatter" + finally: + plt.close(fig) + + +def test_narrow_log_policy_explicit_opt_out_restores_default_formatter(tmp_path): + fig, ax = _draw_narrow_log() + try: + apply_narrow_log_minor_tick_policy(fig) + assert tuple(ax.xaxis.get_minor_formatter().minor_thresholds) == (0.0, 0.0) + save_journal_fig(fig, tmp_path / "opt_out.png", narrow_log_minor_labels=False, dpi=100) + assert tuple(ax.xaxis.get_minor_formatter().minor_thresholds) == (1.0, 0.4) + finally: + plt.close(fig) + + +def test_narrow_log_policy_does_not_rewrite_post_policy_formatter_customization(): + fig, ax = _draw_narrow_log() + try: + apply_narrow_log_minor_tick_policy(fig) + formatter = ax.xaxis.get_minor_formatter() + formatter.minor_thresholds = (7.0, 7.0) + evidence = apply_narrow_log_minor_tick_policy(fig) + assert tuple(formatter.minor_thresholds) == (7.0, 7.0) + assert evidence["axes"][0]["reason"] == "user_modified_formatter" + finally: + plt.close(fig) + + +def test_tick_overlap_evidence_includes_displayed_minor_labels_and_offset_text(): + fig, (log_ax, linear_ax) = plt.subplots(1, 2, figsize=(5, 2), dpi=100) + try: + log_ax.set_xscale("log") + log_ax.set_xlim(28, 338) + # Make the source labels deterministic across Matplotlib releases: + # 3.10's default minor formatter leaves this narrow range blank, + # whereas later releases display labels with the same defaults. + log_ax.xaxis.set_minor_formatter(LogFormatterSciNotation(minor_thresholds=(2.0, 0.4))) + log_ax.plot([30, 300], [1, 2]) + linear_ax.plot([1_000_000, 2_000_000], [1, 2]) + linear_ax.ticklabel_format(axis="x", style="sci", scilimits=(0, 0), useOffset=True) + fig.canvas.draw() + + result = diagnose_figure_geometry(fig, [log_ax, linear_ax], layout_locked=False) + checks = [check for check in result["checks"] if check["name"] == "tick_label_overlaps"] + log_data = checks[0]["data"] + offset_data = checks[1]["data"] + + assert log_data["x_minor_labels"] + assert log_data["x_minor_overlap_pairs"] + # Major indices remain the historical space; minor pairs are additive. + assert log_data["x_overlap_pairs"] == [] + assert set(offset_data["x_offset_text"]) == {"text", "visible", "paintable", "displayed"} + assert offset_data["x_offset_text"]["displayed"] + finally: + plt.close(fig) diff --git a/tests/test_mcp_surface_profiles.py b/tests/test_mcp_surface_profiles.py index a3ae7d7..1d1cd4b 100644 --- a/tests/test_mcp_surface_profiles.py +++ b/tests/test_mcp_surface_profiles.py @@ -1,10 +1,12 @@ from __future__ import annotations import json +import tomllib from pathlib import Path import pytest +from hub_core.artifact_audit import PUBLICATION_READINESS_POLICY from hub_core.mcp import FigOpsMCPServer, GraphHubMCPServer from hub_core.mcp.config import McpServerConfig from hub_core.mcp.schemas import LEGACY_TOOL_NAMES, TOOL_NAMES, list_tool_definitions @@ -107,6 +109,28 @@ def test_writes_disabled_v2_discovery_omits_denied_renders() -> None: assert {"figops.inspect_data", "figops.audit_artifact"} <= set(_names(definitions)) +def test_health_version_is_package_version_not_audit_policy_projection() -> None: + server = FigOpsMCPServer(surface_profile="v2", write_tools_enabled=False) + health = server.call_tool("figops.health", {})["structuredContent"] + package = tomllib.loads((Path(__file__).resolve().parents[1] / "pyproject.toml").read_text(encoding="utf-8")) + + assert health["version"] == package["project"]["version"] + assert health["version"] != "publication-readiness-v2" + + +def test_audit_policy_schema_keeps_public_v1_enum_and_documents_internal_projection() -> None: + definition = next( + item + for item in list_tool_definitions(profile="v2", write_tools_enabled=True) + if item["name"] == "figops.audit_artifact" + ) + schema = definition["inputSchema"]["properties"]["policy_packs"] + + assert schema["items"]["enum"] == [PUBLICATION_READINESS_POLICY] + assert "publication-readiness-v2" in schema["description"] + assert "not_applicable" in definition["description"] + + def test_compatibility_profile_exposes_frozen_fourteen_plus_thirteen() -> None: server = FigOpsMCPServer(surface_profile="compatibility", write_tools_enabled=True) definitions = _tools_list(server) diff --git a/tests/test_release_discipline.py b/tests/test_release_discipline.py index 1bb2c46..b48d190 100644 --- a/tests/test_release_discipline.py +++ b/tests/test_release_discipline.py @@ -28,9 +28,11 @@ def _assert_release_status_matches_post_tag_state(status: str, post_tag_blocker: assert "- Repository release allowed: no" in status assert "- Technical blockers:" in status assert "- Technical blockers: 0" not in status - assert POST_TAG_METADATA_ROW in status if post_tag_blocker is not None: + assert POST_TAG_METADATA_ROW in status assert "Release metadata is stale" in post_tag_blocker + else: + assert POST_TAG_METADATA_ROW not in status def test_package_version_is_semver_and_matches_latest_changelog_entry(): diff --git a/tests/test_render_evidence.py b/tests/test_render_evidence.py index 6e43e0d..fa7994a 100644 --- a/tests/test_render_evidence.py +++ b/tests/test_render_evidence.py @@ -22,6 +22,7 @@ verify_artifact_policy_projection, ) from hub_core.evidence_contract import EvidenceContractError, validate_evidence_envelope +from hub_core.journal_geometry_policy import geometry_minimum_results from hub_core.mcp.render_geometry import _geometry_stub from hub_core.mcp.render_orchestration import _build_preview_artifacts, _preview_resource_references from hub_core.mcp.render_response import audit_response, one_render_response @@ -483,8 +484,15 @@ def test_required_resolution_and_geometry_unavailable_fail_closed( unavailable_results = unavailable["resolved_policy"]["parameters"]["results"] dpi = next(item for item in unavailable_results if item["check_id"] == "dpi") text = next(item for item in unavailable_results if item["check_id"] == "text_geometry") + geometry_results = [ + item for item in unavailable_results if item["metric_id"] == "style_geometry_observations" + ] assert (dpi["status"], dpi["enforcement"]) == ("not_applicable", "required") assert (text["status"], text["enforcement"]) == ("not_applicable", "informational") + assert geometry_results + assert all(item["status"] == "not_applicable" for item in geometry_results) + assert all(item["enforcement"] == "required" for item in geometry_results) + assert all(isinstance(item.get("reason"), str) and item["reason"].strip() for item in geometry_results) measured = tmp_path / "measured.png" Image.new("RGB", (120, 80), "navy").save(measured, format="PNG", dpi=(600, 600)) @@ -503,6 +511,15 @@ def test_required_resolution_and_geometry_unavailable_fail_closed( assert below["policy_projection"]["status"] == "blocked" +def test_required_geometry_not_applicable_has_reason_and_needs_review() -> None: + results = geometry_minimum_results(None, validation_target="nature") + + assert results + assert all(item["status"] == "not_applicable" for item in results) + assert all(item["enforcement"] == "required" for item in results) + assert all(isinstance(item.get("reason"), str) and item["reason"].strip() for item in results) + + def test_pdf_width_and_font_subtype_are_measured_from_existing_bytes(tmp_path: Path) -> None: safe = tmp_path / "safe.pdf" # Keep the fixture's page geometry explicit. Other integration tests may diff --git a/themes/journal_export_policy.py b/themes/journal_export_policy.py new file mode 100644 index 0000000..83d38f4 --- /dev/null +++ b/themes/journal_export_policy.py @@ -0,0 +1,271 @@ +"""Small, state-free export policies used by :mod:`journal_theme`. + +The public theme module remains the compatibility façade. These helpers keep +save-time policy decisions separate from rcParam and figure-token setup while +receiving the active target explicitly, avoiding a second source of theme +state. +""" + +from __future__ import annotations + +import math +import os +import time +from pathlib import Path +from typing import Any + +INTERNAL_STYLE_TARGET_FORMAT = "nature_surfur" +NARROW_LOG_MAX_DECADES = 2.0 +TIFF_AUTO_PRESETS: set[str] = { + "nature", + INTERNAL_STYLE_TARGET_FORMAT, + "science", + "acs", + "rsc", + "elsevier", + "wiley", + "cell", +} + +_JOURNAL_TARGET_FORMATS = frozenset(TIFF_AUTO_PRESETS) +_DEFAULT_LOG_MINOR_THRESHOLDS = (1.0, 0.4) +_NARROW_LOG_POLICY_ATTR = "_graph_hub_narrow_log_minor_policy" +_FIXED_WIDTH_TARGET_FORMATS = frozenset({"nature", "default", INTERNAL_STYLE_TARGET_FORMAT}) +DIAG_BUDGET_FLOOR_SECONDS = 5.0 + + +def _normalize_narrow_log_minor_labels(value: str | bool) -> str: + if isinstance(value, bool): + return "on" if value else "off" + normalized = str(value).strip().lower() + if normalized == "auto": + return "auto" + raise ValueError("narrow_log_minor_labels must be 'auto', True, or False") + + +def _log_axis_decades(axis) -> float | None: + try: + lower, upper = (float(value) for value in axis.get_view_interval()) + base = float(getattr(axis.get_transform(), "base", 10.0)) + if lower <= 0 or upper <= 0 or base <= 1 or not all(math.isfinite(value) for value in (lower, upper, base)): + return None + return abs(math.log(max(lower, upper) / min(lower, upper), base)) + except (AttributeError, TypeError, ValueError, ZeroDivisionError): + return None + + +def _is_default_log_formatter(formatter) -> bool: + """Return True only for Matplotlib's own LogFormatter family.""" + + try: + from matplotlib.ticker import LogFormatter + + return isinstance(formatter, LogFormatter) and type(formatter).__module__ == "matplotlib.ticker" + except (ImportError, TypeError): + return False + + +def _restore_narrow_log_formatter(formatter) -> bool: + prior = getattr(formatter, _NARROW_LOG_POLICY_ATTR, None) + if prior is None: + return False + current = tuple(float(value) for value in getattr(formatter, "minor_thresholds", ())) + if current != (0.0, 0.0): + try: + delattr(formatter, _NARROW_LOG_POLICY_ATTR) + except AttributeError: + pass + return False + formatter.minor_thresholds = tuple(prior) + try: + formatter.set_locs() + except (AttributeError, RuntimeError, ValueError): + pass + try: + delattr(formatter, _NARROW_LOG_POLICY_ATTR) + except AttributeError: + pass + return True + + +def apply_narrow_log_minor_tick_policy( + fig, + *, + mode: str | bool = "auto", + target_format: str | None = None, +) -> dict: + """Apply bounded journal log-minor-label behavior to a live figure.""" + + normalized = _normalize_narrow_log_minor_labels(mode) + target = str(target_format or "").lower() + eligible_target = target in _JOURNAL_TARGET_FORMATS + evidence = { + "mode": normalized, + "target_format": target, + "max_decades": float(NARROW_LOG_MAX_DECADES), + "eligible_target": bool(eligible_target), + "axes": [], + } + for axis_index, ax in enumerate(getattr(fig, "axes", ())): + for axis_name, axis in (("x", ax.xaxis), ("y", ax.yaxis)): + if axis.get_scale() != "log": + continue + formatter = axis.get_minor_formatter() + decades = _log_axis_decades(axis) + axis_evidence = { + "axis_index": int(axis_index), + "axis": axis_name, + "decades": None if decades is None else float(decades), + "applied": False, + "restored": False, + "reason": "", + } + should_apply = ( + normalized != "off" + and eligible_target + and decades is not None + and decades < NARROW_LOG_MAX_DECADES + ) + if not _is_default_log_formatter(formatter): + axis_evidence["reason"] = "custom_minor_formatter" + elif should_apply: + current = tuple(float(value) for value in getattr(formatter, "minor_thresholds", ())) + prior = getattr(formatter, _NARROW_LOG_POLICY_ATTR, None) + if prior is not None and current != (0.0, 0.0): + axis_evidence["reason"] = "user_modified_formatter" + _restore_narrow_log_formatter(formatter) + elif prior is None and current != _DEFAULT_LOG_MINOR_THRESHOLDS: + axis_evidence["reason"] = "nondefault_minor_thresholds" + else: + if prior is None: + setattr(formatter, _NARROW_LOG_POLICY_ATTR, current) + formatter.minor_thresholds = (0.0, 0.0) + try: + formatter.set_locs() + except (AttributeError, RuntimeError, ValueError): + pass + axis_evidence["applied"] = True + axis_evidence["reason"] = "narrow_log_range" + else: + axis_evidence["restored"] = _restore_narrow_log_formatter(formatter) + if normalized == "off": + axis_evidence["reason"] = "explicit_opt_out" + elif not eligible_target: + axis_evidence["reason"] = "non_journal_target" + elif decades is None: + axis_evidence["reason"] = "invalid_log_range" + else: + axis_evidence["reason"] = "range_at_or_above_threshold" + evidence["axes"].append(axis_evidence) + fig._graph_hub_narrow_log_minor_label_policy = evidence + return evidence + + +def resolve_bbox_policy( + *, + target_format: str, + layout_lock: Any, + kwargs: dict[str, Any], + bbox_policy: str | None, +) -> tuple[bool, str]: + """Resolve fixed-canvas vs tight-crop behavior and mutate save kwargs.""" + + normalized = "auto" if bbox_policy is None else str(bbox_policy).strip().lower() + if normalized not in {"auto", "fixed", "tight"}: + raise ValueError("bbox_policy must be 'auto', 'fixed', or 'tight'") + has_explicit_bbox = "bbox_inches" in kwargs + if has_explicit_bbox: + return kwargs.get("bbox_inches") is None, normalized + if normalized == "fixed" or ( + normalized == "auto" and (layout_lock or target_format in _FIXED_WIDTH_TARGET_FORMATS) + ): + return True, normalized + if normalized == "tight" or (normalized == "auto" and target_format != "neutral"): + kwargs["bbox_inches"] = "tight" + return False, normalized + + +def save_auto_tiff_companion( + fig, + file_path: Path, + *, + preset: str | None, + suffix: str, + tiff_companion: bool, + companion_formats: tuple[str, ...], + kwargs: dict[str, Any], + fixed_canvas: bool, +) -> None: + if ( + not tiff_companion + or (preset or "").lower() not in TIFF_AUTO_PRESETS + or suffix == ".tiff" + or "tiff" in companion_formats + ): + return + tiff_kwargs: dict[str, Any] = { + "dpi": 300, + "format": "tiff", + "pil_kwargs": {"compression": "tiff_lzw"}, + } + if not fixed_canvas: + tiff_kwargs["bbox_inches"] = kwargs.get("bbox_inches", "tight") + fig.savefig(str(file_path.with_suffix(".tiff")), **tiff_kwargs) + + +def prepare_save_metadata( + filename, + *, + kwargs: dict[str, Any], + target_format: str, +) -> tuple[Path, str, dict[str, Any]]: + """Normalize deterministic metadata and raster defaults for one save.""" + + metadata = kwargs.pop("metadata", {}) or {} + file_path = Path(filename) + suffix = file_path.suffix.lower() + if target_format != "neutral" and suffix in {".png", ".jpg", ".jpeg", ".tif", ".tiff"}: + kwargs.setdefault("dpi", 600) + metadata.setdefault("Creator", None) + metadata.pop("Producer", None) + metadata.pop("Software", None) + if suffix == ".svg": + metadata.pop("CreationDate", None) + metadata.pop("ModDate", None) + metadata.setdefault("Date", None) + else: + metadata.setdefault("CreationDate", None) + metadata.setdefault("ModDate", None) + return file_path, suffix, metadata + + +def safe_geometry_diagnostics_inline( + fig, + *, + layout_lock_attr: str, + font_token_sizes: list[float], + journal_compliance: Any, +) -> dict: + """Run geometry diagnostics without allowing them to fail a saved figure.""" + + try: + from hub_core.geometry_diagnostics import RAW_SCHEMA_VERSION, diagnose_figure_geometry + + deadline = float(os.environ.get("GEOMETRY_DIAGNOSTICS_DEADLINE", "inf")) + if deadline - time.time() < DIAG_BUDGET_FLOOR_SECONDS: + return {"schema_version": RAW_SCHEMA_VERSION, "measurements": [], "warnings": ["skipped: render budget"]} + data_axes = [ + axis for axis in fig.axes if axis.get_visible() and getattr(axis, "_graph_hub_role", None) != "colorbar" + ] + return diagnose_figure_geometry( + fig, + data_axes, + layout_locked=getattr(fig, layout_lock_attr, None) is not None, + font_token_sizes=font_token_sizes, + journal_compliance=journal_compliance, + contract_version="raw", + ) + except Exception as exc: + from hub_core.geometry_diagnostics import RAW_SCHEMA_VERSION + + return {"schema_version": RAW_SCHEMA_VERSION, "measurements": [], "warnings": [str(exc)]} diff --git a/themes/journal_theme.py b/themes/journal_theme.py index 912d1eb..e87d61b 100644 --- a/themes/journal_theme.py +++ b/themes/journal_theme.py @@ -6,10 +6,10 @@ - target_format 파라미터에 따라 딕셔너리를 로드하고, font_scale에 따라 크기를 보정하는 순수 함수. - 환경 변수 직접 참조를 배제하여 순수 함수(Pure function) 원칙 준수. """ + import copy import json import os -import time from dataclasses import dataclass from pathlib import Path @@ -89,6 +89,19 @@ def get_profile_rc_overrides(profile_name=None): get_legend_args, ) +try: + from . import journal_export_policy as _journal_export_policy +except ImportError: + import journal_export_policy as _journal_export_policy + +NARROW_LOG_MAX_DECADES = _journal_export_policy.NARROW_LOG_MAX_DECADES +TIFF_AUTO_PRESETS = _journal_export_policy.TIFF_AUTO_PRESETS +_apply_narrow_log_minor_tick_policy = _journal_export_policy.apply_narrow_log_minor_tick_policy +prepare_save_metadata = _journal_export_policy.prepare_save_metadata +resolve_bbox_policy = _journal_export_policy.resolve_bbox_policy +save_auto_tiff_companion = _journal_export_policy.save_auto_tiff_companion +_safe_geometry_diagnostics = _journal_export_policy.safe_geometry_diagnostics_inline + __all__ = [ "DOUBLE_COLUMN", "INTERNAL_STYLE_TARGET_FORMAT", @@ -97,6 +110,7 @@ def get_profile_rc_overrides(profile_name=None): "SINGLE_COLUMN", "STYLE_PRESETS", "TIFF_AUTO_PRESETS", + "NARROW_LOG_MAX_DECADES", "_LAYOUT_LOCK_ATTR", "_active_font_token_sizes", "_apply_legacy_publication_layout", @@ -105,6 +119,8 @@ def get_profile_rc_overrides(profile_name=None): "_safe_geometry_diagnostics_inline", "apply_journal_style", "apply_journal_theme", + "apply_narrow_log_minor_tick_policy", + "apply_narrow_log_tick_policy", "apply_panel_grid_layout", "apply_publication_layout", "font_tokens", @@ -120,20 +136,8 @@ def get_profile_rc_overrides(profile_name=None): # ── Nature/Science Standard Widths (mm) ───────────────────────── SINGLE_COLUMN = 89 # mm DOUBLE_COLUMN = 183 # mm -DIAG_BUDGET_FLOOR_SECONDS = 5.0 INTERNAL_STYLE_TARGET_FORMAT = "_".join(("nature", "surfur")) -TIFF_AUTO_PRESETS: set[str] = { - "nature", - INTERNAL_STYLE_TARGET_FORMAT, - "science", - "acs", - "rsc", - "elsevier", - "wiley", - "cell", -} - @dataclass(frozen=True) class FontTokens: @@ -232,7 +236,10 @@ def mm_to_inch(mm): # Output "savefig.dpi": 600, "savefig.format": "pdf", - "savefig.bbox": "tight", + # Nature figures have a publication-width canvas. Tight cropping is + # available through ``save_journal_fig(..., bbox_policy="tight")`` or + # an explicit ``bbox_inches="tight"`` save kwarg. + "savefig.bbox": None, "svg.fonttype": "none", "pdf.fonttype": 42, "ps.fonttype": 42, @@ -465,6 +472,12 @@ def mm_to_inch(mm): } ) +# Keep the historical tight-bbox default for non-Nature journal tracks. Their +# existing render/compatibility paths may rely on crop-to-artists behavior; +# only the Nature-family presets opt into fixed physical canvas dimensions. +for _tight_compatibility_target in ("science", "acs", "rsc", "elsevier", "wiley", "cell"): + STYLE_PRESETS[_tight_compatibility_target]["savefig.bbox"] = "tight" + # Internal project style preset (2026-04-10) # - NatComm 5-7pt strict compliance (title 7.0, legend 6.0) # - 50x50 mm plot box 기준 → spine/tick 약간 굵게 (0.75pt) @@ -627,46 +640,31 @@ def set_figure_size(width_mm, height_mm=None, ratio=0.8): def _safe_geometry_diagnostics_inline(fig) -> dict: - """Run geometry diagnostics in the same frame that holds the live figure. - - Never raises: a diagnostics-engine error degrades to a passed:null stub so the - worker's broad except can never hard-fail an already-saved figure. The wall-clock - budget skip reads ONLY GEOMETRY_DIAGNOSTICS_DEADLINE against a fixed floor; - MCP_RENDER_TIMEOUT_SECONDS is a module constant in mcp_surface and is never in - os.environ, so it must not be read here. - """ - try: - from hub_core.geometry_diagnostics import RAW_SCHEMA_VERSION, diagnose_figure_geometry - - deadline = float(os.environ.get("GEOMETRY_DIAGNOSTICS_DEADLINE", "inf")) - if deadline - time.time() < DIAG_BUDGET_FLOOR_SECONDS: - return { - "schema_version": RAW_SCHEMA_VERSION, - "measurements": [], - "warnings": ["skipped: render budget"], - } - data_axes = [ - axis for axis in fig.axes if axis.get_visible() and getattr(axis, "_graph_hub_role", None) != "colorbar" - ] - layout_locked = getattr(fig, _LAYOUT_LOCK_ATTR, None) is not None - return diagnose_figure_geometry( - fig, - data_axes, - layout_locked=layout_locked, - font_token_sizes=_active_font_token_sizes(), - journal_compliance=_ACTIVE_COMPLIANCE_TOKENS, - contract_version="raw", - ) - except Exception as exc: - from hub_core.geometry_diagnostics import RAW_SCHEMA_VERSION + """Run geometry diagnostics in the same frame that holds the live figure.""" - return {"schema_version": RAW_SCHEMA_VERSION, "measurements": [], "warnings": [str(exc)]} + return _safe_geometry_diagnostics( + fig, + layout_lock_attr=_LAYOUT_LOCK_ATTR, + font_token_sizes=_active_font_token_sizes(), + journal_compliance=_ACTIVE_COMPLIANCE_TOKENS, + ) def _active_font_token_sizes() -> list[float]: return list(_ACTIVE_FONT_TOKENS.as_dict().values()) +def apply_narrow_log_minor_tick_policy(fig, *, mode: str | bool = "auto") -> dict: + """Apply the journal-only narrow-log minor-label policy to ``fig``.""" + + return _apply_narrow_log_minor_tick_policy(fig, mode=mode, target_format=_ACTIVE_TARGET_FORMAT) + + +# A descriptive alias keeps direct callers readable while preserving one +# implementation for older scripts that prefer the shorter ``tick`` spelling. +apply_narrow_log_tick_policy = apply_narrow_log_minor_tick_policy + + def save_journal_fig( fig, filename, @@ -676,8 +674,10 @@ def save_journal_fig( tiff_companion: bool = True, auto_declutter: bool | None = None, declutter_mode: str | None = None, + narrow_log_minor_labels: str | bool = "auto", compliance_mode: str | None = None, mutation_ledger_out: list[dict] | None = None, + bbox_policy: str | None = None, **kwargs, ): """ @@ -685,41 +685,47 @@ def save_journal_fig( PDF uses CreationDate/ModDate, while SVG suppresses Date metadata for stable output. If filename is .pdf, companion files are generated per companion_formats (png, tiff). - Layout-locked figures use rc_context to suppress savefig.bbox='tight' from rcParams, - because passing bbox_inches=None in kwargs still falls back to rcParams in matplotlib. + ``bbox_policy`` controls the canvas-vs-crop policy when ``bbox_inches`` is + not passed explicitly. ``None``/``"auto"`` keeps publication canvas size + for layout-locked and Nature-family figures, while retaining the historical + tight-bbox behavior for other non-neutral targets. Use + ``bbox_policy="tight"`` (or the long-standing ``bbox_inches="tight"`` + kwarg) as an explicit crop opt-in; use ``bbox_policy="fixed"`` to force a + fixed canvas for any target. Explicit ``bbox_inches`` always wins, which + preserves compatibility with callers that already requested tight output. + + Fixed-canvas saves use rc_context to suppress ``savefig.bbox='tight'`` from + rcParams, because passing ``bbox_inches=None`` in kwargs still falls back to + rcParams in matplotlib. If preset is in TIFF_AUTO_PRESETS and tiff_companion is True, a 300 DPI LZW-compressed TIFF companion is saved alongside any primary format (unless primary is already TIFF). + + ``narrow_log_minor_labels`` is a journal-only save-time convenience. In + ``auto`` mode, built-in Matplotlib log minor labels are suppressed below + two decades; pass ``False`` to opt out. User-supplied formatters are never + replaced. """ import contextlib layout_lock = getattr(fig, _LAYOUT_LOCK_ATTR, None) - if layout_lock: + fixed_canvas, _ = resolve_bbox_policy( + target_format=_ACTIVE_TARGET_FORMAT, + layout_lock=layout_lock, + kwargs=kwargs, + bbox_policy=bbox_policy, + ) + if fixed_canvas: kwargs.pop("bbox_inches", None) save_ctx = plt.rc_context({"savefig.bbox": None, "savefig.pad_inches": 0}) else: - if _ACTIVE_TARGET_FORMAT != "neutral": - kwargs.setdefault("bbox_inches", "tight") save_ctx = contextlib.nullcontext() - metadata = kwargs.pop("metadata", {}) or {} - file_path = Path(filename) - suffix = file_path.suffix.lower() - if _ACTIVE_TARGET_FORMAT != "neutral" and suffix in {".png", ".jpg", ".jpeg", ".tif", ".tiff"}: - kwargs.setdefault("dpi", 600) - - # 도구 버전 정보 차단 — 환경별 바이너리 해시 불일치 방지 - metadata.setdefault("Creator", None) - metadata.pop("Producer", None) - metadata.pop("Software", None) - - if suffix == ".svg": - metadata.pop("CreationDate", None) - metadata.pop("ModDate", None) - metadata.setdefault("Date", None) - else: - metadata.setdefault("CreationDate", None) - metadata.setdefault("ModDate", None) + file_path, suffix, metadata = prepare_save_metadata( + filename, + kwargs=kwargs, + target_format=_ACTIVE_TARGET_FORMAT, + ) effective_compliance = str(compliance_mode or _ACTIVE_COMPLIANCE_MODE or "validate").strip().lower() if effective_compliance not in {"validate", "clamp"}: @@ -732,6 +738,7 @@ def save_journal_fig( raise ValueError("declutter_mode must be 'none' or 'declutter'") with save_ctx: + apply_narrow_log_minor_tick_policy(fig, mode=narrow_log_minor_labels) ledger, declutter_evidence = apply_explicit_save_mutations( fig, compliance_mode=effective_compliance, @@ -771,14 +778,13 @@ def save_journal_fig( tiff_kwargs["pil_kwargs"] = {"compression": "tiff_lzw"} fig.savefig(file_path.with_suffix(".tiff"), **tiff_kwargs) - # Auto TIFF companion for journal presets - _preset = (preset or "").lower() - if tiff_companion and _preset in TIFF_AUTO_PRESETS and suffix != ".tiff" and "tiff" not in companion_formats: - tiff_path = file_path.with_suffix(".tiff") - fig.savefig( - str(tiff_path), - dpi=300, - format="tiff", - bbox_inches="tight", - pil_kwargs={"compression": "tiff_lzw"}, - ) + save_auto_tiff_companion( + fig, + file_path, + preset=preset, + suffix=suffix, + tiff_companion=tiff_companion, + companion_formats=companion_formats, + kwargs=kwargs, + fixed_canvas=fixed_canvas, + ) diff --git a/uv.lock b/uv.lock index a237bae..be5afff 100644 --- a/uv.lock +++ b/uv.lock @@ -228,7 +228,7 @@ wheels = [ [[package]] name = "figops" -version = "0.20.1" +version = "0.20.2" source = { editable = "." } dependencies = [ { name = "adjusttext" },