overlay-scoring-skeleton 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Shinichi Suwa
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,161 @@
1
+ Metadata-Version: 2.4
2
+ Name: overlay-scoring-skeleton
3
+ Version: 0.1.0
4
+ Summary: Starter template + canonical overlay engine for rules-as-data readiness/scoring definitions: flat items with a fixed one-level id separator, safely extensible via add/strengthen overlays.
5
+ Author: Shinichi Suwa
6
+ License: MIT License
7
+
8
+ Copyright (c) 2026 Shinichi Suwa
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy
11
+ of this software and associated documentation files (the "Software"), to deal
12
+ in the Software without restriction, including without limitation the rights
13
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
+ copies of the Software, and to permit persons to whom the Software is
15
+ furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
+ SOFTWARE.
27
+
28
+ Project-URL: Homepage, https://github.com/suwa-sh/overlay-scoring-skeleton
29
+ Project-URL: Issues, https://github.com/suwa-sh/overlay-scoring-skeleton/issues
30
+ Keywords: overlay,scoring,readiness,rules-as-data,extensible,yaml
31
+ Classifier: Development Status :: 4 - Beta
32
+ Classifier: Intended Audience :: Developers
33
+ Classifier: License :: OSI Approved :: MIT License
34
+ Classifier: Programming Language :: Python :: 3
35
+ Classifier: Programming Language :: Python :: 3.10
36
+ Classifier: Programming Language :: Python :: 3.11
37
+ Classifier: Programming Language :: Python :: 3.12
38
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
39
+ Classifier: Topic :: Software Development :: Quality Assurance
40
+ Requires-Python: >=3.10
41
+ Description-Content-Type: text/markdown
42
+ License-File: LICENSE
43
+ Requires-Dist: jsonschema[format-nongpl]<5.0,>=4.21
44
+ Requires-Dist: pyyaml>=6.0
45
+ Provides-Extra: dev
46
+ Requires-Dist: pytest<10.0,>=8.0; extra == "dev"
47
+ Dynamic: license-file
48
+
49
+ # overlay-scoring-skeleton
50
+
51
+ rules-as-data な **readiness / scoring 定義**を、組織ごとに**安全に拡張**するための overlay エンジンと定義スキーマの起点テンプレートです。
52
+
53
+ > readiness / scoring 定義 = チェック項目・閾値・マトリクスなどを、コードではなく YAML データとして書いた評価フレームワークのことです。
54
+
55
+ ## 提供する価値
56
+
57
+ - **拡張モデルを再実装しなくてよい**: 「項目を追加する / 閾値を厳しくする」だけを許し、既存項目の上書き・削除・緩和は機械的に拒否する overlay の仕組みが最初から入っています。組織は base 定義を fork せずに自社ルールを重ねられます。
58
+ - **定義が壊れにくい**: 項目のグループ分けを自由なタグ命名ではなく **id の構造 (1 階層固定)** で表すため、命名ミスで表示や集計が崩れません。不正な拡張 (未宣言フィールドの変更、閾値の緩和、存在しないグループへの追加) は検証時に弾かれます。
59
+ - **明細データを自由に持てる**: 各項目は評価に使う数値のほかに、任意の入れ子データ (RACI セル、条件分岐、参照 id など) をそのまま保持できます。エンジンはこれらを解釈せず素通しします。
60
+ - **一貫性**: 複数の readiness/scoring OSS が同じ拡張モデル・同じ検証規則を共有できます。
61
+
62
+ ## データモデル
63
+
64
+ 定義は単一のフラットな `items` リストです。id で 1 階層のグループを表します。
65
+
66
+ - `<group>` (区切り文字なし) = **グループヘッダ**。合否閾値や SLA などグループ単位の数値を持ちます。
67
+ - `<group>.<leaf>` (区切り文字 1 個) = **リーフ**。明細フィールドと任意の入れ子データを持ちます。
68
+
69
+ 区切り文字は既定で `.` です (`separator` で変更可)。区切りなしのリーフ (ungrouped leaf) は許されません。
70
+
71
+ ```yaml
72
+ version: 1
73
+ name: my-framework
74
+ separator: "."
75
+ extension_points:
76
+ - {group: "L*", allow: add} # L* グループにリーフを追加してよい
77
+ - {group: "L*", level: group, field: revise, allow: strengthen, direction: higher}
78
+ items:
79
+ - {id: "L1", label: 基礎層, pass: 1.0, revise: 0.5} # グループヘッダ (数値はここ)
80
+ - {id: "L1.Q1", text: 判断基準は文書化されているか, weight: 1.0} # リーフ
81
+ ```
82
+
83
+ 拡張 (overlay) は `add` と `strengthen` の 2 操作だけです。
84
+
85
+ ```yaml
86
+ extends: my-framework
87
+ add:
88
+ - {id: "L1.Q9", text: 自社固有の追加質問, weight: 1.0}
89
+ strengthen:
90
+ "L1": {revise: 0.8} # 0.5 -> 0.8 (higher = より厳しい。緩和は拒否される)
91
+ ```
92
+
93
+ ## インストール
94
+
95
+ ライブラリとして使う場合は PyPI から入れます。
96
+
97
+ ```bash
98
+ pip install overlay-scoring-skeleton
99
+ ```
100
+
101
+ 導入すると、利用側は使っているエンジンのバージョンを取得できます。
102
+
103
+ ```python
104
+ import overlay_scoring
105
+ print(overlay_scoring.__version__)
106
+ ```
107
+
108
+ ## Quick start
109
+
110
+ このリポを clone して開発・テストする場合は、依存を入れてテストを実行します。
111
+
112
+ ```bash
113
+ python3 -m venv .venv
114
+ ./.venv/bin/pip install ".[dev]"
115
+ ./.venv/bin/python -m pytest tests/ -q # 26 tests
116
+ ```
117
+
118
+ エンジンはライブラリとして使います。
119
+
120
+ ```python
121
+ from overlay_scoring import load_yaml, apply_overlays, group_items, validate_definition
122
+
123
+ base = load_yaml("definitions/example-four-layer.yaml")
124
+ assert validate_definition(base) == [] # base の構造整合を確認
125
+
126
+ result = apply_overlays(base, ["examples/overlays/sample-four-layer.yaml"])
127
+ if not result.ok:
128
+ raise SystemExit([f"{v.path}: {v.message}" for v in result.violations])
129
+
130
+ groups = group_items(result.merged) # 順序を保ったまま group 化
131
+ for gid, g in groups.items():
132
+ header, leaves = g["header"], g["leaves"]
133
+ ... # スコアリングは利用側で実装
134
+ ```
135
+
136
+ ## 想定ワークフロー (準備 → 実行 → 解釈)
137
+
138
+ 新しい readiness/scoring OSS を作る流れです。
139
+
140
+ 1. **準備 — 定義を書く**: このリポを clone し、`definitions/` に自分のフレームワークを 1 ファイル書きます。グループヘッダに閾値を、リーフに明細を置き、`extension_points` で「どのグループに追加してよいか / どの数値をどちら向きに厳格化してよいか」を宣言します。`schemas/definition.schema.json` で形を検証できます。
141
+ 2. **実行 — overlay を適用する**: 導入組織は `add` / `strengthen` だけの overlay を書きます。`apply_overlays(base, [overlay, ...])` が違反を検証しながら重ね、最初の違反で止めて `MergeResult` を返します。
142
+ 3. **解釈 — merged を読む**: `group_items(merged)` でフラットな items を「グループ → ヘッダ + リーフ」に順序保持で畳み込み、ヘッダの数値としきい値、リーフの明細を使ってスコアや判定を出します (このスコアリング部分が各リポ固有の実装です)。
143
+
144
+ ## 検証される拡張ルール
145
+
146
+ - リーフの追加はその `<group>` プレフィックスが実在するときだけ許可 (タイプミスを検出)。
147
+ - id 衝突・既存項目の上書き・削除は拒否。
148
+ - `strengthen` は宣言済みの数値フィールドのみ、宣言した方向 (`higher` / `lower`) にのみ許可。緩和・非数値・宣言外フィールドは拒否。
149
+ - 複数 overlay は順に適用し、最初に違反した overlay で停止します。
150
+
151
+ ## リポ構成
152
+
153
+ | パス | 役割 |
154
+ |---|---|
155
+ | `src/overlay_scoring/overlay.py` | overlay エンジン (canonical 実装) |
156
+ | `schemas/definition.schema.json` | 定義スキーマ |
157
+ | `definitions/example-*.yaml` | 移行済みサンプル定義 (round-trip fixture) |
158
+ | `examples/overlays/*.yaml` | overlay サンプル |
159
+ | `tests/` | エンジンの全境界条件テスト |
160
+ | `docs/01_architecture.md` | 構造 (C4) とデータモデル |
161
+ | `docs/migration-map.md` | 既存フレームワークをこのモデルへ移す対応表 |
@@ -0,0 +1,113 @@
1
+ # overlay-scoring-skeleton
2
+
3
+ rules-as-data な **readiness / scoring 定義**を、組織ごとに**安全に拡張**するための overlay エンジンと定義スキーマの起点テンプレートです。
4
+
5
+ > readiness / scoring 定義 = チェック項目・閾値・マトリクスなどを、コードではなく YAML データとして書いた評価フレームワークのことです。
6
+
7
+ ## 提供する価値
8
+
9
+ - **拡張モデルを再実装しなくてよい**: 「項目を追加する / 閾値を厳しくする」だけを許し、既存項目の上書き・削除・緩和は機械的に拒否する overlay の仕組みが最初から入っています。組織は base 定義を fork せずに自社ルールを重ねられます。
10
+ - **定義が壊れにくい**: 項目のグループ分けを自由なタグ命名ではなく **id の構造 (1 階層固定)** で表すため、命名ミスで表示や集計が崩れません。不正な拡張 (未宣言フィールドの変更、閾値の緩和、存在しないグループへの追加) は検証時に弾かれます。
11
+ - **明細データを自由に持てる**: 各項目は評価に使う数値のほかに、任意の入れ子データ (RACI セル、条件分岐、参照 id など) をそのまま保持できます。エンジンはこれらを解釈せず素通しします。
12
+ - **一貫性**: 複数の readiness/scoring OSS が同じ拡張モデル・同じ検証規則を共有できます。
13
+
14
+ ## データモデル
15
+
16
+ 定義は単一のフラットな `items` リストです。id で 1 階層のグループを表します。
17
+
18
+ - `<group>` (区切り文字なし) = **グループヘッダ**。合否閾値や SLA などグループ単位の数値を持ちます。
19
+ - `<group>.<leaf>` (区切り文字 1 個) = **リーフ**。明細フィールドと任意の入れ子データを持ちます。
20
+
21
+ 区切り文字は既定で `.` です (`separator` で変更可)。区切りなしのリーフ (ungrouped leaf) は許されません。
22
+
23
+ ```yaml
24
+ version: 1
25
+ name: my-framework
26
+ separator: "."
27
+ extension_points:
28
+ - {group: "L*", allow: add} # L* グループにリーフを追加してよい
29
+ - {group: "L*", level: group, field: revise, allow: strengthen, direction: higher}
30
+ items:
31
+ - {id: "L1", label: 基礎層, pass: 1.0, revise: 0.5} # グループヘッダ (数値はここ)
32
+ - {id: "L1.Q1", text: 判断基準は文書化されているか, weight: 1.0} # リーフ
33
+ ```
34
+
35
+ 拡張 (overlay) は `add` と `strengthen` の 2 操作だけです。
36
+
37
+ ```yaml
38
+ extends: my-framework
39
+ add:
40
+ - {id: "L1.Q9", text: 自社固有の追加質問, weight: 1.0}
41
+ strengthen:
42
+ "L1": {revise: 0.8} # 0.5 -> 0.8 (higher = より厳しい。緩和は拒否される)
43
+ ```
44
+
45
+ ## インストール
46
+
47
+ ライブラリとして使う場合は PyPI から入れます。
48
+
49
+ ```bash
50
+ pip install overlay-scoring-skeleton
51
+ ```
52
+
53
+ 導入すると、利用側は使っているエンジンのバージョンを取得できます。
54
+
55
+ ```python
56
+ import overlay_scoring
57
+ print(overlay_scoring.__version__)
58
+ ```
59
+
60
+ ## Quick start
61
+
62
+ このリポを clone して開発・テストする場合は、依存を入れてテストを実行します。
63
+
64
+ ```bash
65
+ python3 -m venv .venv
66
+ ./.venv/bin/pip install ".[dev]"
67
+ ./.venv/bin/python -m pytest tests/ -q # 26 tests
68
+ ```
69
+
70
+ エンジンはライブラリとして使います。
71
+
72
+ ```python
73
+ from overlay_scoring import load_yaml, apply_overlays, group_items, validate_definition
74
+
75
+ base = load_yaml("definitions/example-four-layer.yaml")
76
+ assert validate_definition(base) == [] # base の構造整合を確認
77
+
78
+ result = apply_overlays(base, ["examples/overlays/sample-four-layer.yaml"])
79
+ if not result.ok:
80
+ raise SystemExit([f"{v.path}: {v.message}" for v in result.violations])
81
+
82
+ groups = group_items(result.merged) # 順序を保ったまま group 化
83
+ for gid, g in groups.items():
84
+ header, leaves = g["header"], g["leaves"]
85
+ ... # スコアリングは利用側で実装
86
+ ```
87
+
88
+ ## 想定ワークフロー (準備 → 実行 → 解釈)
89
+
90
+ 新しい readiness/scoring OSS を作る流れです。
91
+
92
+ 1. **準備 — 定義を書く**: このリポを clone し、`definitions/` に自分のフレームワークを 1 ファイル書きます。グループヘッダに閾値を、リーフに明細を置き、`extension_points` で「どのグループに追加してよいか / どの数値をどちら向きに厳格化してよいか」を宣言します。`schemas/definition.schema.json` で形を検証できます。
93
+ 2. **実行 — overlay を適用する**: 導入組織は `add` / `strengthen` だけの overlay を書きます。`apply_overlays(base, [overlay, ...])` が違反を検証しながら重ね、最初の違反で止めて `MergeResult` を返します。
94
+ 3. **解釈 — merged を読む**: `group_items(merged)` でフラットな items を「グループ → ヘッダ + リーフ」に順序保持で畳み込み、ヘッダの数値としきい値、リーフの明細を使ってスコアや判定を出します (このスコアリング部分が各リポ固有の実装です)。
95
+
96
+ ## 検証される拡張ルール
97
+
98
+ - リーフの追加はその `<group>` プレフィックスが実在するときだけ許可 (タイプミスを検出)。
99
+ - id 衝突・既存項目の上書き・削除は拒否。
100
+ - `strengthen` は宣言済みの数値フィールドのみ、宣言した方向 (`higher` / `lower`) にのみ許可。緩和・非数値・宣言外フィールドは拒否。
101
+ - 複数 overlay は順に適用し、最初に違反した overlay で停止します。
102
+
103
+ ## リポ構成
104
+
105
+ | パス | 役割 |
106
+ |---|---|
107
+ | `src/overlay_scoring/overlay.py` | overlay エンジン (canonical 実装) |
108
+ | `schemas/definition.schema.json` | 定義スキーマ |
109
+ | `definitions/example-*.yaml` | 移行済みサンプル定義 (round-trip fixture) |
110
+ | `examples/overlays/*.yaml` | overlay サンプル |
111
+ | `tests/` | エンジンの全境界条件テスト |
112
+ | `docs/01_architecture.md` | 構造 (C4) とデータモデル |
113
+ | `docs/migration-map.md` | 既存フレームワークをこのモデルへ移す対応表 |
@@ -0,0 +1,50 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "overlay-scoring-skeleton"
7
+ version = "0.1.0"
8
+ description = "Starter template + canonical overlay engine for rules-as-data readiness/scoring definitions: flat items with a fixed one-level id separator, safely extensible via add/strengthen overlays."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { file = "LICENSE" }
12
+ authors = [{ name = "Shinichi Suwa" }]
13
+ keywords = [
14
+ "overlay",
15
+ "scoring",
16
+ "readiness",
17
+ "rules-as-data",
18
+ "extensible",
19
+ "yaml",
20
+ ]
21
+ classifiers = [
22
+ "Development Status :: 4 - Beta",
23
+ "Intended Audience :: Developers",
24
+ "License :: OSI Approved :: MIT License",
25
+ "Programming Language :: Python :: 3",
26
+ "Programming Language :: Python :: 3.10",
27
+ "Programming Language :: Python :: 3.11",
28
+ "Programming Language :: Python :: 3.12",
29
+ "Topic :: Software Development :: Libraries :: Python Modules",
30
+ "Topic :: Software Development :: Quality Assurance",
31
+ ]
32
+ dependencies = [
33
+ # [format-nongpl] extra brings rfc3339-validator etc so "format": "date-time"
34
+ # is actually validated (bare jsonschema treats it as a no-op).
35
+ "jsonschema[format-nongpl]>=4.21,<5.0",
36
+ "pyyaml>=6.0",
37
+ ]
38
+
39
+ [project.optional-dependencies]
40
+ dev = ["pytest>=8.0,<10.0"]
41
+
42
+ [project.urls]
43
+ Homepage = "https://github.com/suwa-sh/overlay-scoring-skeleton"
44
+ Issues = "https://github.com/suwa-sh/overlay-scoring-skeleton/issues"
45
+
46
+ [tool.setuptools.package-dir]
47
+ "" = "src"
48
+
49
+ [tool.setuptools.packages.find]
50
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,35 @@
1
+ """Canonical overlay engine for readiness / scoring definitions."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version as _version
4
+
5
+ try:
6
+ __version__ = _version("overlay-scoring-skeleton")
7
+ except PackageNotFoundError: # not installed (e.g. running from a source checkout)
8
+ __version__ = "0.0.0.dev0"
9
+
10
+ from .overlay import (
11
+ MergeResult,
12
+ MergeViolation,
13
+ apply_overlay,
14
+ apply_overlays,
15
+ group_items,
16
+ group_of,
17
+ is_leaf,
18
+ load_yaml,
19
+ separator_of,
20
+ validate_definition,
21
+ )
22
+
23
+ __all__ = [
24
+ "__version__",
25
+ "MergeResult",
26
+ "MergeViolation",
27
+ "apply_overlay",
28
+ "apply_overlays",
29
+ "group_items",
30
+ "group_of",
31
+ "is_leaf",
32
+ "load_yaml",
33
+ "separator_of",
34
+ "validate_definition",
35
+ ]
@@ -0,0 +1,380 @@
1
+ """Canonical overlay engine for readiness / scoring definitions.
2
+
3
+ A *definition* is a single flat ``items`` list. Every item has an ``id`` that is
4
+ either a **group header** (no separator, e.g. ``L1``) or a **leaf**
5
+ (exactly one separator, e.g. ``L1.Q1``). The separator (default ``.``) is fixed
6
+ to one level: there are no ungrouped leaves and no deeper nesting.
7
+
8
+ A leaf's group prefix must reference an existing header. Group headers may carry
9
+ group-level numeric fields (thresholds, SLAs); leaves carry item fields plus any
10
+ *opaque* nested payload (``cells`` / ``when`` / ``recommended`` / ``injects`` ...)
11
+ that the engine never interprets.
12
+
13
+ The base declares its ``extension_points``. An overlay may only:
14
+
15
+ - ``add`` : append a new item (fresh ``id``) to a group the base allows.
16
+ - ``strengthen`` : move a declared numeric field toward the stricter direction.
17
+
18
+ The stricter direction is declared per field (``direction: lower`` for SLA hours,
19
+ ``direction: higher`` for pass thresholds). Everything else (overwrite, delete,
20
+ weaken, unknown id, out-of-scope field) is a violation and rejected.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from copy import deepcopy
26
+ from dataclasses import dataclass, field
27
+ from pathlib import Path
28
+
29
+ import yaml
30
+
31
+ DEFAULT_SEPARATOR = "."
32
+
33
+
34
+ @dataclass
35
+ class MergeViolation:
36
+ """A single rule-violation detected while applying an overlay."""
37
+
38
+ path: str
39
+ kind: str
40
+ message: str
41
+
42
+
43
+ @dataclass
44
+ class MergeResult:
45
+ """Outcome of applying overlays to a base definition."""
46
+
47
+ merged: dict
48
+ applied: list[str] = field(default_factory=list)
49
+ violations: list[MergeViolation] = field(default_factory=list)
50
+
51
+ @property
52
+ def ok(self) -> bool:
53
+ return not self.violations
54
+
55
+
56
+ def load_yaml(path: str | Path) -> dict:
57
+ """Load a YAML file into a dict. Raises on syntax errors."""
58
+ with open(path, encoding="utf-8") as f:
59
+ return yaml.safe_load(f)
60
+
61
+
62
+ # --- id helpers -------------------------------------------------------------
63
+
64
+ def separator_of(defn: dict) -> str:
65
+ return defn.get("separator", DEFAULT_SEPARATOR)
66
+
67
+
68
+ def is_leaf(item_id: str, sep: str) -> bool:
69
+ return sep in item_id
70
+
71
+
72
+ def group_of(item_id: str, sep: str) -> str:
73
+ """The group a header/leaf belongs to (the header's own id, or a leaf's prefix)."""
74
+ return item_id.split(sep, 1)[0] if sep in item_id else item_id
75
+
76
+
77
+ def _depth_ok(item_id: str, sep: str) -> bool:
78
+ """Fixed one level: an id has at most one separator."""
79
+ return item_id.count(sep) <= 1
80
+
81
+
82
+ def _match_group(selector: str, group_id: str) -> bool:
83
+ """Group selector: exact id, prefix family ('L*'), or '*' (all)."""
84
+ if selector == "*":
85
+ return True
86
+ if selector.endswith("*"):
87
+ return group_id.startswith(selector[:-1])
88
+ return selector == group_id
89
+
90
+
91
+ # --- extension points -------------------------------------------------------
92
+
93
+ @dataclass
94
+ class StrengthenSpec:
95
+ group_sel: str
96
+ on: str # "group" | "leaf"
97
+ field: str
98
+ direction: str # "lower" | "higher"
99
+
100
+
101
+ def _parse_extension_points(base: dict) -> tuple[list[str], list[StrengthenSpec]]:
102
+ """Return (add_selectors, strengthen_specs) declared by the base."""
103
+ add_selectors: list[str] = []
104
+ strengthen_specs: list[StrengthenSpec] = []
105
+ for ep in base.get("extension_points", []):
106
+ if not isinstance(ep, dict):
107
+ continue
108
+ allow = ep.get("allow")
109
+ group_sel = ep.get("group", "*")
110
+ if allow == "add":
111
+ add_selectors.append(group_sel)
112
+ elif allow == "strengthen":
113
+ on = ep.get("level", "leaf") # 'level' (not 'on'): 'on' is a YAML 1.1 boolean keyword
114
+ if on not in {"group", "leaf"}:
115
+ on = "leaf"
116
+ direction = ep.get("direction", "lower")
117
+ if direction not in {"lower", "higher"}:
118
+ direction = "lower" # ambiguous -> fail safe to lower (stricter = shorter)
119
+ fld = ep.get("field")
120
+ if fld:
121
+ strengthen_specs.append(StrengthenSpec(group_sel, on, fld, direction))
122
+ return add_selectors, strengthen_specs
123
+
124
+
125
+ # --- add --------------------------------------------------------------------
126
+
127
+ def _apply_add(
128
+ merged: dict,
129
+ new_items: list,
130
+ add_selectors: list[str],
131
+ sep: str,
132
+ violations: list[MergeViolation],
133
+ ) -> None:
134
+ """Append new items in listed order. Leaf prefixes may reference headers
135
+ added earlier in the same overlay (base + prior adds)."""
136
+ if not isinstance(new_items, list):
137
+ violations.append(
138
+ MergeViolation(path="add", kind="invalid_overlay", message="'add' must be a list of items")
139
+ )
140
+ return
141
+ items = merged.setdefault("items", [])
142
+ existing_ids = {x.get("id") for x in items if isinstance(x, dict)}
143
+ header_ids = {x["id"] for x in items if isinstance(x, dict) and "id" in x and not is_leaf(x["id"], sep)}
144
+
145
+ for item in new_items:
146
+ if not isinstance(item, dict) or "id" not in item:
147
+ violations.append(
148
+ MergeViolation(path="add", kind="invalid_overlay", message="each added item needs an 'id'")
149
+ )
150
+ continue
151
+ new_id = item["id"]
152
+ if not _depth_ok(new_id, sep):
153
+ violations.append(
154
+ MergeViolation(
155
+ path=f"add[{new_id}]",
156
+ kind="invalid_overlay",
157
+ message=f"id '{new_id}' has more than one '{sep}' (only one nesting level is allowed)",
158
+ )
159
+ )
160
+ continue
161
+ if new_id in existing_ids:
162
+ violations.append(
163
+ MergeViolation(
164
+ path=f"add[{new_id}]",
165
+ kind="id_collision",
166
+ message=f"added id '{new_id}' collides with an existing item (overwrite is not allowed)",
167
+ )
168
+ )
169
+ continue
170
+ grp = group_of(new_id, sep)
171
+ if not any(_match_group(sel, grp) for sel in add_selectors):
172
+ violations.append(
173
+ MergeViolation(
174
+ path=f"add[{new_id}]",
175
+ kind="unsupported_op",
176
+ message=f"group '{grp}' is not an add-able extension point of this definition",
177
+ )
178
+ )
179
+ continue
180
+ if is_leaf(new_id, sep) and grp not in header_ids:
181
+ violations.append(
182
+ MergeViolation(
183
+ path=f"add[{new_id}]",
184
+ kind="unknown_group",
185
+ message=f"leaf '{new_id}' references group '{grp}' which does not exist",
186
+ )
187
+ )
188
+ continue
189
+ items.append(deepcopy(item))
190
+ existing_ids.add(new_id)
191
+ if not is_leaf(new_id, sep):
192
+ header_ids.add(new_id)
193
+
194
+
195
+ # --- strengthen -------------------------------------------------------------
196
+
197
+ def _strengthen_one(
198
+ target: dict, fld: str, new_val, direction: str, path: str
199
+ ) -> MergeViolation | None:
200
+ """Apply one stricter-direction field update, or return a violation.
201
+
202
+ strengthen targets are numeric. The new value must be numeric even when the
203
+ base value is absent, so a non-numeric value cannot slip in through an unset base.
204
+ """
205
+ try:
206
+ new_f = float(new_val)
207
+ except (TypeError, ValueError):
208
+ return MergeViolation(path=path, kind="invalid_overlay", message=f"strengthen value for '{fld}' must be numeric")
209
+ old = target.get(fld)
210
+ if old is None:
211
+ target[fld] = new_val
212
+ return None
213
+ try:
214
+ old_f = float(old)
215
+ except (TypeError, ValueError):
216
+ return MergeViolation(path=path, kind="invalid_overlay", message=f"base value for '{fld}' is not numeric; cannot strengthen")
217
+ weakened = new_f > old_f if direction == "lower" else new_f < old_f
218
+ if weakened:
219
+ return MergeViolation(
220
+ path=path,
221
+ kind="weakening_rejected",
222
+ message=f"strengthen would weaken '{fld}' from {old} to {new_val} (stricter direction is '{direction}')",
223
+ )
224
+ target[fld] = new_val
225
+ return None
226
+
227
+
228
+ def _apply_strengthen(
229
+ merged: dict,
230
+ item_map: dict,
231
+ strengthen_specs: list[StrengthenSpec],
232
+ sep: str,
233
+ violations: list[MergeViolation],
234
+ ) -> None:
235
+ if not isinstance(item_map, dict):
236
+ violations.append(
237
+ MergeViolation(path="strengthen", kind="invalid_overlay", message="'strengthen' must be a mapping of id -> fields")
238
+ )
239
+ return
240
+ index = {x.get("id"): x for x in merged.get("items", []) if isinstance(x, dict)}
241
+ for item_id, fields in item_map.items():
242
+ if item_id not in index:
243
+ violations.append(
244
+ MergeViolation(path=f"strengthen[{item_id}]", kind="unknown_id", message=f"id '{item_id}' is not in the definition")
245
+ )
246
+ continue
247
+ if not isinstance(fields, dict):
248
+ violations.append(
249
+ MergeViolation(path=f"strengthen[{item_id}]", kind="invalid_overlay", message="strengthen target must be a mapping of field -> value")
250
+ )
251
+ continue
252
+ on = "leaf" if is_leaf(item_id, sep) else "group"
253
+ grp = group_of(item_id, sep)
254
+ target = index[item_id]
255
+ for fld, new_val in fields.items():
256
+ spec = next(
257
+ (s for s in strengthen_specs if s.on == on and s.field == fld and _match_group(s.group_sel, grp)),
258
+ None,
259
+ )
260
+ if spec is None:
261
+ violations.append(
262
+ MergeViolation(
263
+ path=f"strengthen[{item_id}].{fld}",
264
+ kind="unsupported_op",
265
+ message=f"field '{fld}' is not a declared strengthen-able {on} field for group '{grp}'",
266
+ )
267
+ )
268
+ continue
269
+ v = _strengthen_one(target, fld, new_val, spec.direction, f"strengthen[{item_id}].{fld}")
270
+ if v is not None:
271
+ violations.append(v)
272
+
273
+
274
+ # --- validation & apply -----------------------------------------------------
275
+
276
+ def validate_definition(defn: dict) -> list[MergeViolation]:
277
+ """Structural checks on a base definition (independent of any overlay)."""
278
+ violations: list[MergeViolation] = []
279
+ sep = separator_of(defn)
280
+ items = defn.get("items", [])
281
+ if not isinstance(items, list):
282
+ return [MergeViolation(path="items", kind="invalid_definition", message="'items' must be a list")]
283
+ seen: set[str] = set()
284
+ header_ids: set[str] = set()
285
+ for it in items:
286
+ if not isinstance(it, dict) or "id" not in it:
287
+ violations.append(MergeViolation(path="items", kind="invalid_definition", message="each item needs an 'id'"))
288
+ continue
289
+ iid = it["id"]
290
+ if iid in seen:
291
+ violations.append(MergeViolation(path=f"items[{iid}]", kind="id_collision", message=f"duplicate id '{iid}'"))
292
+ seen.add(iid)
293
+ if not _depth_ok(iid, sep):
294
+ violations.append(MergeViolation(path=f"items[{iid}]", kind="invalid_definition", message=f"id '{iid}' has more than one '{sep}'"))
295
+ if not is_leaf(iid, sep):
296
+ header_ids.add(iid)
297
+ for it in items:
298
+ if isinstance(it, dict) and "id" in it and is_leaf(it["id"], sep):
299
+ grp = group_of(it["id"], sep)
300
+ if grp not in header_ids:
301
+ violations.append(
302
+ MergeViolation(path=f"items[{it['id']}]", kind="unknown_group", message=f"leaf '{it['id']}' references missing group header '{grp}'")
303
+ )
304
+ return violations
305
+
306
+
307
+ def apply_overlay(base: dict, overlay: dict) -> MergeResult:
308
+ """Apply a single overlay onto ``base`` and return the merged definition."""
309
+ violations: list[MergeViolation] = []
310
+
311
+ if overlay.get("extends") != base.get("name"):
312
+ violations.append(
313
+ MergeViolation(
314
+ path="extends",
315
+ kind="extends_mismatch",
316
+ message=f"overlay extends '{overlay.get('extends')}' does not match base name '{base.get('name')}'",
317
+ )
318
+ )
319
+ return MergeResult(merged=deepcopy(base), violations=violations)
320
+
321
+ sep = separator_of(base)
322
+ add_selectors, strengthen_specs = _parse_extension_points(base)
323
+ merged = deepcopy(base)
324
+
325
+ if "add" in overlay:
326
+ _apply_add(merged, overlay["add"], add_selectors, sep, violations)
327
+ if "strengthen" in overlay:
328
+ _apply_strengthen(merged, overlay["strengthen"], strengthen_specs, sep, violations)
329
+
330
+ for key in overlay:
331
+ if key in {"version", "extends", "add", "strengthen"}:
332
+ continue
333
+ violations.append(
334
+ MergeViolation(
335
+ path=key,
336
+ kind="unsupported_op",
337
+ message=f"overlay top-level key '{key}' is not supported (use add / strengthen)",
338
+ )
339
+ )
340
+
341
+ return MergeResult(merged=merged, violations=violations)
342
+
343
+
344
+ def apply_overlays(base: dict, overlay_paths: list[str | Path]) -> MergeResult:
345
+ """Apply multiple overlays in order, stopping at the first that violates."""
346
+ current = deepcopy(base)
347
+ applied: list[str] = []
348
+ all_violations: list[MergeViolation] = []
349
+ for path in overlay_paths:
350
+ overlay = load_yaml(path)
351
+ result = apply_overlay(current, overlay)
352
+ all_violations.extend(result.violations)
353
+ if result.violations:
354
+ return MergeResult(merged=result.merged, applied=applied, violations=all_violations)
355
+ current = result.merged
356
+ applied.append(str(path))
357
+ return MergeResult(merged=current, applied=applied, violations=all_violations)
358
+
359
+
360
+ # --- projection helper (for consumers) --------------------------------------
361
+
362
+ def group_items(defn: dict) -> dict[str, dict]:
363
+ """Regroup the flat ``items`` list into an ordered {group_id: {...}} map.
364
+
365
+ Each entry is ``{"header": <header item or None>, "leaves": [<leaf items>]}``,
366
+ preserving source order both across groups and within each group's leaves.
367
+ Consumers use this instead of re-reading a nested structure.
368
+ """
369
+ sep = separator_of(defn)
370
+ groups: dict[str, dict] = {}
371
+ for it in defn.get("items", []):
372
+ if not isinstance(it, dict) or "id" not in it:
373
+ continue
374
+ grp = group_of(it["id"], sep)
375
+ g = groups.setdefault(grp, {"header": None, "leaves": []})
376
+ if is_leaf(it["id"], sep):
377
+ g["leaves"].append(it)
378
+ else:
379
+ g["header"] = it
380
+ return groups
@@ -0,0 +1,161 @@
1
+ Metadata-Version: 2.4
2
+ Name: overlay-scoring-skeleton
3
+ Version: 0.1.0
4
+ Summary: Starter template + canonical overlay engine for rules-as-data readiness/scoring definitions: flat items with a fixed one-level id separator, safely extensible via add/strengthen overlays.
5
+ Author: Shinichi Suwa
6
+ License: MIT License
7
+
8
+ Copyright (c) 2026 Shinichi Suwa
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy
11
+ of this software and associated documentation files (the "Software"), to deal
12
+ in the Software without restriction, including without limitation the rights
13
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
+ copies of the Software, and to permit persons to whom the Software is
15
+ furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
+ SOFTWARE.
27
+
28
+ Project-URL: Homepage, https://github.com/suwa-sh/overlay-scoring-skeleton
29
+ Project-URL: Issues, https://github.com/suwa-sh/overlay-scoring-skeleton/issues
30
+ Keywords: overlay,scoring,readiness,rules-as-data,extensible,yaml
31
+ Classifier: Development Status :: 4 - Beta
32
+ Classifier: Intended Audience :: Developers
33
+ Classifier: License :: OSI Approved :: MIT License
34
+ Classifier: Programming Language :: Python :: 3
35
+ Classifier: Programming Language :: Python :: 3.10
36
+ Classifier: Programming Language :: Python :: 3.11
37
+ Classifier: Programming Language :: Python :: 3.12
38
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
39
+ Classifier: Topic :: Software Development :: Quality Assurance
40
+ Requires-Python: >=3.10
41
+ Description-Content-Type: text/markdown
42
+ License-File: LICENSE
43
+ Requires-Dist: jsonschema[format-nongpl]<5.0,>=4.21
44
+ Requires-Dist: pyyaml>=6.0
45
+ Provides-Extra: dev
46
+ Requires-Dist: pytest<10.0,>=8.0; extra == "dev"
47
+ Dynamic: license-file
48
+
49
+ # overlay-scoring-skeleton
50
+
51
+ rules-as-data な **readiness / scoring 定義**を、組織ごとに**安全に拡張**するための overlay エンジンと定義スキーマの起点テンプレートです。
52
+
53
+ > readiness / scoring 定義 = チェック項目・閾値・マトリクスなどを、コードではなく YAML データとして書いた評価フレームワークのことです。
54
+
55
+ ## 提供する価値
56
+
57
+ - **拡張モデルを再実装しなくてよい**: 「項目を追加する / 閾値を厳しくする」だけを許し、既存項目の上書き・削除・緩和は機械的に拒否する overlay の仕組みが最初から入っています。組織は base 定義を fork せずに自社ルールを重ねられます。
58
+ - **定義が壊れにくい**: 項目のグループ分けを自由なタグ命名ではなく **id の構造 (1 階層固定)** で表すため、命名ミスで表示や集計が崩れません。不正な拡張 (未宣言フィールドの変更、閾値の緩和、存在しないグループへの追加) は検証時に弾かれます。
59
+ - **明細データを自由に持てる**: 各項目は評価に使う数値のほかに、任意の入れ子データ (RACI セル、条件分岐、参照 id など) をそのまま保持できます。エンジンはこれらを解釈せず素通しします。
60
+ - **一貫性**: 複数の readiness/scoring OSS が同じ拡張モデル・同じ検証規則を共有できます。
61
+
62
+ ## データモデル
63
+
64
+ 定義は単一のフラットな `items` リストです。id で 1 階層のグループを表します。
65
+
66
+ - `<group>` (区切り文字なし) = **グループヘッダ**。合否閾値や SLA などグループ単位の数値を持ちます。
67
+ - `<group>.<leaf>` (区切り文字 1 個) = **リーフ**。明細フィールドと任意の入れ子データを持ちます。
68
+
69
+ 区切り文字は既定で `.` です (`separator` で変更可)。区切りなしのリーフ (ungrouped leaf) は許されません。
70
+
71
+ ```yaml
72
+ version: 1
73
+ name: my-framework
74
+ separator: "."
75
+ extension_points:
76
+ - {group: "L*", allow: add} # L* グループにリーフを追加してよい
77
+ - {group: "L*", level: group, field: revise, allow: strengthen, direction: higher}
78
+ items:
79
+ - {id: "L1", label: 基礎層, pass: 1.0, revise: 0.5} # グループヘッダ (数値はここ)
80
+ - {id: "L1.Q1", text: 判断基準は文書化されているか, weight: 1.0} # リーフ
81
+ ```
82
+
83
+ 拡張 (overlay) は `add` と `strengthen` の 2 操作だけです。
84
+
85
+ ```yaml
86
+ extends: my-framework
87
+ add:
88
+ - {id: "L1.Q9", text: 自社固有の追加質問, weight: 1.0}
89
+ strengthen:
90
+ "L1": {revise: 0.8} # 0.5 -> 0.8 (higher = より厳しい。緩和は拒否される)
91
+ ```
92
+
93
+ ## インストール
94
+
95
+ ライブラリとして使う場合は PyPI から入れます。
96
+
97
+ ```bash
98
+ pip install overlay-scoring-skeleton
99
+ ```
100
+
101
+ 導入すると、利用側は使っているエンジンのバージョンを取得できます。
102
+
103
+ ```python
104
+ import overlay_scoring
105
+ print(overlay_scoring.__version__)
106
+ ```
107
+
108
+ ## Quick start
109
+
110
+ このリポを clone して開発・テストする場合は、依存を入れてテストを実行します。
111
+
112
+ ```bash
113
+ python3 -m venv .venv
114
+ ./.venv/bin/pip install ".[dev]"
115
+ ./.venv/bin/python -m pytest tests/ -q # 26 tests
116
+ ```
117
+
118
+ エンジンはライブラリとして使います。
119
+
120
+ ```python
121
+ from overlay_scoring import load_yaml, apply_overlays, group_items, validate_definition
122
+
123
+ base = load_yaml("definitions/example-four-layer.yaml")
124
+ assert validate_definition(base) == [] # base の構造整合を確認
125
+
126
+ result = apply_overlays(base, ["examples/overlays/sample-four-layer.yaml"])
127
+ if not result.ok:
128
+ raise SystemExit([f"{v.path}: {v.message}" for v in result.violations])
129
+
130
+ groups = group_items(result.merged) # 順序を保ったまま group 化
131
+ for gid, g in groups.items():
132
+ header, leaves = g["header"], g["leaves"]
133
+ ... # スコアリングは利用側で実装
134
+ ```
135
+
136
+ ## 想定ワークフロー (準備 → 実行 → 解釈)
137
+
138
+ 新しい readiness/scoring OSS を作る流れです。
139
+
140
+ 1. **準備 — 定義を書く**: このリポを clone し、`definitions/` に自分のフレームワークを 1 ファイル書きます。グループヘッダに閾値を、リーフに明細を置き、`extension_points` で「どのグループに追加してよいか / どの数値をどちら向きに厳格化してよいか」を宣言します。`schemas/definition.schema.json` で形を検証できます。
141
+ 2. **実行 — overlay を適用する**: 導入組織は `add` / `strengthen` だけの overlay を書きます。`apply_overlays(base, [overlay, ...])` が違反を検証しながら重ね、最初の違反で止めて `MergeResult` を返します。
142
+ 3. **解釈 — merged を読む**: `group_items(merged)` でフラットな items を「グループ → ヘッダ + リーフ」に順序保持で畳み込み、ヘッダの数値としきい値、リーフの明細を使ってスコアや判定を出します (このスコアリング部分が各リポ固有の実装です)。
143
+
144
+ ## 検証される拡張ルール
145
+
146
+ - リーフの追加はその `<group>` プレフィックスが実在するときだけ許可 (タイプミスを検出)。
147
+ - id 衝突・既存項目の上書き・削除は拒否。
148
+ - `strengthen` は宣言済みの数値フィールドのみ、宣言した方向 (`higher` / `lower`) にのみ許可。緩和・非数値・宣言外フィールドは拒否。
149
+ - 複数 overlay は順に適用し、最初に違反した overlay で停止します。
150
+
151
+ ## リポ構成
152
+
153
+ | パス | 役割 |
154
+ |---|---|
155
+ | `src/overlay_scoring/overlay.py` | overlay エンジン (canonical 実装) |
156
+ | `schemas/definition.schema.json` | 定義スキーマ |
157
+ | `definitions/example-*.yaml` | 移行済みサンプル定義 (round-trip fixture) |
158
+ | `examples/overlays/*.yaml` | overlay サンプル |
159
+ | `tests/` | エンジンの全境界条件テスト |
160
+ | `docs/01_architecture.md` | 構造 (C4) とデータモデル |
161
+ | `docs/migration-map.md` | 既存フレームワークをこのモデルへ移す対応表 |
@@ -0,0 +1,11 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/overlay_scoring/__init__.py
5
+ src/overlay_scoring/overlay.py
6
+ src/overlay_scoring_skeleton.egg-info/PKG-INFO
7
+ src/overlay_scoring_skeleton.egg-info/SOURCES.txt
8
+ src/overlay_scoring_skeleton.egg-info/dependency_links.txt
9
+ src/overlay_scoring_skeleton.egg-info/requires.txt
10
+ src/overlay_scoring_skeleton.egg-info/top_level.txt
11
+ tests/test_overlay.py
@@ -0,0 +1,5 @@
1
+ jsonschema[format-nongpl]<5.0,>=4.21
2
+ pyyaml>=6.0
3
+
4
+ [dev]
5
+ pytest<10.0,>=8.0
@@ -0,0 +1,254 @@
1
+ """Tests for the canonical overlay engine.
2
+
3
+ Covers add / strengthen (leaf + group, group-scoped), every boundary condition,
4
+ source-order preservation, opaque-payload preservation, and a round-trip against
5
+ two real migrated definitions (aidr four-layer + siir dpa-clauses).
6
+ """
7
+
8
+ from copy import deepcopy
9
+ from pathlib import Path
10
+
11
+ from overlay_scoring import (
12
+ apply_overlay,
13
+ apply_overlays,
14
+ group_items,
15
+ load_yaml,
16
+ validate_definition,
17
+ )
18
+
19
+ DEFS = Path(__file__).resolve().parents[1] / "definitions"
20
+ OVERLAYS = Path(__file__).resolve().parents[1] / "examples" / "overlays"
21
+
22
+
23
+ def four_layer() -> dict:
24
+ return load_yaml(DEFS / "example-four-layer.yaml")
25
+
26
+
27
+ def dpa() -> dict:
28
+ return load_yaml(DEFS / "example-dpa-clauses.yaml")
29
+
30
+
31
+ def _ids(defn: dict) -> list[str]:
32
+ return [it["id"] for it in defn["items"]]
33
+
34
+
35
+ # --- base integrity ---------------------------------------------------------
36
+
37
+ def test_base_definitions_validate():
38
+ assert validate_definition(four_layer()) == []
39
+ assert validate_definition(dpa()) == []
40
+
41
+
42
+ def test_definition_with_orphan_leaf_is_rejected():
43
+ defn = {"version": 1, "name": "x", "items": [{"id": "A.child"}]}
44
+ kinds = {v.kind for v in validate_definition(defn)}
45
+ assert "unknown_group" in kinds
46
+
47
+
48
+ def test_definition_with_duplicate_id_is_rejected():
49
+ defn = {"version": 1, "name": "x", "items": [{"id": "A"}, {"id": "A"}]}
50
+ kinds = {v.kind for v in validate_definition(defn)}
51
+ assert "id_collision" in kinds
52
+
53
+
54
+ # --- add --------------------------------------------------------------------
55
+
56
+ def test_add_leaf_to_existing_group():
57
+ ov = {"extends": "four-layer-delegation-readiness",
58
+ "add": [{"id": "L1.Q5", "text": "extra", "weight": 1.0}]}
59
+ r = apply_overlay(four_layer(), ov)
60
+ assert r.ok, r.violations
61
+ assert "L1.Q5" in _ids(r.merged)
62
+
63
+
64
+ def test_add_leaf_with_unknown_group_is_rejected():
65
+ ov = {"extends": "four-layer-delegation-readiness",
66
+ "add": [{"id": "LZ.Q1", "text": "x", "weight": 1.0}]}
67
+ r = apply_overlay(four_layer(), ov)
68
+ # LZ matches selector "L*" but there is no LZ header
69
+ assert not r.ok
70
+ assert {v.kind for v in r.violations} == {"unknown_group"}
71
+
72
+
73
+ def test_add_to_ungoverned_group_is_rejected():
74
+ ov = {"extends": "four-layer-delegation-readiness",
75
+ "add": [{"id": "XYZ.q", "text": "x"}]}
76
+ r = apply_overlay(four_layer(), ov)
77
+ assert {v.kind for v in r.violations} == {"unsupported_op"}
78
+
79
+
80
+ def test_add_id_collision_with_base_is_rejected():
81
+ ov = {"extends": "four-layer-delegation-readiness",
82
+ "add": [{"id": "L1.Q1", "text": "dup", "weight": 1.0}]}
83
+ r = apply_overlay(four_layer(), ov)
84
+ assert {v.kind for v in r.violations} == {"id_collision"}
85
+
86
+
87
+ def test_add_id_collision_within_same_overlay_is_rejected():
88
+ ov = {"extends": "four-layer-delegation-readiness",
89
+ "add": [{"id": "L1.NEW", "text": "a", "weight": 1.0},
90
+ {"id": "L1.NEW", "text": "b", "weight": 1.0}]}
91
+ r = apply_overlay(four_layer(), ov)
92
+ assert {v.kind for v in r.violations} == {"id_collision"}
93
+
94
+
95
+ def test_add_new_group_header_then_leaf_in_same_overlay():
96
+ ov = {"extends": "four-layer-delegation-readiness",
97
+ "add": [{"id": "L5", "name": "extra_layer", "pass": 1.0, "revise": 0.7},
98
+ {"id": "L5.Q1", "text": "q", "weight": 1.0}]}
99
+ r = apply_overlay(four_layer(), ov)
100
+ assert r.ok, r.violations
101
+ assert "L5" in _ids(r.merged) and "L5.Q1" in _ids(r.merged)
102
+
103
+
104
+ def test_add_id_with_two_separators_is_rejected():
105
+ ov = {"extends": "four-layer-delegation-readiness",
106
+ "add": [{"id": "L1.Q1.deep", "text": "x", "weight": 1.0}]}
107
+ r = apply_overlay(four_layer(), ov)
108
+ assert {v.kind for v in r.violations} == {"invalid_overlay"}
109
+
110
+
111
+ def test_add_item_without_id_is_rejected():
112
+ ov = {"extends": "four-layer-delegation-readiness", "add": [{"text": "no id"}]}
113
+ r = apply_overlay(four_layer(), ov)
114
+ assert {v.kind for v in r.violations} == {"invalid_overlay"}
115
+
116
+
117
+ # --- strengthen -------------------------------------------------------------
118
+
119
+ def test_strengthen_group_field_higher_is_accepted():
120
+ ov = {"extends": "four-layer-delegation-readiness",
121
+ "strengthen": {"L4": {"revise": 0.8}}}
122
+ r = apply_overlay(four_layer(), ov)
123
+ assert r.ok, r.violations
124
+ l4 = next(i for i in r.merged["items"] if i["id"] == "L4")
125
+ assert l4["revise"] == 0.8
126
+
127
+
128
+ def test_strengthen_group_field_equal_is_accepted():
129
+ ov = {"extends": "four-layer-delegation-readiness",
130
+ "strengthen": {"L4": {"revise": 0.6}}} # base is 0.6
131
+ r = apply_overlay(four_layer(), ov)
132
+ assert r.ok, r.violations
133
+
134
+
135
+ def test_strengthen_group_field_weakening_is_rejected():
136
+ ov = {"extends": "four-layer-delegation-readiness",
137
+ "strengthen": {"L4": {"revise": 0.4}}} # 0.6 -> 0.4 is weaker
138
+ r = apply_overlay(four_layer(), ov)
139
+ assert {v.kind for v in r.violations} == {"weakening_rejected"}
140
+
141
+
142
+ def test_strengthen_leaf_field_lower_is_accepted():
143
+ ov = {"extends": "shared-infra-dpa-clauses",
144
+ "strengthen": {"clauses.DPA03": {"sla_confirmed_hours": 48}}}
145
+ r = apply_overlay(dpa(), ov)
146
+ assert r.ok, r.violations
147
+ c = next(i for i in r.merged["items"] if i["id"] == "clauses.DPA03")
148
+ assert c["sla_confirmed_hours"] == 48
149
+
150
+
151
+ def test_strengthen_leaf_field_weakening_is_rejected():
152
+ ov = {"extends": "shared-infra-dpa-clauses",
153
+ "strengthen": {"clauses.DPA03": {"sla_hours": 48}}} # 24 -> 48 is weaker (lower is stricter)
154
+ r = apply_overlay(dpa(), ov)
155
+ assert {v.kind for v in r.violations} == {"weakening_rejected"}
156
+
157
+
158
+ def test_strengthen_non_numeric_is_rejected():
159
+ ov = {"extends": "shared-infra-dpa-clauses",
160
+ "strengthen": {"clauses.DPA03": {"sla_hours": "soon"}}}
161
+ r = apply_overlay(dpa(), ov)
162
+ assert {v.kind for v in r.violations} == {"invalid_overlay"}
163
+
164
+
165
+ def test_strengthen_undeclared_field_is_rejected():
166
+ # weight is a leaf field but not declared strengthen-able in four-layer
167
+ ov = {"extends": "four-layer-delegation-readiness",
168
+ "strengthen": {"L1.Q1": {"weight": 2.0}}}
169
+ r = apply_overlay(four_layer(), ov)
170
+ assert {v.kind for v in r.violations} == {"unsupported_op"}
171
+
172
+
173
+ def test_strengthen_unknown_id_is_rejected():
174
+ ov = {"extends": "four-layer-delegation-readiness",
175
+ "strengthen": {"L9": {"revise": 0.9}}}
176
+ r = apply_overlay(four_layer(), ov)
177
+ assert {v.kind for v in r.violations} == {"unknown_id"}
178
+
179
+
180
+ # --- top-level / extends ----------------------------------------------------
181
+
182
+ def test_extends_mismatch_is_rejected():
183
+ ov = {"extends": "wrong-name", "add": []}
184
+ r = apply_overlay(four_layer(), ov)
185
+ assert {v.kind for v in r.violations} == {"extends_mismatch"}
186
+
187
+
188
+ def test_unsupported_top_level_key_is_rejected():
189
+ ov = {"extends": "four-layer-delegation-readiness", "delete": ["L1"]}
190
+ r = apply_overlay(four_layer(), ov)
191
+ assert {v.kind for v in r.violations} == {"unsupported_op"}
192
+
193
+
194
+ # --- multi-overlay ----------------------------------------------------------
195
+
196
+ def test_apply_overlays_stops_at_first_bad(tmp_path):
197
+ good = tmp_path / "good.yaml"
198
+ bad = tmp_path / "bad.yaml"
199
+ after = tmp_path / "after.yaml"
200
+ good.write_text("extends: four-layer-delegation-readiness\nadd:\n - {id: 'L1.G1', text: g, weight: 1.0}\n")
201
+ bad.write_text("extends: four-layer-delegation-readiness\nstrengthen:\n L4: {revise: 0.1}\n")
202
+ after.write_text("extends: four-layer-delegation-readiness\nadd:\n - {id: 'L1.G2', text: g, weight: 1.0}\n")
203
+ r = apply_overlays(four_layer(), [good, bad, after])
204
+ assert not r.ok
205
+ assert r.applied == [str(good)] # good applied, stopped before 'after'
206
+ assert "L1.G1" in _ids(r.merged)
207
+ assert "L1.G2" not in _ids(r.merged)
208
+
209
+
210
+ # --- structural guarantees --------------------------------------------------
211
+
212
+ def test_source_order_and_opaque_payload_preserved():
213
+ base = four_layer()
214
+ ov = load_yaml(OVERLAYS / "sample-four-layer.yaml")
215
+ r = apply_overlay(base, ov)
216
+ assert r.ok, r.violations
217
+ groups = group_items(r.merged)
218
+ # group order preserved
219
+ assert list(groups.keys()) == ["L1", "L2", "L3", "L4", "efficacy"]
220
+ # added leaves appended to their group, base leaves kept in order
221
+ l1_leaves = [i["id"] for i in groups["L1"]["leaves"]]
222
+ assert l1_leaves == ["L1.Q1", "L1.Q2", "L1.Q3", "L1.Q4", "L1.ACME_Q5"]
223
+ # opaque payload on the header survives untouched
224
+ assert groups["L1"]["header"]["case_evidence"][0]["confidence"] == "observed_fact"
225
+
226
+
227
+ def test_engine_never_mutates_base():
228
+ base = four_layer()
229
+ snapshot = deepcopy(base)
230
+ apply_overlay(base, {"extends": "four-layer-delegation-readiness",
231
+ "add": [{"id": "L1.Z", "text": "z", "weight": 1.0}]})
232
+ assert base == snapshot
233
+
234
+
235
+ # --- round-trip against both real migrated definitions -----------------------
236
+
237
+ def test_roundtrip_four_layer_sample_overlay():
238
+ r = apply_overlay(four_layer(), load_yaml(OVERLAYS / "sample-four-layer.yaml"))
239
+ assert r.ok, r.violations
240
+ ids = _ids(r.merged)
241
+ assert "L1.ACME_Q5" in ids and "L4.ACME_Q6" in ids
242
+ l4 = next(i for i in r.merged["items"] if i["id"] == "L4")
243
+ assert l4["revise"] == 0.8
244
+
245
+
246
+ def test_roundtrip_dpa_sample_overlay():
247
+ r = apply_overlay(dpa(), load_yaml(OVERLAYS / "sample-dpa-clauses.yaml"))
248
+ assert r.ok, r.violations
249
+ ids = _ids(r.merged)
250
+ assert "clauses.ACME01" in ids
251
+ dpa03 = next(i for i in r.merged["items"] if i["id"] == "clauses.DPA03")
252
+ assert dpa03["sla_confirmed_hours"] == 48
253
+ assert dpa03["sla_hours"] == 24 # untouched
254
+ assert dpa03["title"] == "委託先→委託元 漏えい通知SLA" # opaque payload preserved