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 ADDED
@@ -0,0 +1,3 @@
1
+ """madcop: a pluggable LangGraph framework for supply chain anomaly orchestration."""
2
+
3
+ __version__ = "0.1.0"
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
@@ -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