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.
- overlay_scoring_skeleton-0.1.0/LICENSE +21 -0
- overlay_scoring_skeleton-0.1.0/PKG-INFO +161 -0
- overlay_scoring_skeleton-0.1.0/README.md +113 -0
- overlay_scoring_skeleton-0.1.0/pyproject.toml +50 -0
- overlay_scoring_skeleton-0.1.0/setup.cfg +4 -0
- overlay_scoring_skeleton-0.1.0/src/overlay_scoring/__init__.py +35 -0
- overlay_scoring_skeleton-0.1.0/src/overlay_scoring/overlay.py +380 -0
- overlay_scoring_skeleton-0.1.0/src/overlay_scoring_skeleton.egg-info/PKG-INFO +161 -0
- overlay_scoring_skeleton-0.1.0/src/overlay_scoring_skeleton.egg-info/SOURCES.txt +11 -0
- overlay_scoring_skeleton-0.1.0/src/overlay_scoring_skeleton.egg-info/dependency_links.txt +1 -0
- overlay_scoring_skeleton-0.1.0/src/overlay_scoring_skeleton.egg-info/requires.txt +5 -0
- overlay_scoring_skeleton-0.1.0/src/overlay_scoring_skeleton.egg-info/top_level.txt +1 -0
- overlay_scoring_skeleton-0.1.0/tests/test_overlay.py +254 -0
|
@@ -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,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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
overlay_scoring
|
|
@@ -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
|