madcop 0.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.
- madcop/__init__.py +3 -0
- madcop/__main__.py +123 -0
- madcop/adapters/__init__.py +0 -0
- madcop/adapters/base.py +82 -0
- madcop/adapters/wms.py +94 -0
- madcop/anomaly/__init__.py +0 -0
- madcop/anomaly/detector.py +88 -0
- madcop/anomaly/rules.py +387 -0
- madcop/event.py +160 -0
- madcop/graph/__init__.py +0 -0
- madcop/rca/graph.py +263 -0
- madcop/rca/seed.py +71 -0
- madcop/strategy/__init__.py +0 -0
- madcop-0.1.0.dist-info/METADATA +201 -0
- madcop-0.1.0.dist-info/RECORD +18 -0
- madcop-0.1.0.dist-info/WHEEL +5 -0
- madcop-0.1.0.dist-info/licenses/LICENSE +21 -0
- madcop-0.1.0.dist-info/top_level.txt +1 -0
madcop/__init__.py
ADDED
madcop/__main__.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""madcop CLI entry point.
|
|
2
|
+
|
|
3
|
+
Two demo scenarios today:
|
|
4
|
+
python -m madcop run coldchain # W1 — print the event stream
|
|
5
|
+
python -m madcop run anomalies coldchain # W2 — run anomaly detection
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import sys
|
|
12
|
+
|
|
13
|
+
from .adapters.wms import WMSAdapter
|
|
14
|
+
from .anomaly.rules import default_detector
|
|
15
|
+
from .rca.graph import explain, trace
|
|
16
|
+
from .rca.seed import build_coldchain_seed
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _format_event(idx: int, total: int, ev) -> str:
|
|
20
|
+
ts = ev.parsed_timestamp.strftime("%H:%M:%S")
|
|
21
|
+
val = f"{ev.value:>6.1f}°C" if ev.value is not None else " — "
|
|
22
|
+
sev = "·" * ev.severity
|
|
23
|
+
return f" [{idx:>2}/{total}] {ts} {val} sev{ev.severity} {sev} {ev.attributes.get('note', '')}"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def run_coldchain() -> int:
|
|
27
|
+
"""W1 demo: print the WMS cold-chain event stream."""
|
|
28
|
+
adapter = WMSAdapter()
|
|
29
|
+
events = sorted(adapter.fetch(), key=lambda e: e.parsed_timestamp)
|
|
30
|
+
if not events:
|
|
31
|
+
print("(no events)", file=sys.stderr)
|
|
32
|
+
return 1
|
|
33
|
+
print(f"cold-chain timeline for {events[0].subject_id}")
|
|
34
|
+
print(f" threshold: {adapter.COLD_CHAIN_THRESHOLD_C}°C")
|
|
35
|
+
print()
|
|
36
|
+
for i, ev in enumerate(events, 1):
|
|
37
|
+
print(_format_event(i, len(events), ev))
|
|
38
|
+
breaches = [e for e in events if e.value is not None and e.value > adapter.COLD_CHAIN_THRESHOLD_C]
|
|
39
|
+
print()
|
|
40
|
+
print(f" → {len(breaches)} threshold breach(es) detected")
|
|
41
|
+
return 0
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def run_anomalies_coldchain() -> int:
|
|
45
|
+
"""W2 demo: run all 5 anomaly rules on the cold-chain stream."""
|
|
46
|
+
adapter = WMSAdapter()
|
|
47
|
+
events = sorted(adapter.fetch(), key=lambda e: e.parsed_timestamp)
|
|
48
|
+
detector = default_detector()
|
|
49
|
+
findings = list(detector.run(events))
|
|
50
|
+
print(f"madcop anomaly report — subject={events[0].subject_id if events else 'N/A'}")
|
|
51
|
+
print(f" events: {len(events)} rules: {len(detector.rules)} findings: {len(findings)}")
|
|
52
|
+
print()
|
|
53
|
+
if not findings:
|
|
54
|
+
print(" (no anomalies)")
|
|
55
|
+
return 0
|
|
56
|
+
for i, f in enumerate(findings, 1):
|
|
57
|
+
print(f" [{i}/{len(findings)}] sev{f.severity} {f.rule_id}")
|
|
58
|
+
print(f" {f.summary}")
|
|
59
|
+
return 0
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def run_rca_coldchain() -> int:
|
|
63
|
+
"""W3 demo: detect anomalies and trace each to a root-cause decision."""
|
|
64
|
+
from rich.console import Console
|
|
65
|
+
from rich.panel import Panel
|
|
66
|
+
|
|
67
|
+
console = Console()
|
|
68
|
+
events = sorted(WMSAdapter().fetch(), key=lambda e: e.parsed_timestamp)
|
|
69
|
+
findings = list(default_detector().run(events))
|
|
70
|
+
g = build_coldchain_seed()
|
|
71
|
+
|
|
72
|
+
console.print(f"[bold]madcop RCA demo[/] — {len(findings)} finding(s) on {events[0].subject_id}\n")
|
|
73
|
+
if not findings:
|
|
74
|
+
console.print(" [dim](no findings to trace)[/]")
|
|
75
|
+
return 0
|
|
76
|
+
for i, f in enumerate(findings, 1):
|
|
77
|
+
console.print(f"[cyan]━━━ finding {i}/{len(findings)} ━━━[/]")
|
|
78
|
+
console.print(f" rule: [yellow]{f.rule_id}[/]")
|
|
79
|
+
console.print(f" summary: {f.summary}")
|
|
80
|
+
chain = trace(f, g)
|
|
81
|
+
if not chain.steps:
|
|
82
|
+
console.print(" [dim](no causal chain — subject not in knowledge graph)[/]")
|
|
83
|
+
continue
|
|
84
|
+
console.print(f" chain: [bold]{len(chain.steps)}[/] step(s), root cause:")
|
|
85
|
+
console.print(Panel(explain(chain), title="root cause", border_style="red"))
|
|
86
|
+
return 0
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def main(argv: list[str] | None = None) -> int:
|
|
90
|
+
parser = argparse.ArgumentParser(
|
|
91
|
+
prog="madcop",
|
|
92
|
+
description="madcop — the supply chain cop that goes mad for anomalies.",
|
|
93
|
+
)
|
|
94
|
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
95
|
+
|
|
96
|
+
# run <scenario>
|
|
97
|
+
run_p = sub.add_parser("run", help="Run a scenario")
|
|
98
|
+
run_sub = run_p.add_subparsers(dest="scenario", required=True)
|
|
99
|
+
run_sub.add_parser("coldchain", help="W1: print the cold-chain event stream")
|
|
100
|
+
run_sub.add_parser("anomalies", help="W2: run anomaly detection on the cold-chain stream")
|
|
101
|
+
run_sub.add_parser("rca", help="W3: detect anomalies and trace each to a root cause")
|
|
102
|
+
|
|
103
|
+
# demo <scenario> — alias for `run`
|
|
104
|
+
demo_p = sub.add_parser("demo", help="Alias for `run`")
|
|
105
|
+
demo_sub = demo_p.add_subparsers(dest="scenario", required=True)
|
|
106
|
+
demo_sub.add_parser("coldchain", help="W1: print the cold-chain event stream")
|
|
107
|
+
demo_sub.add_parser("anomalies", help="W2: run anomaly detection on the cold-chain stream")
|
|
108
|
+
demo_sub.add_parser("rca", help="W3: detect anomalies and trace each to a root cause")
|
|
109
|
+
|
|
110
|
+
args = parser.parse_args(argv)
|
|
111
|
+
if args.cmd in ("run", "demo"):
|
|
112
|
+
if args.scenario == "coldchain":
|
|
113
|
+
return run_coldchain()
|
|
114
|
+
if args.scenario == "anomalies":
|
|
115
|
+
return run_anomalies_coldchain()
|
|
116
|
+
if args.scenario == "rca":
|
|
117
|
+
return run_rca_coldchain()
|
|
118
|
+
parser.print_help()
|
|
119
|
+
return 2
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
if __name__ == "__main__":
|
|
123
|
+
sys.exit(main())
|
|
File without changes
|
madcop/adapters/base.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Adapter contract — the seam between the outside world and `UnifiedEvent`.
|
|
2
|
+
|
|
3
|
+
Every system (OMS / TMS / WMS / BMS / future) ships an adapter that implements
|
|
4
|
+
`BaseAdapter`. Two responsibilities, and only two:
|
|
5
|
+
|
|
6
|
+
1. **Pull** raw data from a system (HTTP, DB, file, etc.) and yield `UnifiedEvent`s.
|
|
7
|
+
2. **Push** actions back to the system (e.g. re-route shipment, mark exception).
|
|
8
|
+
|
|
9
|
+
We deliberately keep the adapter small. Logic that *reads* the event stream
|
|
10
|
+
lives in the anomaly engine. Logic that *acts* on decisions lives in the
|
|
11
|
+
strategy router. The adapter is just a translator.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from abc import ABC, abstractmethod
|
|
17
|
+
from typing import Iterator
|
|
18
|
+
|
|
19
|
+
from ..event import SourceSystem, UnifiedEvent
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class BaseAdapter(ABC):
|
|
23
|
+
"""The contract every system adapter must satisfy.
|
|
24
|
+
|
|
25
|
+
Adapters are stateful objects (they hold a connection, an API key, a
|
|
26
|
+
file handle). They are constructed once per session and called repeatedly.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
source_system: SourceSystem # set by subclass
|
|
30
|
+
|
|
31
|
+
@abstractmethod
|
|
32
|
+
def fetch(self, *, since: str | None = None, subject_id: str | None = None) -> Iterator[UnifiedEvent]:
|
|
33
|
+
"""Yield events from the upstream system, optionally filtered.
|
|
34
|
+
|
|
35
|
+
`since` is a UTC ISO 8601 string. If given, only events with
|
|
36
|
+
`timestamp >= since` should be returned.
|
|
37
|
+
|
|
38
|
+
`subject_id`, if given, restricts to events about a single business
|
|
39
|
+
object (an order, a SKU, a shipment, a contract). Adapters that
|
|
40
|
+
cannot filter server-side should filter in-memory after fetching.
|
|
41
|
+
|
|
42
|
+
Yields events in **any order** — the LangGraph orchestrator sorts
|
|
43
|
+
by timestamp.
|
|
44
|
+
"""
|
|
45
|
+
raise NotImplementedError
|
|
46
|
+
|
|
47
|
+
@abstractmethod
|
|
48
|
+
def execute(self, action: "Action") -> dict:
|
|
49
|
+
"""Push an action back to the system. Returns the system's response.
|
|
50
|
+
|
|
51
|
+
See `Action` for the schema. Adapters that cannot perform an action
|
|
52
|
+
(e.g. WMS can't change a contract) should raise
|
|
53
|
+
`UnsupportedActionError`.
|
|
54
|
+
"""
|
|
55
|
+
raise NotImplementedError
|
|
56
|
+
|
|
57
|
+
def health_check(self) -> bool:
|
|
58
|
+
"""Optional. Default: assume healthy. Override for real wire checks."""
|
|
59
|
+
return True
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class UnsupportedActionError(NotImplementedError):
|
|
63
|
+
"""Raised when an adapter is asked to perform an action outside its scope."""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
from dataclasses import dataclass
|
|
67
|
+
from typing import Any, Mapping
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True)
|
|
71
|
+
class Action:
|
|
72
|
+
"""A command the strategy router wants executed against an adapter.
|
|
73
|
+
|
|
74
|
+
The action is **adapter-agnostic**: the router does not know which system
|
|
75
|
+
will run it. The router picks an adapter based on `target_system` and the
|
|
76
|
+
adapter translates the rest.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
target_system: SourceSystem
|
|
80
|
+
action_type: str # free-form, adapter-defined vocabulary
|
|
81
|
+
subject_id: str # what the action is about
|
|
82
|
+
parameters: Mapping[str, Any] # adapter-specific args
|
madcop/adapters/wms.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""WMS mock adapter — cold-chain temperature monitoring.
|
|
2
|
+
|
|
3
|
+
W1 deliverable: 1 of 4 mock adapters. This one is real-enough to drive a
|
|
4
|
+
cold-chain anomaly demo. Future weeks add OMS, TMS, BMS as separate files
|
|
5
|
+
following the same shape.
|
|
6
|
+
|
|
7
|
+
The "data" is hardcoded. That's intentional — a real wire adapter would swap
|
|
8
|
+
`fetch()` for an HTTP call, but the **shape** is what the framework depends
|
|
9
|
+
on, and the shape lives in `BaseAdapter`.
|
|
10
|
+
|
|
11
|
+
Scenario: a 冷链 (cold-chain) shipment from 广州仓 → 上海仓 on 2026-06-15.
|
|
12
|
+
- 08:00 dispatch, setpoint -18°C
|
|
13
|
+
- 12:00 routine reading, -17.8°C
|
|
14
|
+
- 14:30 reading: -14.2°C (!!! threshold breach, severity 4)
|
|
15
|
+
- 15:00 reading: -13.8°C
|
|
16
|
+
- 15:30 reading: -17.5°C (recovered — driver opened the door, not equipment)
|
|
17
|
+
- 18:42 delivered, last reading -17.6°C
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from typing import Iterator
|
|
23
|
+
|
|
24
|
+
from ..event import EventType, SourceSystem, UnifiedEvent, make_event
|
|
25
|
+
from .base import Action, BaseAdapter, UnsupportedActionError
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class WMSAdapter(BaseAdapter):
|
|
29
|
+
"""WMS adapter (mock). Cold-chain temperature stream for one shipment."""
|
|
30
|
+
|
|
31
|
+
source_system = SourceSystem.WMS
|
|
32
|
+
|
|
33
|
+
# Cold-chain threshold, in °C. Anything above this for any reading is
|
|
34
|
+
# an anomaly. We hardcode it here; in a real adapter it would come from
|
|
35
|
+
# the WMS product master.
|
|
36
|
+
COLD_CHAIN_THRESHOLD_C = -15.0
|
|
37
|
+
|
|
38
|
+
def __init__(self, shipment_id: str = "SHIP-2026-0615-CG-SH"):
|
|
39
|
+
self.shipment_id = shipment_id
|
|
40
|
+
|
|
41
|
+
def fetch(
|
|
42
|
+
self,
|
|
43
|
+
*,
|
|
44
|
+
since: str | None = None,
|
|
45
|
+
subject_id: str | None = None,
|
|
46
|
+
) -> Iterator[UnifiedEvent]:
|
|
47
|
+
# The data: a single shipment's temperature stream.
|
|
48
|
+
# In a real adapter this would come from `requests.get(...)` or a DB
|
|
49
|
+
# query. Here it's a static list so the demo is reproducible.
|
|
50
|
+
raw = [
|
|
51
|
+
# (timestamp, value, severity, attributes)
|
|
52
|
+
("2026-06-15T08:00:00Z", -18.0, 1, {"setpoint_c": -18.0, "phase": "dispatch"}),
|
|
53
|
+
("2026-06-15T12:00:00Z", -17.8, 1, {"setpoint_c": -18.0, "phase": "in_transit"}),
|
|
54
|
+
("2026-06-15T14:30:00Z", -14.2, 4, {"setpoint_c": -18.0, "phase": "in_transit",
|
|
55
|
+
"note": "threshold breach"}),
|
|
56
|
+
("2026-06-15T15:00:00Z", -13.8, 5, {"setpoint_c": -18.0, "phase": "in_transit",
|
|
57
|
+
"note": "still rising"}),
|
|
58
|
+
("2026-06-15T15:30:00Z", -17.5, 1, {"setpoint_c": -18.0, "phase": "in_transit",
|
|
59
|
+
"note": "recovered — door opened at 15:25"}),
|
|
60
|
+
("2026-06-15T18:42:00Z", -17.6, 1, {"setpoint_c": -18.0, "phase": "delivered"}),
|
|
61
|
+
]
|
|
62
|
+
for ts, value, sev, attrs in raw:
|
|
63
|
+
if subject_id and subject_id != self.shipment_id:
|
|
64
|
+
continue
|
|
65
|
+
yield make_event(
|
|
66
|
+
timestamp=ts,
|
|
67
|
+
source_system=SourceSystem.WMS,
|
|
68
|
+
event_type=EventType.TEMPERATURE_READING,
|
|
69
|
+
subject_id=self.shipment_id,
|
|
70
|
+
value=value,
|
|
71
|
+
attributes=attrs,
|
|
72
|
+
severity=sev,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
def execute(self, action: Action) -> dict:
|
|
76
|
+
# WMS can mark exceptions, request re-icing, dispatch a recovery team.
|
|
77
|
+
# Anything else is out of scope.
|
|
78
|
+
supported = {"mark_exception", "request_re_ice", "dispatch_recovery"}
|
|
79
|
+
if action.action_type not in supported:
|
|
80
|
+
raise UnsupportedActionError(
|
|
81
|
+
f"WMSAdapter does not support action {action.action_type!r}; "
|
|
82
|
+
f"supported: {sorted(supported)}"
|
|
83
|
+
)
|
|
84
|
+
# Mock: just echo.
|
|
85
|
+
return {
|
|
86
|
+
"ok": True,
|
|
87
|
+
"system": "wms",
|
|
88
|
+
"action": action.action_type,
|
|
89
|
+
"subject_id": action.subject_id,
|
|
90
|
+
"parameters": dict(action.parameters),
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
def health_check(self) -> bool:
|
|
94
|
+
return True
|
|
File without changes
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""L2 — Anomaly detection.
|
|
2
|
+
|
|
3
|
+
The detector is a thin orchestrator over a list of `BaseRule` instances. Each
|
|
4
|
+
rule inspects the event stream and either fires or stays silent. The detector
|
|
5
|
+
collects fired rules and returns a list of `AnomalyFinding`s.
|
|
6
|
+
|
|
7
|
+
Design choices:
|
|
8
|
+
|
|
9
|
+
- Rules are **stateless** and **per-event**. A rule that needs a window
|
|
10
|
+
(e.g. "breach sustained for >15 min") does its own windowing internally.
|
|
11
|
+
This is simple and correct, at the cost of some redundant work. Optimize
|
|
12
|
+
later if needed.
|
|
13
|
+
- A single event can fire multiple rules. We do not deduplicate. Different
|
|
14
|
+
rules signal different concerns (a single breach can be both a temperature
|
|
15
|
+
rule AND a sustained-duration rule).
|
|
16
|
+
- Rules have a `rule_id` so downstream consumers (the strategy router in W5,
|
|
17
|
+
the weekly report in W7) can identify them in feedback logs.
|
|
18
|
+
- `severity` here is the **rule's** severity, distinct from the event's
|
|
19
|
+
adapter-set severity. The orchestrator will eventually combine them; for
|
|
20
|
+
W2, the rule severity is what we surface.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from abc import ABC, abstractmethod
|
|
26
|
+
from dataclasses import dataclass, field
|
|
27
|
+
from typing import Iterable, Sequence
|
|
28
|
+
|
|
29
|
+
from ..event import UnifiedEvent
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class AnomalyFinding:
|
|
34
|
+
"""A single rule's verdict on a single event (or window)."""
|
|
35
|
+
rule_id: str
|
|
36
|
+
subject_id: str
|
|
37
|
+
timestamp: str # when the anomaly was observed
|
|
38
|
+
severity: int # 1..5, set by the rule
|
|
39
|
+
summary: str # one-line human description
|
|
40
|
+
details: dict = field(default_factory=dict)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class BaseRule(ABC):
|
|
44
|
+
"""A single anomaly detection rule.
|
|
45
|
+
|
|
46
|
+
Rules are constructed once and reused. They must be **idempotent**:
|
|
47
|
+
calling `evaluate()` twice with the same event must yield the same result.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
rule_id: str # set by subclass
|
|
51
|
+
description: str # one-line human description
|
|
52
|
+
|
|
53
|
+
@abstractmethod
|
|
54
|
+
def evaluate(self, event: UnifiedEvent) -> AnomalyFinding | None:
|
|
55
|
+
"""Return an `AnomalyFinding` if this rule fires, else `None`."""
|
|
56
|
+
raise NotImplementedError
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class Detector:
|
|
60
|
+
"""Runs a set of rules over a stream of events.
|
|
61
|
+
|
|
62
|
+
Usage:
|
|
63
|
+
detector = Detector([ColdChainTemperature(threshold_c=-15.0)])
|
|
64
|
+
findings = list(detector.run(events))
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
def __init__(self, rules: Sequence[BaseRule]):
|
|
68
|
+
if not rules:
|
|
69
|
+
raise ValueError("Detector requires at least one rule")
|
|
70
|
+
self._rules = list(rules)
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def rules(self) -> Sequence[BaseRule]:
|
|
74
|
+
return tuple(self._rules)
|
|
75
|
+
|
|
76
|
+
def evaluate_event(self, event: UnifiedEvent) -> list[AnomalyFinding]:
|
|
77
|
+
"""Run all rules on a single event. Returns findings (may be empty)."""
|
|
78
|
+
return [
|
|
79
|
+
f for f in (r.evaluate(event) for r in self._rules)
|
|
80
|
+
if f is not None
|
|
81
|
+
]
|
|
82
|
+
|
|
83
|
+
def run(self, events: Iterable[UnifiedEvent]) -> Iterable[AnomalyFinding]:
|
|
84
|
+
"""Stream findings as we iterate events. Order: rule order per event,
|
|
85
|
+
event order across the stream."""
|
|
86
|
+
for ev in events:
|
|
87
|
+
for f in self.evaluate_event(ev):
|
|
88
|
+
yield f
|