flight-alloc 0.0.1__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.
Files changed (71) hide show
  1. flight_alloc-0.0.1.dist-info/METADATA +11 -0
  2. flight_alloc-0.0.1.dist-info/RECORD +71 -0
  3. flight_alloc-0.0.1.dist-info/WHEEL +5 -0
  4. flight_alloc-0.0.1.dist-info/entry_points.txt +2 -0
  5. flight_alloc-0.0.1.dist-info/top_level.txt +1 -0
  6. src/__init__.py +0 -0
  7. src/allocator/__init__.py +6 -0
  8. src/allocator/caps.py +513 -0
  9. src/allocator/eligibility.py +302 -0
  10. src/allocator/greedy_fallback.py +106 -0
  11. src/allocator/invariants.py +124 -0
  12. src/allocator/p2f_priority.py +142 -0
  13. src/allocator/pair_validation.py +108 -0
  14. src/allocator/pairings.py +554 -0
  15. src/allocator/postpass_break.py +366 -0
  16. src/allocator/postpass_intl.py +483 -0
  17. src/allocator/postpass_p2f.py +723 -0
  18. src/allocator/postpass_rebalance.py +244 -0
  19. src/allocator/postsolve.py +549 -0
  20. src/allocator/recommender.py +348 -0
  21. src/allocator/windows.py +377 -0
  22. src/cli.py +41 -0
  23. src/config.py +168 -0
  24. src/greedy_fallback.py +102 -0
  25. src/io/__init__.py +0 -0
  26. src/io/export.py +270 -0
  27. src/io/export_xml.py +66 -0
  28. src/io/readers.py +1048 -0
  29. src/io/roster_library.py +89 -0
  30. src/plan.py +192 -0
  31. src/recommender_staffing.py +329 -0
  32. src/roster_store.py +159 -0
  33. src/schemas.py +1244 -0
  34. src/solver/__init__.py +0 -0
  35. src/solver/allocator_cpsat.py +1412 -0
  36. src/staged_overrides.py +468 -0
  37. src/state.py +494 -0
  38. src/step1_clean_flights.py +286 -0
  39. src/step2_extract_roster.py +316 -0
  40. src/step3_allocate_flights.py +1639 -0
  41. src/web/__init__.py +47 -0
  42. src/web/__main__.py +9 -0
  43. src/web/api/__init__.py +56 -0
  44. src/web/api/export.py +37 -0
  45. src/web/api/inputs.py +122 -0
  46. src/web/api/override_rows.py +138 -0
  47. src/web/api/pages.py +30 -0
  48. src/web/api/readbacks.py +72 -0
  49. src/web/api/recommender.py +72 -0
  50. src/web/api/runs.py +102 -0
  51. src/web/api/settings.py +201 -0
  52. src/web/api/zc.py +117 -0
  53. src/web/core/__init__.py +5 -0
  54. src/web/core/responses.py +91 -0
  55. src/web/core/router.py +167 -0
  56. src/web/core/static_files.py +85 -0
  57. src/web/overrides/__init__.py +66 -0
  58. src/web/overrides/airports.py +261 -0
  59. src/web/overrides/break_time.py +83 -0
  60. src/web/overrides/config_yaml.py +21 -0
  61. src/web/overrides/filters.py +187 -0
  62. src/web/overrides/rows.py +110 -0
  63. src/web/readback/__init__.py +67 -0
  64. src/web/readback/common.py +68 -0
  65. src/web/readback/dashboard.py +83 -0
  66. src/web/readback/planning.py +335 -0
  67. src/web/readback/session.py +158 -0
  68. src/web/readback/tables.py +163 -0
  69. src/web/runner.py +168 -0
  70. src/web/server.py +185 -0
  71. src/zc_store.py +221 -0
