measuring-autonomy 1.1.0__py3-none-any.whl
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.
- measuring_autonomy/__init__.py +33 -0
- measuring_autonomy/ledger.py +315 -0
- measuring_autonomy/score.py +216 -0
- measuring_autonomy-1.1.0.dist-info/METADATA +137 -0
- measuring_autonomy-1.1.0.dist-info/RECORD +9 -0
- measuring_autonomy-1.1.0.dist-info/WHEEL +5 -0
- measuring_autonomy-1.1.0.dist-info/entry_points.txt +3 -0
- measuring_autonomy-1.1.0.dist-info/licenses/LICENSE +29 -0
- measuring_autonomy-1.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Measuring & Managing Autonomy toolkit.
|
|
2
|
+
|
|
3
|
+
Scoring engine and Autonomy Ledger health checker for the framework
|
|
4
|
+
(https://github.com/emmanuel-a-otchere/MeasuringAndManagingAutonomy).
|
|
5
|
+
|
|
6
|
+
The ledger module is imported lazily by its console command only, so
|
|
7
|
+
`import measuring_autonomy` stays dependency-free; PyYAML is required
|
|
8
|
+
solely by `ledger-health`.
|
|
9
|
+
"""
|
|
10
|
+
from .score import (
|
|
11
|
+
DIMENSIONS,
|
|
12
|
+
WEAKEST_LINK,
|
|
13
|
+
RULESET_VERSION,
|
|
14
|
+
assess,
|
|
15
|
+
gap_analysis,
|
|
16
|
+
parse_scores,
|
|
17
|
+
recommended_move,
|
|
18
|
+
safe_level,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
__version__ = "1.1.0"
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"__version__",
|
|
25
|
+
"DIMENSIONS",
|
|
26
|
+
"RULESET_VERSION",
|
|
27
|
+
"WEAKEST_LINK",
|
|
28
|
+
"assess",
|
|
29
|
+
"gap_analysis",
|
|
30
|
+
"parse_scores",
|
|
31
|
+
"recommended_move",
|
|
32
|
+
"safe_level",
|
|
33
|
+
]
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Autonomy Ledger health checker for the Measuring & Managing Autonomy framework.
|
|
3
|
+
|
|
4
|
+
Validates a ledger (YAML, format described in docs/ledger-format.md) against
|
|
5
|
+
the framework's rules and prints an org-level health report:
|
|
6
|
+
|
|
7
|
+
- validity: schema errors (bad level, missing fields, date inconsistencies)
|
|
8
|
+
- coverage: fraction of contracts with all required fields
|
|
9
|
+
- staleness: contracts whose last_review is older than their cadence
|
|
10
|
+
- expiry: active contracts past their horizon (autonomy with no live review)
|
|
11
|
+
- guardrails: contracts with empty guardrail/telemetry/trigger lists
|
|
12
|
+
|
|
13
|
+
Exit code 0 = healthy ledger; 1 = findings; 2 = unreadable/invalid file.
|
|
14
|
+
|
|
15
|
+
Usage:
|
|
16
|
+
ledger-health examples/ledger.yaml
|
|
17
|
+
ledger-health ledger.yaml --today 2025-12-31
|
|
18
|
+
ledger-health --self-test
|
|
19
|
+
python3 tools/ledger_health.py ... # zero-install form from a checkout
|
|
20
|
+
|
|
21
|
+
Requires PyYAML (a declared dependency of this package; the zero-install
|
|
22
|
+
form needs `pip install pyyaml`).
|
|
23
|
+
License: MIT (see LICENSE).
|
|
24
|
+
"""
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import argparse
|
|
28
|
+
import datetime as dt
|
|
29
|
+
import json
|
|
30
|
+
import sys
|
|
31
|
+
|
|
32
|
+
try:
|
|
33
|
+
import yaml
|
|
34
|
+
except ImportError: # pragma: no cover
|
|
35
|
+
yaml = None
|
|
36
|
+
|
|
37
|
+
REQUIRED_LISTS = ("guardrails", "telemetry", "revocation_triggers")
|
|
38
|
+
REQUIRED_FIELDS = (
|
|
39
|
+
"id",
|
|
40
|
+
"unit",
|
|
41
|
+
"domain",
|
|
42
|
+
"level",
|
|
43
|
+
"scope",
|
|
44
|
+
"guardrails",
|
|
45
|
+
"telemetry",
|
|
46
|
+
"revocation_triggers",
|
|
47
|
+
"grantor",
|
|
48
|
+
"horizon",
|
|
49
|
+
"last_review",
|
|
50
|
+
)
|
|
51
|
+
STATUSES = ("active", "expired", "revoked", "draft")
|
|
52
|
+
DEFAULT_CADENCE_DAYS = 90
|
|
53
|
+
|
|
54
|
+
# Ledger format version handled by this tool (docs/versioning.md, docs/ledger-format.md).
|
|
55
|
+
# Ledgers without a top-level `schema:` field are treated as schema 1
|
|
56
|
+
# (backward compatibility with pre-versioning files). A newer schema than
|
|
57
|
+
# supported is a warning, not an error: report on what is understood.
|
|
58
|
+
SUPPORTED_SCHEMA_VERSION = 1
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def parse_date(value) -> dt.date | None:
|
|
62
|
+
if isinstance(value, dt.date):
|
|
63
|
+
return value
|
|
64
|
+
try:
|
|
65
|
+
return dt.date.fromisoformat(str(value))
|
|
66
|
+
except ValueError:
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def check_contract(c: dict, today: dt.date, idx: int) -> tuple[list[str], dict]:
|
|
71
|
+
"""Return (errors, metrics) for one contract. Errors are fatal findings;
|
|
72
|
+
metrics feed the health report."""
|
|
73
|
+
errors: list[str] = []
|
|
74
|
+
label = c.get("id") or f"contract[{idx}]"
|
|
75
|
+
status = c.get("status", "active")
|
|
76
|
+
|
|
77
|
+
# required fields
|
|
78
|
+
for field in REQUIRED_FIELDS:
|
|
79
|
+
if field not in c or c[field] in (None, ""):
|
|
80
|
+
errors.append(f"{label}: missing required field '{field}'")
|
|
81
|
+
# non-empty lists
|
|
82
|
+
for field in REQUIRED_LISTS:
|
|
83
|
+
value = c.get(field)
|
|
84
|
+
if value is not None and not (isinstance(value, list) and len(value) > 0):
|
|
85
|
+
errors.append(f"{label}: '{field}' must be a non-empty list")
|
|
86
|
+
# level
|
|
87
|
+
if "level" in c and (not isinstance(c["level"], int) or not 0 <= c["level"] <= 4):
|
|
88
|
+
errors.append(f"{label}: level must be an integer 0..4")
|
|
89
|
+
# status
|
|
90
|
+
if status not in STATUSES:
|
|
91
|
+
errors.append(f"{label}: status must be one of {STATUSES}")
|
|
92
|
+
# dates
|
|
93
|
+
horizon = parse_date(c.get("horizon")) if "horizon" in c else None
|
|
94
|
+
last_review = parse_date(c.get("last_review")) if "last_review" in c else None
|
|
95
|
+
if "horizon" in c and horizon is None:
|
|
96
|
+
errors.append(f"{label}: horizon is not an ISO date")
|
|
97
|
+
if "last_review" in c and last_review is None:
|
|
98
|
+
errors.append(f"{label}: last_review is not an ISO date")
|
|
99
|
+
if horizon and last_review and last_review > horizon:
|
|
100
|
+
errors.append(f"{label}: last_review is after horizon")
|
|
101
|
+
|
|
102
|
+
# health metrics (only meaningful for live contracts)
|
|
103
|
+
cadence = c.get("review_cadence_days", DEFAULT_CADENCE_DAYS)
|
|
104
|
+
stale = expired = False
|
|
105
|
+
if status == "active":
|
|
106
|
+
if last_review and (today - last_review).days > cadence:
|
|
107
|
+
stale = True
|
|
108
|
+
if horizon and today > horizon:
|
|
109
|
+
expired = True
|
|
110
|
+
metrics = {
|
|
111
|
+
"id": c.get("id"),
|
|
112
|
+
"unit": c.get("unit"),
|
|
113
|
+
"domain": c.get("domain"),
|
|
114
|
+
"level": c.get("level"),
|
|
115
|
+
"status": status,
|
|
116
|
+
"stale": stale,
|
|
117
|
+
"expired": expired,
|
|
118
|
+
}
|
|
119
|
+
return errors, metrics
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def health_report(ledger: dict, today: dt.date) -> dict:
|
|
123
|
+
schema = ledger.get("schema", SUPPORTED_SCHEMA_VERSION)
|
|
124
|
+
if not isinstance(schema, int) or schema < 1:
|
|
125
|
+
raise ValueError(f"ledger 'schema' must be a positive integer, got {schema!r}")
|
|
126
|
+
warnings: list[str] = []
|
|
127
|
+
if schema > SUPPORTED_SCHEMA_VERSION:
|
|
128
|
+
warnings.append(
|
|
129
|
+
f"ledger declares schema v{schema}; this tool supports v{SUPPORTED_SCHEMA_VERSION} — "
|
|
130
|
+
"validating the v1 fields it understands; upgrade the tool for full support"
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
contracts = ledger.get("contracts")
|
|
134
|
+
if not isinstance(contracts, list):
|
|
135
|
+
raise ValueError("ledger must contain a 'contracts' list")
|
|
136
|
+
|
|
137
|
+
errors: list[str] = []
|
|
138
|
+
metrics: list[dict] = []
|
|
139
|
+
ids: set[str] = set()
|
|
140
|
+
for idx, c in enumerate(contracts):
|
|
141
|
+
if not isinstance(c, dict):
|
|
142
|
+
errors.append(f"contract[{idx}]: must be a mapping")
|
|
143
|
+
continue
|
|
144
|
+
c_errors, c_metrics = check_contract(c, today, idx)
|
|
145
|
+
errors += c_errors
|
|
146
|
+
if c_metrics["id"]:
|
|
147
|
+
if c_metrics["id"] in ids:
|
|
148
|
+
errors.append(f"{c_metrics['id']}: duplicate id")
|
|
149
|
+
ids.add(c_metrics["id"])
|
|
150
|
+
metrics.append(c_metrics)
|
|
151
|
+
|
|
152
|
+
active = [m for m in metrics if m["status"] == "active"]
|
|
153
|
+
valid = [m for m in metrics if m["id"] and m["status"] in ("active", "draft")]
|
|
154
|
+
report = {
|
|
155
|
+
"schema_version": schema,
|
|
156
|
+
"schema_warnings": warnings,
|
|
157
|
+
"total_contracts": len(metrics),
|
|
158
|
+
"active": len(active),
|
|
159
|
+
"valid": len(valid),
|
|
160
|
+
"validity_errors": errors,
|
|
161
|
+
"coverage": round(len(valid) / len(metrics), 2) if metrics else 1.0,
|
|
162
|
+
"stale_reviews": [m["id"] for m in active if m["stale"]],
|
|
163
|
+
"expired_horizons": [m["id"] for m in active if m["expired"]],
|
|
164
|
+
"unhygienic": len(errors),
|
|
165
|
+
"today": today.isoformat(),
|
|
166
|
+
}
|
|
167
|
+
return report
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def render(report: dict) -> str:
|
|
171
|
+
lines = ["Autonomy Ledger health", "=" * 40]
|
|
172
|
+
lines.append(
|
|
173
|
+
f"Contracts: {report['total_contracts']} total, {report['active']} active"
|
|
174
|
+
)
|
|
175
|
+
lines.append(f"Coverage: {int(report['coverage'] * 100)}% valid contracts")
|
|
176
|
+
lines.append(f"Ledger schema: v{report['schema_version']}")
|
|
177
|
+
for w in report.get("schema_warnings", []):
|
|
178
|
+
lines.append(f"warning: {w}")
|
|
179
|
+
if report["stale_reviews"]:
|
|
180
|
+
lines.append(f"STALE reviews (past cadence): {', '.join(report['stale_reviews'])}")
|
|
181
|
+
else:
|
|
182
|
+
lines.append("Stale reviews: none")
|
|
183
|
+
if report["expired_horizons"]:
|
|
184
|
+
lines.append(
|
|
185
|
+
f"EXPIRED horizons (active past horizon): {', '.join(report['expired_horizons'])}"
|
|
186
|
+
)
|
|
187
|
+
else:
|
|
188
|
+
lines.append("Expired horizons: none")
|
|
189
|
+
for err in report["validity_errors"]:
|
|
190
|
+
lines.append(f"error: {err}")
|
|
191
|
+
verdict = "HEALTHY" if not (report["validity_errors"] or report["stale_reviews"] or report["expired_horizons"]) else "NEEDS ATTENTION"
|
|
192
|
+
lines.append(f"Verdict: {verdict}")
|
|
193
|
+
return "\n".join(lines)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _self_test() -> None:
|
|
197
|
+
good = {
|
|
198
|
+
"contracts": [
|
|
199
|
+
{
|
|
200
|
+
"id": "c1", "unit": "u", "domain": "d", "level": 2, "scope": "s",
|
|
201
|
+
"guardrails": ["g"], "telemetry": ["t"],
|
|
202
|
+
"revocation_triggers": ["r"], "grantor": "G",
|
|
203
|
+
"horizon": "2025-12-31", "last_review": "2025-09-30",
|
|
204
|
+
"status": "active",
|
|
205
|
+
}
|
|
206
|
+
]
|
|
207
|
+
}
|
|
208
|
+
today = dt.date(2025, 10, 1)
|
|
209
|
+
r = health_report(good, today)
|
|
210
|
+
assert not r["validity_errors"] and not r["stale_reviews"], r
|
|
211
|
+
assert r["coverage"] == 1.0
|
|
212
|
+
|
|
213
|
+
# staleness: review 200 days ago, cadence 90
|
|
214
|
+
stale = {"contracts": [dict(good["contracts"][0], last_review="2025-01-01")]}
|
|
215
|
+
r = health_report(stale, today)
|
|
216
|
+
assert r["stale_reviews"] == ["c1"], r
|
|
217
|
+
|
|
218
|
+
# expiry: active contract past horizon
|
|
219
|
+
expired = {"contracts": [dict(good["contracts"][0], horizon="2025-09-30", last_review="2025-09-01")]}
|
|
220
|
+
r = health_report(expired, dt.date(2025, 10, 2))
|
|
221
|
+
assert r["expired_horizons"] == ["c1"], r
|
|
222
|
+
|
|
223
|
+
# schema violations
|
|
224
|
+
bad = {"contracts": [{"id": "c", "level": 7, "guardrails": [], "status": "weird"}]}
|
|
225
|
+
r = health_report(bad, today)
|
|
226
|
+
errs = " ".join(r["validity_errors"])
|
|
227
|
+
assert "level" in errs and "status" in errs and "guardrails" in errs, r["validity_errors"]
|
|
228
|
+
|
|
229
|
+
# duplicate ids
|
|
230
|
+
dup = {"contracts": [good["contracts"][0], dict(good["contracts"][0])]}
|
|
231
|
+
r = health_report(dup, today)
|
|
232
|
+
assert any("duplicate" in e for e in r["validity_errors"]), r["validity_errors"]
|
|
233
|
+
|
|
234
|
+
# last_review after horizon
|
|
235
|
+
inv = {"contracts": [dict(good["contracts"][0], horizon="2025-06-30", last_review="2025-09-30")]}
|
|
236
|
+
r = health_report(inv, today)
|
|
237
|
+
assert any("after horizon" in e for e in r["validity_errors"]), r["validity_errors"]
|
|
238
|
+
|
|
239
|
+
# schema handling: missing field = schema 1 (backward compatible)
|
|
240
|
+
r = health_report({"contracts": [good["contracts"][0]]}, today)
|
|
241
|
+
assert r["schema_version"] == 1 and not r["schema_warnings"], r
|
|
242
|
+
|
|
243
|
+
# explicit schema 1 is accepted
|
|
244
|
+
r = health_report({"schema": 1, "contracts": [good["contracts"][0]]}, today)
|
|
245
|
+
assert r["schema_version"] == 1 and not r["schema_warnings"], r
|
|
246
|
+
|
|
247
|
+
# future schema: warns but still validates (forward compatibility)
|
|
248
|
+
r = health_report({"schema": 2, "contracts": [good["contracts"][0]]}, today)
|
|
249
|
+
assert r["schema_version"] == 2 and r["schema_warnings"], r
|
|
250
|
+
assert not r["validity_errors"], r
|
|
251
|
+
|
|
252
|
+
# invalid schema value
|
|
253
|
+
try:
|
|
254
|
+
health_report({"schema": "one", "contracts": []}, today)
|
|
255
|
+
except ValueError:
|
|
256
|
+
pass
|
|
257
|
+
else:
|
|
258
|
+
raise AssertionError("expected ValueError for non-integer schema")
|
|
259
|
+
|
|
260
|
+
# not a contracts list
|
|
261
|
+
for bad_ledger in ({}, {"contracts": "nope"}):
|
|
262
|
+
try:
|
|
263
|
+
health_report(bad_ledger, today)
|
|
264
|
+
except ValueError:
|
|
265
|
+
pass
|
|
266
|
+
else:
|
|
267
|
+
raise AssertionError("expected ValueError for malformed ledger")
|
|
268
|
+
|
|
269
|
+
print("self-test: OK")
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def main(argv: list[str] | None = None) -> int:
|
|
273
|
+
parser = argparse.ArgumentParser(description="Check Autonomy Ledger health (v1 format).")
|
|
274
|
+
parser.add_argument("ledger", nargs="?", help="path to ledger.yaml")
|
|
275
|
+
parser.add_argument("--today", help="override today's date (ISO) for testing")
|
|
276
|
+
parser.add_argument("--json", action="store_true", help="emit JSON")
|
|
277
|
+
parser.add_argument("--self-test", action="store_true", help="run built-in tests and exit")
|
|
278
|
+
args = parser.parse_args(argv)
|
|
279
|
+
|
|
280
|
+
if args.self_test:
|
|
281
|
+
_self_test()
|
|
282
|
+
return 0
|
|
283
|
+
|
|
284
|
+
if not args.ledger:
|
|
285
|
+
parser.error("a ledger file is required (or --self-test)")
|
|
286
|
+
|
|
287
|
+
if yaml is None:
|
|
288
|
+
print("error: PyYAML is required (pip install pyyaml)", file=sys.stderr)
|
|
289
|
+
return 2
|
|
290
|
+
|
|
291
|
+
try:
|
|
292
|
+
with open(args.ledger, encoding="utf-8") as fh:
|
|
293
|
+
ledger = yaml.safe_load(fh)
|
|
294
|
+
except (OSError, yaml.YAMLError) as exc:
|
|
295
|
+
print(f"error: cannot read ledger: {exc}", file=sys.stderr)
|
|
296
|
+
return 2
|
|
297
|
+
|
|
298
|
+
today = parse_date(args.today) if args.today else dt.date.today()
|
|
299
|
+
if today is None:
|
|
300
|
+
print("error: --today must be an ISO date", file=sys.stderr)
|
|
301
|
+
return 2
|
|
302
|
+
|
|
303
|
+
try:
|
|
304
|
+
report = health_report(ledger or {}, today)
|
|
305
|
+
except ValueError as exc:
|
|
306
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
307
|
+
return 2
|
|
308
|
+
|
|
309
|
+
print(json.dumps(report, indent=2) if args.json else render(report))
|
|
310
|
+
bad = report["validity_errors"] or report["stale_reviews"] or report["expired_horizons"]
|
|
311
|
+
return 1 if bad else 0
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
if __name__ == "__main__":
|
|
315
|
+
sys.exit(main())
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Autonomy score calculator for the Measuring & Managing Autonomy framework.
|
|
3
|
+
|
|
4
|
+
Implements the scoring rules from docs/maturity-model.md and
|
|
5
|
+
docs/assessment.md:
|
|
6
|
+
|
|
7
|
+
- Each dimension (D1..D6) is scored 0..4 (autonomy levels L0..L4).
|
|
8
|
+
- Safe grant level per domain = min(D3, D4, D6) ("weakest-link rule").
|
|
9
|
+
- D1/D2 are coaching signals; they cap expansion pace, not current grants.
|
|
10
|
+
- Recommended move comes from the decision table in the maturity model.
|
|
11
|
+
|
|
12
|
+
Usage:
|
|
13
|
+
autonomy-score 3 3 2 2 1 1 # scores for D1..D6
|
|
14
|
+
autonomy-score --current 1 3 3 2 2 1
|
|
15
|
+
autonomy-score --self-test # run the built-in tests
|
|
16
|
+
python3 tools/autonomy_score.py ... # zero-install form from a checkout
|
|
17
|
+
|
|
18
|
+
Scores are given in order: D1_clarity D2_capability D3_guardrails
|
|
19
|
+
D4_telemetry D5_transparency D6_accountability.
|
|
20
|
+
|
|
21
|
+
License: MIT (see LICENSE).
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import argparse
|
|
26
|
+
import json
|
|
27
|
+
import os
|
|
28
|
+
import re
|
|
29
|
+
import sys
|
|
30
|
+
|
|
31
|
+
DIMENSIONS = [
|
|
32
|
+
("D1", "decision_clarity"),
|
|
33
|
+
("D2", "capability"),
|
|
34
|
+
("D3", "guardrails"),
|
|
35
|
+
("D4", "telemetry"),
|
|
36
|
+
("D5", "transparency"),
|
|
37
|
+
("D6", "accountability"),
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
WEAKEST_LINK = ("D3", "D4", "D6") # guardrails, telemetry, accountability
|
|
41
|
+
CLEAN_CYCLES_TO_EXPAND = 2 # consecutive clean reviews required to expand
|
|
42
|
+
|
|
43
|
+
# Semantic version of the scoring ruleset. See docs/versioning.md.
|
|
44
|
+
# MAJOR = re-scoring (scores not comparable); MINOR = additive/clarification;
|
|
45
|
+
# PATCH = no change to scored outputs. Kept identical to site/scoring.js;
|
|
46
|
+
# parity is asserted by tools/site_selftest.js.
|
|
47
|
+
RULESET_VERSION = "1.0.0"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def parse_scores(values: list[str]) -> dict[str, int]:
|
|
51
|
+
"""Parse and validate six dimension scores (ints 0..4)."""
|
|
52
|
+
if len(values) != 6:
|
|
53
|
+
raise SystemExit(
|
|
54
|
+
f"error: expected exactly 6 scores (D1..D6), got {len(values)}\n"
|
|
55
|
+
"usage: autonomy-score D1 D2 D3 D4 D5 D6"
|
|
56
|
+
)
|
|
57
|
+
scores: dict[str, int] = {}
|
|
58
|
+
for (code, _name), raw in zip(DIMENSIONS, values):
|
|
59
|
+
try:
|
|
60
|
+
score = int(raw)
|
|
61
|
+
except ValueError:
|
|
62
|
+
raise SystemExit(f"error: score for {code} must be an integer, got {raw!r}")
|
|
63
|
+
if not 0 <= score <= 4:
|
|
64
|
+
raise SystemExit(f"error: score for {code} must be in 0..4, got {score}")
|
|
65
|
+
scores[code] = score
|
|
66
|
+
return scores
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def safe_level(scores: dict[str, int]) -> int:
|
|
70
|
+
"""Weakest-link rule: min over D3, D4, D6."""
|
|
71
|
+
return min(scores[d] for d in WEAKEST_LINK)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def gap_analysis(scores: dict[str, int], current: int | None) -> list[str]:
|
|
75
|
+
"""Notes on granted-vs-exercised and coaching signals."""
|
|
76
|
+
notes: list[str] = []
|
|
77
|
+
weakest = min(scores[d] for d in WEAKEST_LINK)
|
|
78
|
+
coaching = [d for d in ("D1", "D2") if scores[d] < weakest]
|
|
79
|
+
if coaching:
|
|
80
|
+
notes.append(
|
|
81
|
+
f"coaching signal: {', '.join(coaching)} below the safety floor — "
|
|
82
|
+
"invest in clarity/capability; do not revoke for this"
|
|
83
|
+
)
|
|
84
|
+
if current is not None:
|
|
85
|
+
headroom = safe_level(scores) - current
|
|
86
|
+
if headroom > 0:
|
|
87
|
+
notes.append(
|
|
88
|
+
f"headroom: safe level exceeds granted level by {headroom}; "
|
|
89
|
+
f"expand after {CLEAN_CYCLES_TO_EXPAND} clean review cycles"
|
|
90
|
+
)
|
|
91
|
+
elif headroom < 0:
|
|
92
|
+
notes.append(
|
|
93
|
+
f"over-grant: granted level exceeds safe level by {-headroom}; "
|
|
94
|
+
"fix guardrails/telemetry/accountability or step down"
|
|
95
|
+
)
|
|
96
|
+
else:
|
|
97
|
+
notes.append("granted level matches safe level: hold and review")
|
|
98
|
+
return notes
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def recommended_move(scores: dict[str, int], current: int | None) -> str:
|
|
102
|
+
"""Map a score vector to the management move (maturity model §4)."""
|
|
103
|
+
weakest = safe_level(scores)
|
|
104
|
+
if current is None:
|
|
105
|
+
return "formalize: write a contract at the safe level before any change"
|
|
106
|
+
if weakest < current:
|
|
107
|
+
return "contract or re-shape: safety dimensions lag the grant"
|
|
108
|
+
if weakest > current:
|
|
109
|
+
return (
|
|
110
|
+
f"expand (after {CLEAN_CYCLES_TO_EXPAND} clean cycles): "
|
|
111
|
+
"raise one level or widen scope in the strongest dimension"
|
|
112
|
+
)
|
|
113
|
+
if min(scores["D1"], scores["D2"]) < weakest:
|
|
114
|
+
return "hold and coach: capability/clarity lag; keep the grant"
|
|
115
|
+
return "hold: stable at the current level; re-review next horizon"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def assess(scores: dict[str, int], current: int | None) -> dict:
|
|
119
|
+
return {
|
|
120
|
+
"ruleset_version": RULESET_VERSION,
|
|
121
|
+
"scores": {code: scores[code] for code, _ in DIMENSIONS},
|
|
122
|
+
"safe_grant_level": safe_level(scores),
|
|
123
|
+
"granted_level": current,
|
|
124
|
+
"notes": gap_analysis(scores, current),
|
|
125
|
+
"recommended_move": recommended_move(scores, current),
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def render(result: dict) -> str:
|
|
130
|
+
lines = ["Autonomy assessment", "=" * 40]
|
|
131
|
+
lines.append(f"Ruleset version: {RULESET_VERSION}")
|
|
132
|
+
for (code, name) in DIMENSIONS:
|
|
133
|
+
lines.append(f"{code} ({name}): {result['scores'][code]}")
|
|
134
|
+
lines += ["", f"Safe grant level (min D3,D4,D6): L{result['safe_grant_level']}"]
|
|
135
|
+
if result["granted_level"] is not None:
|
|
136
|
+
lines.append(f"Currently granted level: L{result['granted_level']}")
|
|
137
|
+
lines.append(f"Recommended move: {result['recommended_move']}")
|
|
138
|
+
for note in result["notes"]:
|
|
139
|
+
lines.append(f"note: {note}")
|
|
140
|
+
return "\n".join(lines)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _self_test() -> None:
|
|
144
|
+
# Version stamp present in outputs, and identical to the JS engine
|
|
145
|
+
# (parity: docs/versioning.md makes cross-implementation drift a blocker).
|
|
146
|
+
# Enforced when the JS engine sits next to this package (repository
|
|
147
|
+
# checkout, CI); skipped for installed wheels and standalone binaries,
|
|
148
|
+
# which do not ship site/scoring.js — CI keeps enforcing it on push.
|
|
149
|
+
assert RULESET_VERSION == "1.0.0"
|
|
150
|
+
assert assess({d: 1 for d, _ in DIMENSIONS}, None)["ruleset_version"] == RULESET_VERSION
|
|
151
|
+
_repo_root = os.path.join(
|
|
152
|
+
os.path.dirname(os.path.abspath(__file__)), os.pardir, os.pardir
|
|
153
|
+
)
|
|
154
|
+
_js_path = os.path.join(_repo_root, "site", "scoring.js")
|
|
155
|
+
if os.path.exists(_js_path):
|
|
156
|
+
with open(_js_path, encoding="utf-8") as _fh:
|
|
157
|
+
_js = _fh.read()
|
|
158
|
+
_m = re.search(r'RULESET_VERSION\s*=\s*"([^"]+)"', _js)
|
|
159
|
+
assert _m and _m.group(1) == RULESET_VERSION, (
|
|
160
|
+
f"version drift: py={RULESET_VERSION} js={_m and _m.group(1)}"
|
|
161
|
+
)
|
|
162
|
+
else:
|
|
163
|
+
print("note: JS parity check skipped (site/scoring.js not present; standalone install)")
|
|
164
|
+
# Weakest-link rule
|
|
165
|
+
assert safe_level({d: 4 for d, _ in DIMENSIONS}) == 4
|
|
166
|
+
assert safe_level({"D3": 2, "D4": 3, "D6": 1, "D1": 4, "D2": 4, "D5": 4}) == 1
|
|
167
|
+
# Weakest link ignores D1/D2/D5
|
|
168
|
+
assert safe_level({"D3": 3, "D4": 3, "D6": 3, "D1": 0, "D2": 0, "D5": 0}) == 3
|
|
169
|
+
# Worked example from examples/README.md: scores 1 3 2 2 1 1, no formal grant
|
|
170
|
+
ex = assess({"D1": 1, "D2": 3, "D3": 2, "D4": 2, "D5": 1, "D6": 1}, current=None)
|
|
171
|
+
assert ex["safe_grant_level"] == 1
|
|
172
|
+
assert ex["recommended_move"].startswith("formalize")
|
|
173
|
+
# Coaching signal fires when D1/D2 lag the safety floor
|
|
174
|
+
coach = assess({"D1": 0, "D2": 1, "D3": 3, "D4": 3, "D6": 3, "D5": 3}, current=3)
|
|
175
|
+
assert any("coaching" in n for n in coach["notes"])
|
|
176
|
+
assert coach["recommended_move"].startswith("hold and coach")
|
|
177
|
+
# Over-grant detection
|
|
178
|
+
over = assess({"D1": 4, "D2": 4, "D3": 1, "D4": 4, "D5": 4, "D6": 4}, current=3)
|
|
179
|
+
assert any("over-grant" in n for n in over["notes"])
|
|
180
|
+
assert over["recommended_move"].startswith("contract")
|
|
181
|
+
# Validation errors
|
|
182
|
+
for bad in (["1", "2", "3", "4", "5"], ["1", "2", "3", "4", "5", "x"], ["9"] * 6):
|
|
183
|
+
try:
|
|
184
|
+
parse_scores(bad)
|
|
185
|
+
except SystemExit:
|
|
186
|
+
pass
|
|
187
|
+
else:
|
|
188
|
+
raise AssertionError(f"expected validation failure for {bad}")
|
|
189
|
+
print("self-test: OK")
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def main(argv: list[str] | None = None) -> int:
|
|
193
|
+
parser = argparse.ArgumentParser(
|
|
194
|
+
description="Compute safe autonomy level and recommended move from six dimension scores."
|
|
195
|
+
)
|
|
196
|
+
parser.add_argument("scores", nargs="*", help="six scores 0..4 for D1..D6")
|
|
197
|
+
parser.add_argument("--current", type=int, metavar="L", help="currently granted level 0..4")
|
|
198
|
+
parser.add_argument("--json", action="store_true", help="emit JSON")
|
|
199
|
+
parser.add_argument("--self-test", action="store_true", help="run built-in tests and exit")
|
|
200
|
+
args = parser.parse_args(argv)
|
|
201
|
+
|
|
202
|
+
if args.self_test:
|
|
203
|
+
_self_test()
|
|
204
|
+
return 0
|
|
205
|
+
|
|
206
|
+
if args.current is not None and not 0 <= args.current <= 4:
|
|
207
|
+
raise SystemExit("error: --current must be in 0..4")
|
|
208
|
+
|
|
209
|
+
scores = parse_scores(args.scores)
|
|
210
|
+
result = assess(scores, args.current)
|
|
211
|
+
print(json.dumps(result, indent=2) if args.json else render(result))
|
|
212
|
+
return 0
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
if __name__ == "__main__":
|
|
216
|
+
sys.exit(main())
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: measuring-autonomy
|
|
3
|
+
Version: 1.1.0
|
|
4
|
+
Summary: Measure and manage autonomy: safe-level scoring, Autonomy Ledger health checks, and framework tooling.
|
|
5
|
+
Author: Emmanuel A. Otchere
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/emmanuel-a-otchere/MeasuringAndManagingAutonomy
|
|
8
|
+
Project-URL: Changelog, https://github.com/emmanuel-a-otchere/MeasuringAndManagingAutonomy/blob/main/CHANGELOG.md
|
|
9
|
+
Keywords: autonomy,governance,maturity-model,management,org-design
|
|
10
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Intended Audience :: Information Technology
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Topic :: Office/Business
|
|
23
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
24
|
+
Requires-Python: >=3.9
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
License-File: LICENSE
|
|
27
|
+
Requires-Dist: pyyaml>=5.4
|
|
28
|
+
Dynamic: license-file
|
|
29
|
+
|
|
30
|
+
# Measuring and Managing Autonomy
|
|
31
|
+
|
|
32
|
+
Autonomy from a business and systems point of view can be challenging to
|
|
33
|
+
manage. This project defines a framework by which managing and measuring
|
|
34
|
+
autonomy can be designed and implemented.
|
|
35
|
+
|
|
36
|
+
Most organizations manage autonomy implicitly — it emerges from trust, tenure,
|
|
37
|
+
and accidents of structure, and is therefore felt but not measured, expanded
|
|
38
|
+
but never designed, and revoked arbitrarily. This framework makes autonomy an
|
|
39
|
+
**explicit, designed, measurable property** of the organization.
|
|
40
|
+
|
|
41
|
+
## The framework in one diagram
|
|
42
|
+
|
|
43
|
+
```mermaid
|
|
44
|
+
flowchart LR
|
|
45
|
+
A[Design<br>decision + blast radius] --> B[Grant<br>autonomy contract]
|
|
46
|
+
B --> C[Measure<br>ledger + telemetry]
|
|
47
|
+
C --> D[Manage<br>expand / contract / re-shape]
|
|
48
|
+
D -->|re-grant, next horizon| B
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## What's here
|
|
52
|
+
|
|
53
|
+
| Document | Purpose |
|
|
54
|
+
|---|---|
|
|
55
|
+
| [Framework](docs/framework.md) | Definitions, the three axes, the five levels (L0–L4), autonomy contracts |
|
|
56
|
+
| [Maturity model](docs/maturity-model.md) | Six dimensions × five levels grid, scoring rules, the Autonomy Ledger |
|
|
57
|
+
| [Assessment](docs/assessment.md) | The questionnaire and scoring procedure — the practical instrument |
|
|
58
|
+
| [Governance loop](docs/governance-loop.md) | Grant → Instrument → Observe → Review, with cadence and anti-patterns |
|
|
59
|
+
| [Positioning](docs/positioning.md) | Related work, lineage, open questions, BibTeX |
|
|
60
|
+
| [Worked example](examples/README.md) | One team, scored end to end, producing a governance move |
|
|
61
|
+
| [AI agents as units](docs/agents-as-units.md) | First-pass extension: scoring agent units with the same instrument |
|
|
62
|
+
| [Ledger format](docs/ledger-format.md) | Machine-readable Autonomy Ledger (YAML v1) + health checks |
|
|
63
|
+
| [Contract template](docs/contract-template.md) | Autonomy contract + review agenda, ready to copy |
|
|
64
|
+
| [Versioning](docs/versioning.md) | Ruleset/ledger versioning policy — how assessments stay comparable |
|
|
65
|
+
| [Changelog](CHANGELOG.md) | Every change, classified by comparability impact |
|
|
66
|
+
|
|
67
|
+
## Install
|
|
68
|
+
|
|
69
|
+
**No Python needed** — standalone binaries for macOS, Linux, and Windows are
|
|
70
|
+
attached to each [GitHub Release](https://github.com/emmanuel-a-otchere/MeasuringAndManagingAutonomy/releases):
|
|
71
|
+
download `autonomy-score-<os>-<arch>` and `ledger-health-<os>-<arch>`, mark
|
|
72
|
+
them executable, run.
|
|
73
|
+
|
|
74
|
+
**With Python 3.9+** (installs the `autonomy-score` and `ledger-health`
|
|
75
|
+
commands):
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
pipx install measuring-autonomy # or: pip install measuring-autonomy
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
**From a clone**, the zero-install form keeps working with no packaging step:
|
|
82
|
+
`python3 tools/autonomy_score.py ...` (the `tools/` scripts are shims over
|
|
83
|
+
`src/measuring_autonomy/`).
|
|
84
|
+
|
|
85
|
+
## Quick start: score a team
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
python3 tools/autonomy_score.py 1 3 2 2 1 1 --current 1
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Scores are D1 clarity, D2 capability, D3 guardrails, D4 telemetry,
|
|
92
|
+
D5 transparency, D6 accountability (0–4 each). The tool applies the
|
|
93
|
+
weakest-link rule — `min(guardrails, telemetry, accountability)` — and
|
|
94
|
+
outputs the safe grant level and recommended move.
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
python3 tools/autonomy_score.py --self-test # verify the scoring rules
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Track granted autonomy in a machine-readable **Autonomy Ledger** (v1 YAML
|
|
101
|
+
format, [`examples/ledger.yaml`](examples/ledger.yaml)) and check its health:
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
python3 tools/ledger_health.py examples/ledger.yaml # requires PyYAML (pip install pyyaml)
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Try it without a terminal
|
|
108
|
+
|
|
109
|
+
A dependency-free web version of the assessment lives in [`site/`](site/)
|
|
110
|
+
(and is published to GitHub Pages when enabled — repo **Settings → Pages →
|
|
111
|
+
Source: GitHub Actions**). Open `site/index.html` locally, tick the items
|
|
112
|
+
with evidence, and print the scorecard.
|
|
113
|
+
|
|
114
|
+
## The three questions
|
|
115
|
+
|
|
116
|
+
1. **Design** — What autonomy should this unit have, and why? →
|
|
117
|
+
[contracts](docs/framework.md#5-autonomy-contracts)
|
|
118
|
+
2. **Measurement** — How much autonomy exists, and how well is it used? →
|
|
119
|
+
[maturity model](docs/maturity-model.md) + [assessment](docs/assessment.md)
|
|
120
|
+
3. **Management** — When should autonomy expand, contract, or re-shape? →
|
|
121
|
+
[governance loop](docs/governance-loop.md)
|
|
122
|
+
|
|
123
|
+
## Roadmap
|
|
124
|
+
|
|
125
|
+
See [docs/roadmap.md](docs/roadmap.md). Current status: **v0.1 draft** — the
|
|
126
|
+
written framework, maturity model, and assessment instrument are complete;
|
|
127
|
+
next up are validation against real teams and optional tooling.
|
|
128
|
+
|
|
129
|
+
## Contributing
|
|
130
|
+
|
|
131
|
+
Feedback, corrections, and real-world applications are very welcome — see
|
|
132
|
+
[CONTRIBUTING.md](CONTRIBUTING.md). Use the issue templates to report
|
|
133
|
+
framework feedback or submit a case study.
|
|
134
|
+
|
|
135
|
+
## License
|
|
136
|
+
|
|
137
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
measuring_autonomy/__init__.py,sha256=4DcuKsJQlKOkC5_HxNh3lTt5bk-Mu0UDvkXU9we_2lA,730
|
|
2
|
+
measuring_autonomy/ledger.py,sha256=G9mQOyPOhT-vsJmopbx1O80-SvKGWNFs_7VRcN03RZU,11776
|
|
3
|
+
measuring_autonomy/score.py,sha256=PJ3mLwPnTwUEvvSpANYOIZAC1yCbtidLfpuegDGop8Y,8821
|
|
4
|
+
measuring_autonomy-1.1.0.dist-info/licenses/LICENSE,sha256=Uyxb-RRxVwMjN0PYUEysE-dHMo_IdVzJ41H6ENIuag8,1526
|
|
5
|
+
measuring_autonomy-1.1.0.dist-info/METADATA,sha256=6h5pauuK7ACpFgXmyG4MGJ54Xoxkclsbulc8KX55aBw,5875
|
|
6
|
+
measuring_autonomy-1.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
7
|
+
measuring_autonomy-1.1.0.dist-info/entry_points.txt,sha256=KAvqDpYsiQTOVZpZw5avIC49nBrGkwvj4Mi_BY7YIYM,112
|
|
8
|
+
measuring_autonomy-1.1.0.dist-info/top_level.txt,sha256=MhB6uAgKVwArcQR5-uQE1d6SghqRl6s3iEg2JH22nkQ,19
|
|
9
|
+
measuring_autonomy-1.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
BSD 3-Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2022, emmanuel-a-otchere
|
|
4
|
+
All rights reserved.
|
|
5
|
+
|
|
6
|
+
Redistribution and use in source and binary forms, with or without
|
|
7
|
+
modification, are permitted provided that the following conditions are met:
|
|
8
|
+
|
|
9
|
+
1. Redistributions of source code must retain the above copyright notice, this
|
|
10
|
+
list of conditions and the following disclaimer.
|
|
11
|
+
|
|
12
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
13
|
+
this list of conditions and the following disclaimer in the documentation
|
|
14
|
+
and/or other materials provided with the distribution.
|
|
15
|
+
|
|
16
|
+
3. Neither the name of the copyright holder nor the names of its
|
|
17
|
+
contributors may be used to endorse or promote products derived from
|
|
18
|
+
this software without specific prior written permission.
|
|
19
|
+
|
|
20
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
21
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
22
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
23
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
24
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
25
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
26
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
27
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
28
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
29
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
measuring_autonomy
|