src/web/runner.py ADDED
@@ -0,0 +1,168 @@
1
+ """Runs Plan / Allocate on a background thread for the web UI.
2
+
3
+ State lives in this process, so the stages run in-thread rather than in
4
+ a subprocess — there is no workbook to hand off through. The UI fires
5
+ POST /api/run and then polls GET /api/run/status until it settles.
6
+
7
+ Run state is module-level: the server is single-process and serves one
8
+ assigner.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import threading
14
+ import traceback
15
+ from dataclasses import dataclass, field
16
+ from datetime import date as date_t
17
+ from datetime import datetime
18
+ from pathlib import Path
19
+ from typing import Literal
20
+
21
+ from ..state import AppState
22
+
23
+ RunState = Literal["idle", "running", "ok", "failed"]
24
+
25
+ REPO_ROOT = Path(__file__).resolve().parents[2]
26
+ CONFIG_PATH = REPO_ROOT / "configs" / "config.yml"
27
+
28
+
29
+ @dataclass
30
+ class _Run:
31
+ state: RunState = "idle"
32
+ step: str = ""
33
+ started_at: datetime | None = None
34
+ finished_at: datetime | None = None
35
+ error: str = ""
36
+ counts: dict[str, int] = field(default_factory=dict)
37
+ cancelled: bool = False
38
+ """Set by Reset while a run is in flight. The worker checks it before
39
+ committing results, so a cancelled run leaves the freshly-cleared
40
+ state alone instead of repopulating it."""
41
+
42
+
43
+ _run = _Run()
44
+ _lock = threading.Lock()
45
+
46
+
47
+ def status() -> dict[str, object]:
48
+ """Snapshot for ``GET /api/run/status``."""
49
+ with _lock:
50
+ elapsed = None
51
+ if _run.started_at:
52
+ end = _run.finished_at or datetime.now()
53
+ elapsed = round((end - _run.started_at).total_seconds(), 1)
54
+ return {
55
+ "state": _run.state,
56
+ "step": _run.step,
57
+ "started_at": _run.started_at.isoformat(timespec="seconds")
58
+ if _run.started_at else None,
59
+ "finished_at": _run.finished_at.isoformat(timespec="seconds")
60
+ if _run.finished_at else None,
61
+ "elapsed_s": elapsed,
62
+ "error": _run.error,
63
+ "counts": dict(_run.counts),
64
+ }
65
+
66
+
67
+ def is_running() -> bool:
68
+ with _lock:
69
+ return _run.state == "running"
70
+
71
+
72
+ def cancel() -> bool:
73
+ """Ask an in-flight run to discard its results. Returns True when a
74
+ run was actually in flight.
75
+
76
+ The solver runs under its own ``max_seconds`` ceiling, so the thread
77
+ finishes on its own shortly; marking it cancelled means whatever it
78
+ produces is thrown away rather than written over the state the
79
+ operator just reset.
80
+ """
81
+ with _lock:
82
+ if _run.state != "running":
83
+ return False
84
+ _run.cancelled = True
85
+ return True
86
+
87
+
88
+ def trigger(state: AppState, d_day: date_t, sub_command: str) -> bool:
89
+ """Start a run on a background thread. Returns False when one is
90
+ already in flight."""
91
+ with _lock:
92
+ if _run.state == "running":
93
+ return False
94
+ _run.state = "running"
95
+ _run.step = sub_command
96
+ _run.started_at = datetime.now()
97
+ _run.finished_at = None
98
+ _run.error = ""
99
+ _run.counts = {}
100
+ _run.cancelled = False
101
+
102
+ thread = threading.Thread(
103
+ target=_worker, args=(state, d_day, sub_command),
104
+ name=f"flight-alloc-{sub_command}", daemon=True,
105
+ )
106
+ thread.start()
107
+ return True
108
+
109
+
110
+ def _worker(state: AppState, d_day: date_t, sub_command: str) -> None:
111
+ counts: dict[str, int] = {}
112
+ error = ""
113
+ try:
114
+ counts = _dispatch(state, d_day, sub_command)
115
+ except FileNotFoundError as exc:
116
+ # A missing upload is an operator problem, not a crash — say so
117
+ # plainly rather than dumping a traceback into the UI.
118
+ error = str(exc)
119
+ print(f"[run] {sub_command} blocked: {error}")
120
+ except Exception as exc: # noqa: BLE001
121
+ error = f"{type(exc).__name__}: {exc}"
122
+ traceback.print_exc()
123
+
124
+ with _lock:
125
+ cancelled = _run.cancelled
126
+ _run.finished_at = datetime.now()
127
+ _run.error = "" if cancelled else error
128
+ _run.counts = {} if cancelled else counts
129
+ _run.state = "ok" if (cancelled or not error) else "failed"
130
+ _run.cancelled = False
131
+
132
+ if cancelled:
133
+ # Results are discarded; the state the operator reset stands.
134
+ state.reset_results()
135
+ print(f"[run] {sub_command} finished but was cancelled — results dropped")
136
+
137
+
138
+ def _dispatch(state: AppState, d_day: date_t, sub_command: str) -> dict[str, int]:
139
+ """Run one stage and return its counts."""
140
+ from ..plan import run as run_plan
141
+ from ..step1_clean_flights import run as run_step1
142
+ from ..step2_extract_roster import run as run_step2
143
+ from ..step3_allocate_flights import run as run_allocate
144
+
145
+ if sub_command == "plan":
146
+ run_plan(state, d_day, CONFIG_PATH)
147
+ return {"FLIGHTS": len(state.allocatable_cleaned()),
148
+ "STAFF": len(state.availability)}
149
+
150
+ if sub_command == "step1":
151
+ return {k.value: v for k, v in run_step1(state, d_day, CONFIG_PATH).items()}
152
+
153
+ if sub_command == "step2":
154
+ return run_step2(state, d_day, CONFIG_PATH)
155
+
156
+ if sub_command in ("step3", "allocate"):
157
+ return run_allocate(state, d_day, CONFIG_PATH, mode_label="Allocate")
158
+
159
+ if sub_command == "all":
160
+ # Plan first so cleaned flights + availability are rebuilt from
161
+ # the uploads, then solve on top of them.
162
+ run_plan(state, d_day, CONFIG_PATH)
163
+ return run_allocate(
164
+ state, d_day, CONFIG_PATH,
165
+ append_warnings=True, mode_label="Allocate",
166
+ )
167
+
168
+ raise ValueError(f"unknown run step: {sub_command!r}")
src/web/server.py ADDED
@@ -0,0 +1,185 @@
1
+ """The request loop and the launcher.
2
+
3
+ Everything about *what* an endpoint does lives in ``api``; this module
4
+ only knows how to get bytes off the socket, hand them to the router,
5
+ and put the Response back on the wire. Adding an endpoint never touches
6
+ this file.
7
+
8
+ Served from stdlib http.server — no FastAPI / Flask / external deps, so
9
+ it fits IT allow-list constraints and ships in the same PyInstaller
10
+ bundle as the engine.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import logging
16
+ import sys
17
+ import threading
18
+ import webbrowser
19
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
20
+ from typing import Any
21
+
22
+ from .api import build_router
23
+ from .core.responses import Response, bad_request
24
+ from .core.router import Router
25
+
26
+ DEFAULT_HOST = "127.0.0.1"
27
+ DEFAULT_PORT = 8765
28
+
29
+ # Guard against an accidental multi-hundred-MB POST filling memory.
30
+ MAX_UPLOAD_BYTES = 64 * 1024 * 1024
31
+
32
+ logger = logging.getLogger("src.web")
33
+
34
+ #: Built once at import; every request thread reads from it.
35
+ ROUTER: Router = build_router()
36
+
37
+
38
+ class Handler(BaseHTTPRequestHandler):
39
+ """One instance per request. Holds no state of its own — the shared
40
+ state is the module-level ``STATE`` singleton the handlers reach."""
41
+
42
+ server_version = "FlightAllocWeb/2.0"
43
+
44
+ # HTTP/1.1 keep-alive. The default HTTP/1.0 + Connection: close
45
+ # means a new TCP connection per request, and on Windows the
46
+ # loopback handshake (plus Defender's inspection of it) is slow
47
+ # enough that 5-6 cold connections on page load are noticeable.
48
+ protocol_version = "HTTP/1.1"
49
+
50
+ def log_message(self, format: str, *args: Any) -> None: # noqa: A002
51
+ # DEBUG, not INFO: the console handler is set to INFO so the
52
+ # operator sees what happened ([upload], [reset], ...) without a
53
+ # line per request. On Windows every console write goes through
54
+ # the console driver, and one per request bottlenecks the loop.
55
+ logger.debug("%s - %s", self.client_address[0], format % args)
56
+
57
+ def address_string(self) -> str:
58
+ # The default can do a reverse DNS lookup on the client IP,
59
+ # which on some Windows setups adds 30s+ per request.
60
+ return self.client_address[0]
61
+
62
+ # ------------ the four verbs, all the same shape ------------
63
+
64
+ def do_GET(self) -> None: # noqa: N802
65
+ self._handle("GET")
66
+
67
+ def do_POST(self) -> None: # noqa: N802
68
+ self._handle("POST")
69
+
70
+ def do_PUT(self) -> None: # noqa: N802
71
+ self._handle("PUT")
72
+
73
+ def do_DELETE(self) -> None: # noqa: N802
74
+ self._handle("DELETE")
75
+
76
+ # ------------ plumbing ------------
77
+
78
+ def _handle(self, method: str) -> None:
79
+ try:
80
+ body = self._read_body()
81
+ except ValueError as exc:
82
+ self._send(bad_request(str(exc)))
83
+ return
84
+ self._send(ROUTER.dispatch(method, self.path, self.headers, body))
85
+
86
+ def _read_body(self) -> bytes:
87
+ length = int(self.headers.get("Content-Length", "0") or "0")
88
+ if length <= 0:
89
+ return b""
90
+ if length > MAX_UPLOAD_BYTES:
91
+ raise ValueError(
92
+ f"payload too large ({length / 1e6:.0f} MB); "
93
+ f"limit is {MAX_UPLOAD_BYTES / 1e6:.0f} MB"
94
+ )
95
+ return self.rfile.read(length)
96
+
97
+ def _send(self, response: Response) -> None:
98
+ try:
99
+ self.send_response(response.status)
100
+ self.send_header("Content-Type", response.content_type)
101
+ self.send_header("Content-Length", str(len(response.body)))
102
+ self.send_header("Cache-Control", "no-store")
103
+ for key, value in response.headers.items():
104
+ self.send_header(key, value)
105
+ self.end_headers()
106
+ self.wfile.write(response.body)
107
+ except (ConnectionAbortedError, ConnectionResetError, BrokenPipeError):
108
+ # Browser bailed mid-response — common when the operator
109
+ # navigates or hits Reset while a poll is in flight. Don't
110
+ # flood stderr with the stack trace.
111
+ pass
112
+
113
+
114
+ class WebServer(ThreadingHTTPServer):
115
+ """Threading so one slow request (a Reset, an in-flight poll) doesn't
116
+ block the others — a single page load fires 5-6 API calls in
117
+ parallel. ``daemon_threads`` so a hung handler doesn't keep the
118
+ process alive after Ctrl+C."""
119
+
120
+ daemon_threads = True
121
+
122
+
123
+ def configure_logging(level: int = logging.INFO) -> None:
124
+ """Send this package's log to stdout.
125
+
126
+ The operational lines ([upload], [reset], [staged-form], ...) are the
127
+ only feedback an operator gets in the launcher window, so they need a
128
+ handler. Per-request access logs sit at DEBUG and stay off.
129
+ """
130
+ if logger.handlers:
131
+ return
132
+ handler = logging.StreamHandler(sys.stdout)
133
+ handler.setFormatter(logging.Formatter("%(message)s"))
134
+ logger.addHandler(handler)
135
+ logger.setLevel(level)
136
+ logger.propagate = False
137
+
138
+
139
+ def serve(
140
+ host: str = DEFAULT_HOST,
141
+ port: int = DEFAULT_PORT,
142
+ *,
143
+ open_browser: bool = True,
144
+ ) -> None:
145
+ """Run the server in the foreground. Ctrl+C exits cleanly."""
146
+ configure_logging()
147
+ from ..state import STATE
148
+ restored = STATE.load_persisted_rosters()
149
+ if restored:
150
+ logger.info("[rosters] restored %d saved roster file(s)", restored)
151
+ server = WebServer((host, port), Handler)
152
+ url = f"http://{host}:{port}/"
153
+ print(f"Flight Allocation console - {url}")
154
+ print(" Upload today's flight schedule from the dashboard;")
155
+ print(" the two roster files live in the Setup sidebar and are")
156
+ print(" remembered between runs (saved under data/rosters/).")
157
+ print(" Ctrl+C to stop.")
158
+ logger.debug("routes: %s", ", ".join(ROUTER.routes))
159
+
160
+ if open_browser:
161
+ threading.Timer(0.6, lambda: webbrowser.open(url)).start()
162
+
163
+ try:
164
+ server.serve_forever()
165
+ except KeyboardInterrupt:
166
+ print("\nstopping...")
167
+ finally:
168
+ server.server_close()
169
+
170
+
171
+ def main(argv: list[str] | None = None) -> int:
172
+ import argparse
173
+
174
+ p = argparse.ArgumentParser(prog="flight-alloc-web")
175
+ p.add_argument("--host", default=DEFAULT_HOST)
176
+ p.add_argument("--port", type=int, default=DEFAULT_PORT)
177
+ p.add_argument("--no-browser", action="store_true",
178
+ help="do not auto-open the browser")
179
+ args = p.parse_args(argv)
180
+ serve(host=args.host, port=args.port, open_browser=not args.no_browser)
181
+ return 0
182
+
183
+
184
+ if __name__ == "__main__":
185
+ sys.exit(main())
src/zc_store.py ADDED
@@ -0,0 +1,221 @@
1
+ """On-disk Zone Controller list, one list per operating date.
2
+
3
+ The rosters no longer carry ``/ZC`` in their cells; the assigner picks
4
+ the Zone Controllers on the dashboard instead. That choice has to
5
+ outlive a restart and has to be *updatable day to day* rather than
6
+ re-entered from scratch every morning, so it is kept here.
7
+
8
+ Rules
9
+ -----
10
+ * Each date has its own list. Editing a date never touches another one.
11
+ * A date with no list of its own **inherits** the list of the most
12
+ recent earlier date ("carried over"). The assigner opens the day,
13
+ sees yesterday's ZCs pre-filled, and only adds / removes what changed.
14
+ * The list a day is actually planned with is frozen onto that day (see
15
+ ``freeze``), so later edits to an earlier date cannot rewrite history.
16
+ * A date whose list was deliberately emptied stays empty — it does not
17
+ fall back to an older date.
18
+ * Removing someone also records them as *removed* for that date only.
19
+ That is what lets the assigner take a ZC off who was marked ``/ZC`` in
20
+ the roster file: they are not in the saved list, so without the record
21
+ there would be nothing to say "not a ZC today". Removals never carry
22
+ over, because a roster ``/ZC`` is a fact about one specific day.
23
+
24
+ Layout::
25
+
26
+ data/zc_lists.json
27
+ {"2026-09-19": {"names": ["NAME ONE", "NAME TWO"],
28
+ "removed": ["NAME THREE"]}, ...}
29
+
30
+ (An older file that stores a bare list per date is still read.)
31
+
32
+ Names are stored as displayed and matched case- and whitespace-
33
+ insensitively, the same way override rows match names.
34
+
35
+ Persistence is best-effort, like ``roster_store``: if the folder is
36
+ read-only the lists keep working from memory and a warning is logged.
37
+ Override the location with ``FLIGHT_ALLOC_DATA_DIR``.
38
+ """
39
+
40
+ from __future__ import annotations
41
+
42
+ import json
43
+ import logging
44
+ import os
45
+ import re
46
+ import tempfile
47
+ import threading
48
+ from datetime import date as date_t
49
+ from pathlib import Path
50
+
51
+ from .roster_store import data_dir
52
+
53
+ logger = logging.getLogger("src.web")
54
+
55
+ _lock = threading.RLock()
56
+ _cache: dict[str, dict[str, list[str]]] | None = None
57
+ _cache_path: Path | None = None
58
+
59
+
60
+ def _path() -> Path:
61
+ return data_dir() / "zc_lists.json"
62
+
63
+
64
+ def _norm(name: str) -> str:
65
+ return re.sub(r"\s+", " ", str(name or "")).strip().upper()
66
+
67
+
68
+ def _clean(names: list[str]) -> list[str]:
69
+ """Trim, drop blanks, drop duplicates (case-insensitive), keep order."""
70
+ seen: set[str] = set()
71
+ out: list[str] = []
72
+ for n in names:
73
+ display = re.sub(r"\s+", " ", str(n or "")).strip()
74
+ key = _norm(display)
75
+ if not key or key in seen:
76
+ continue
77
+ seen.add(key)
78
+ out.append(display)
79
+ return out
80
+
81
+
82
+ def _load() -> dict[str, dict[str, list[str]]]:
83
+ """The whole store, read from disk once per path (and again if
84
+ ``FLIGHT_ALLOC_DATA_DIR`` changes)."""
85
+ global _cache, _cache_path
86
+ path = _path()
87
+ with _lock:
88
+ if _cache is not None and _cache_path == path:
89
+ return _cache
90
+ data: dict[str, dict[str, list[str]]] = {}
91
+ if path.exists():
92
+ try:
93
+ raw = json.loads(path.read_text(encoding="utf-8"))
94
+ except (OSError, json.JSONDecodeError) as exc:
95
+ logger.warning("[zc] cannot read %s: %s", path, exc)
96
+ raw = {}
97
+ if isinstance(raw, dict):
98
+ for k, v in raw.items():
99
+ try:
100
+ date_t.fromisoformat(str(k))
101
+ except ValueError:
102
+ continue
103
+ if isinstance(v, list): # older format
104
+ data[str(k)] = {"names": _clean([str(x) for x in v]), "removed": []}
105
+ elif isinstance(v, dict):
106
+ data[str(k)] = {
107
+ "names": _clean([str(x) for x in v.get("names", [])]),
108
+ "removed": _clean([str(x) for x in v.get("removed", [])]),
109
+ }
110
+ _cache, _cache_path = data, path
111
+ return data
112
+
113
+
114
+ def _write(data: dict[str, dict[str, list[str]]]) -> None:
115
+ """Atomic replace, so a crash mid-write can't leave a torn file."""
116
+ path = _path()
117
+ try:
118
+ path.parent.mkdir(parents=True, exist_ok=True)
119
+ fd, tmp = tempfile.mkstemp(dir=path.parent, suffix=".tmp")
120
+ try:
121
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
122
+ json.dump(dict(sorted(data.items())), fh, indent=2)
123
+ os.replace(tmp, path)
124
+ except OSError:
125
+ try:
126
+ os.unlink(tmp)
127
+ except OSError:
128
+ pass
129
+ raise
130
+ except OSError as exc:
131
+ logger.warning(
132
+ "[zc] could not persist the ZC list (%s) — it stays in memory only",
133
+ exc,
134
+ )
135
+
136
+
137
+ def effective(d: date_t) -> tuple[list[str], str, date_t | None]:
138
+ """The list to use for ``d``.
139
+
140
+ Returns ``(names, source, carried_from)`` where ``source`` is
141
+ ``"saved"`` (the date has its own list), ``"carried"`` (inherited
142
+ from ``carried_from``) or ``"none"`` (nothing saved on or before
143
+ ``d``).
144
+ """
145
+ with _lock:
146
+ data = _load()
147
+ own = data.get(d.isoformat())
148
+ if own is not None:
149
+ return list(own["names"]), "saved", None
150
+ earlier = [k for k in data if k < d.isoformat()]
151
+ if earlier:
152
+ latest = max(earlier)
153
+ return list(data[latest]["names"]), "carried", date_t.fromisoformat(latest)
154
+ return [], "none", None
155
+
156
+
157
+ def removed(d: date_t) -> list[str]:
158
+ """People explicitly taken off ZC on ``d`` (this date only, never
159
+ carried over)."""
160
+ with _lock:
161
+ own = _load().get(d.isoformat())
162
+ return list(own["removed"]) if own else []
163
+
164
+
165
+ def set_list(
166
+ d: date_t, names: list[str], *, removed_names: list[str] | None = None,
167
+ ) -> list[str]:
168
+ """Save ``names`` as the list for ``d`` (an empty list is a real,
169
+ deliberate answer and is kept). ``removed_names`` replaces the day's
170
+ removal record; left as ``None`` the existing one is kept, minus
171
+ anyone who is being put on the list."""
172
+ with _lock:
173
+ data = _load()
174
+ cleaned = _clean(names)
175
+ on_list = {_norm(n) for n in cleaned}
176
+ base = data.get(d.isoformat(), {}).get("removed", []) if removed_names is None else removed_names
177
+ data[d.isoformat()] = {
178
+ "names": cleaned,
179
+ "removed": [n for n in _clean(base) if _norm(n) not in on_list],
180
+ }
181
+ _write(data)
182
+ return list(cleaned)
183
+
184
+
185
+ def add(d: date_t, name: str) -> list[str]:
186
+ """Add one person to ``d``'s list, starting from what ``d`` already
187
+ resolves to (so a carried-over list is seeded, then saved)."""
188
+ with _lock:
189
+ names, _, _ = effective(d)
190
+ return set_list(d, names + [name])
191
+
192
+
193
+ def remove(d: date_t, name: str) -> list[str]:
194
+ """Take one person off ``d``'s list, and record them as removed for
195
+ that date so a roster ``/ZC`` for them is overridden too."""
196
+ with _lock:
197
+ names, _, _ = effective(d)
198
+ key = _norm(name)
199
+ return set_list(
200
+ d, [n for n in names if _norm(n) != key],
201
+ removed_names=removed(d) + [name],
202
+ )
203
+
204
+
205
+ def freeze(d: date_t) -> bool:
206
+ """Pin an inherited list onto ``d`` itself. Called when the list is
207
+ actually applied to a plan, so tomorrow's carry-over starts from
208
+ what today really ran with. Returns True when something was written."""
209
+ with _lock:
210
+ names, source, _ = effective(d)
211
+ if source != "carried":
212
+ return False
213
+ set_list(d, names)
214
+ return True
215
+
216
+
217
+ def _reset_for_tests() -> None:
218
+ """Drop the in-memory cache (tests point the store at a temp dir)."""
219
+ global _cache, _cache_path
220
+ with _lock:
221
+ _cache, _cache_path = None, None