From 20498aa4ce12619b7f2bfdf19e276e3e31d35370 Mon Sep 17 00:00:00 2001 From: Justin Ko Date: Thu, 4 Jun 2026 00:16:07 +0900 Subject: [PATCH] Deliver BAT-001: bootstrap trex-core + port slot pure domain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement REQ-001~004 (dcy-001): scaffold the trex-core package (pyproject, trex_core/, tests/), port the slot pure domain into trex_core.domain.slot with import rewrites, add the EXPECTED_SLOT_FIELDS contract guard (test_contract), and port the state/service unit tests. Verification: 56 passed, ruff clean, import sweep shows zero external deps, public __all__ exposes no infra symbols, contract negative case reproduced. Align project-structure/runtime-flows baselines to the real tree. Sync SDLC tracking: REQ-001~004 → Implemented, BAT-001 → release-candidate. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 6 +- .../design.md | 30 ++ .../implementation.md | 35 ++ .../index.md | 32 ++ .../planning.md | 42 ++ .../verification.md | 46 +++ docs/batches/index.md | 1 + ...001_20260603_slot-pure-domain-migration.md | 4 +- docs/project-structure.md | 41 +- docs/runtime-flows.md | 1 + .../req-004_baseline-doc-alignment.md | 152 +++++++ .../req-001_trex-core-bootstrap.md | 154 +++++++ .../req-002_slot-pure-domain-port.md | 154 +++++++ ...03_slot-domain-tests-and-contract-guard.md | 154 +++++++ docs/srs/index.md | 10 +- pyproject.toml | 27 ++ tests/__init__.py | 0 tests/domain/__init__.py | 0 tests/domain/slot/__init__.py | 0 tests/domain/slot/test_contract.py | 36 ++ tests/domain/slot/test_service.py | 375 ++++++++++++++++++ tests/domain/slot/test_state.py | 96 +++++ trex_core/__init__.py | 1 + trex_core/domain/__init__.py | 1 + trex_core/domain/slot/__init__.py | 54 +++ trex_core/domain/slot/errors.py | 23 ++ trex_core/domain/slot/in_memory_repository.py | 113 ++++++ trex_core/domain/slot/models.py | 84 ++++ trex_core/domain/slot/repository.py | 79 ++++ trex_core/domain/slot/service.py | 250 ++++++++++++ trex_core/domain/slot/state.py | 64 +++ 31 files changed, 2044 insertions(+), 21 deletions(-) create mode 100644 docs/batches/bat-001_20260603_trex-core-slot-domain-seed/design.md create mode 100644 docs/batches/bat-001_20260603_trex-core-slot-domain-seed/implementation.md create mode 100644 docs/batches/bat-001_20260603_trex-core-slot-domain-seed/index.md create mode 100644 docs/batches/bat-001_20260603_trex-core-slot-domain-seed/planning.md create mode 100644 docs/batches/bat-001_20260603_trex-core-slot-domain-seed/verification.md create mode 100644 docs/srs/Documentation/req-004_baseline-doc-alignment.md create mode 100644 docs/srs/Installation/req-001_trex-core-bootstrap.md create mode 100644 docs/srs/Interface/req-002_slot-pure-domain-port.md create mode 100644 docs/srs/Testing/req-003_slot-domain-tests-and-contract-guard.md create mode 100644 pyproject.toml create mode 100644 tests/__init__.py create mode 100644 tests/domain/__init__.py create mode 100644 tests/domain/slot/__init__.py create mode 100644 tests/domain/slot/test_contract.py create mode 100644 tests/domain/slot/test_service.py create mode 100644 tests/domain/slot/test_state.py create mode 100644 trex_core/__init__.py create mode 100644 trex_core/domain/__init__.py create mode 100644 trex_core/domain/slot/__init__.py create mode 100644 trex_core/domain/slot/errors.py create mode 100644 trex_core/domain/slot/in_memory_repository.py create mode 100644 trex_core/domain/slot/models.py create mode 100644 trex_core/domain/slot/repository.py create mode 100644 trex_core/domain/slot/service.py create mode 100644 trex_core/domain/slot/state.py diff --git a/.gitignore b/.gitignore index a47f7e0..61709d2 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,5 @@ -/temp/ \ No newline at end of file +/temp/ +/.venv/ +__pycache__/ +*.egg-info/ +.pytest_cache/ diff --git a/docs/batches/bat-001_20260603_trex-core-slot-domain-seed/design.md b/docs/batches/bat-001_20260603_trex-core-slot-domain-seed/design.md new file mode 100644 index 0000000..e674ea8 --- /dev/null +++ b/docs/batches/bat-001_20260603_trex-core-slot-domain-seed/design.md @@ -0,0 +1,30 @@ +# BAT-001 Design + +- Status: confirmed +- Batch: BAT-001 + +## Architecture Summary +- trex-core 를 `trex_core` import 패키지(배포명 `trex-core`)로 부트스트랩하고, 첫 도메인으로 slot 순수 도메인을 `trex_core/domain/slot/` 에 둔다. 도메인은 저장소를 `SlotRepository` Protocol 로 추상화하고 구현체(InMemorySlotRepository)를 주입받는 storage-agnostic 구조다. 인프라(향후 PostgreSQL/CLI/config)는 도메인을 의존할 수 있으나 그 역은 금지한다. + +## Changed Areas +- Docs: docs/project-structure.md, docs/runtime-flows.md (REQ-004 정합), docs/data-model.md·docs/interface-contract.md (계약 기준 참조) +- Modules: pyproject.toml, trex_core/__init__.py, trex_core/domain/__init__.py, trex_core/domain/slot/{__init__,state,models,errors,repository,service,in_memory_repository}.py +- Interfaces: 공개 API = SlotState, Slot, SlotCapacity, SlotTransitionRecord, SlotRepository, SlotService, 예외 4종, is_allowed_transition, SLOT_ADVISORY_LOCK_ID, ALLOWED_TRANSITIONS + +## Key Decisions +1. 네임스페이스 `trex_core.domain.slot`(dcy-001 OQ-1). 모듈 간 import 를 모두 `trex_core.domain.slot.*` 로 통일하고 `app.domain.slot` 잔존을 0으로 만든다. +2. 컬럼 계약 박제 = 계약 테스트(dcy-001 OQ-2). 도메인에 `EXPECTED_SLOT_FIELDS` 표준 필드 집합을 선언하고 `test_contract.py` 가 `{f.name for f in dataclasses.fields(Slot)}` 와의 일치를 단언. 타입 단언은 하지 않고 타입 매핑은 문서(부록 A/data-model)에 둔다. +3. 공개 API 는 인프라 심볼(`PostgreSQLSlotRepository`, `check_slot_domain_schema_drift`)을 노출하지 않는다 — 애초 `__init__` 이 이를 export 하지 않으므로 docstring 정리만 수행. +4. `SLOT_ADVISORY_LOCK_ID` 는 순수 도메인에 잔류(현행 유지). 영속화 계층 신설 시 재배치 여부는 후속 반복(dcy-001 OQ-3 인접) 판단. + +## Edge Cases +- `EXPECTED_SLOT_FIELDS` 와 `Slot` 필드 불일치 시 계약 테스트가 실패해야 한다(음성 케이스 의도적 검증). +- `closed→idle`/`pending_buy→idle` 복구 시 점유 필드 초기화 + 재진입 카운트 +1 불변식 보존 확인(이식 테스트 범위). +- in-memory repo 의 threading.Lock 이 advisory lock 의미를 모사하므로, 동시성 관련 테스트도 DB 없이 성립. + +## Architecture Impact +- both (project-structure + runtime-flows) + +## Reference Doc Update Plan +- project-structure.md: planned 트리·모듈 책임의 `(planned)` 표기를 실제 `trex_core/` 구조로 갱신(REQ-004). 네임스페이스는 이미 `trex_core/` 로 정정 완료, 잔여는 실재 반영. +- runtime-flows.md: in-memory 기반 도메인 흐름(preallocate/transit)이 실제 코드와 일치하는지 확인하고 "미구현/planned" 성격 표기를 정정(REQ-004). diff --git a/docs/batches/bat-001_20260603_trex-core-slot-domain-seed/implementation.md b/docs/batches/bat-001_20260603_trex-core-slot-domain-seed/implementation.md new file mode 100644 index 0000000..71d10f9 --- /dev/null +++ b/docs/batches/bat-001_20260603_trex-core-slot-domain-seed/implementation.md @@ -0,0 +1,35 @@ +# BAT-001 Implementation + +- Status: completed +- Batch: BAT-001 + +## Plan Summary +- REQ-001 부트스트랩 → REQ-002 slot 순수 도메인 이식 → REQ-003 테스트·계약 가드 → REQ-004 baseline 정합을 순차 구현했다. trex-orbit `app/domain/slot/` 의 순수 6개 모듈 + `__init__` 을 `trex_core.domain.slot` 로 복사하고 import 를 전면 치환, 테스트 2종 이식 + 계약 테스트 신설. + +## Changed Files +- 신규(REQ-001): `pyproject.toml`, `trex_core/__init__.py`, `trex_core/domain/__init__.py`, `tests/__init__.py`, `tests/domain/__init__.py`, `tests/domain/slot/__init__.py` +- 신규(REQ-002): `trex_core/domain/slot/{__init__,state,models,errors,repository,service,in_memory_repository}.py` + - import 경로 `app.domain.slot.*` → `trex_core.domain.slot.*` 전면 치환, `__init__` docstring 순수 도메인 기준 정리(인프라 export 없음 확인) +- 신규(REQ-003): `tests/domain/slot/{test_state,test_service,test_contract}.py`, `models.py` 에 `EXPECTED_SLOT_FIELDS` 선언 +- 갱신(REQ-004): `docs/project-structure.md`, `docs/runtime-flows.md` planned→실제 정합 + +## Execution Log +1. `pyproject.toml`(name=trex-core, requires-python>=3.12, setuptools, dev extras=pytest·ruff) + 패키지 레이아웃 생성. +2. 순수 6개 모듈 + `__init__` 이식, import 치환, `EXPECTED_SLOT_FIELDS` 추가. +3. `test_state.py`·`test_service.py` 이식(미사용 import 정리, `t1.start()/join()` 멀티스테이트먼트 분리), `test_contract.py` 신설. +4. `.venv` 생성 후 `pip install -e .[dev]` (PEP 668 외부관리 환경이라 venv 사용). +5. `pytest`, `ruff check .`, 공개 API import, import 스윕, 계약 음성 케이스 실행. +6. `docs/project-structure.md`·`docs/runtime-flows.md` 정합(REQ-004). + +## Validation +- `pytest -q`: **56 passed** (test_state 18 + test_service 35 + test_contract 3, 근사). +- `ruff check .`: **All checks passed**. +- 공개 API import: 15개 심볼 전부 import 성공(`SlotState`...`EXPECTED_SLOT_FIELDS`). +- import 스윕: `trex_core/domain/slot/*.py` 에 stdlib + `trex_core.domain.slot` 외 외부 의존 **0**. +- 공개 `__all__`: `PostgreSQL`/`schema` 인프라 심볼 노출 **0**. +- 계약 음성 케이스: `Slot` 에 rogue 필드 추가 시 계약 일치 단언이 False 로 떨어짐(실패 재현 확인). + +## Remaining Risks +- R-1 영속화 부재(in-memory 한정) — 후속 Discovery 인계. +- R-2 DB-side 컬럼 드리프트 검사 미도입 — 영속화 REQ 인계(계약 테스트는 코드 측 가드 확보). +- 환경: 시스템 Python 3.14.4 에서 venv 로 검증(requires-python>=3.12 충족). CI 표준 인터프리터 고정은 후속. diff --git a/docs/batches/bat-001_20260603_trex-core-slot-domain-seed/index.md b/docs/batches/bat-001_20260603_trex-core-slot-domain-seed/index.md new file mode 100644 index 0000000..df0e3cc --- /dev/null +++ b/docs/batches/bat-001_20260603_trex-core-slot-domain-seed/index.md @@ -0,0 +1,32 @@ +# BAT-001: trex-core 베이스 부트스트랩 + slot 순수 도메인 시드 + +- Status: release-candidate +- Profile: standard +- Source Discovery: dcy-001 +- Included REQ: REQ-001, REQ-002, REQ-003, REQ-004 +- Excluded REQ: 없음 + +## Summary +- dcy-001 의 단일 가치(trex-core 베이스 수립 + slot 순수 도메인 시드)를 하나의 delivery 단위로 묶는다. 부트스트랩(REQ-001) → 도메인 이식(REQ-002) → 테스트·계약 가드(REQ-003) → baseline 정합(REQ-004)이 같은 iteration·같은 모듈 경로(`trex_core/domain/slot`)·같은 검증 흐름(`pytest`)을 공유한다. + +## Scope +- In Scope: + - REQ-001: `pyproject.toml`(배포명 `trex-core`) + `trex_core/`·`trex_core/domain/` 레이아웃 + pytest/ruff 설정. + - REQ-002: 순수 6개 모듈 + `__init__` 을 `trex_core.domain.slot` 로 이식, 공개 API 인프라 심볼 없이 노출. + - REQ-003: 단위테스트(`test_state`/`test_service`) 이식 green + 계약 가드(`EXPECTED_SLOT_FIELDS` + `test_contract`). + - REQ-004: 이식 후 `docs/project-structure.md`·`docs/runtime-flows.md` planned→실제 정합. +- Out of Scope: + - PostgreSQL 구현체/ SQL DDL/ `db init`/ schema drift 감지. + - `trex slot` CLI, config 해석, coordinator 연동, archive/prune. + - trex-orbit 수정 및 trex-orbit↔trex-core 코드 수렴. + +## Documents +- Planning: [planning.md](./planning.md) +- Design: [design.md](./design.md) +- Implementation: [implementation.md](./implementation.md) +- Verification: [verification.md](./verification.md) + +## Notes +- Source Discovery 는 dcy-001 단일. 모든 REQ 가 dcy-001 FR-1~FR-5 에서 분해됨. +- 공통 리스크: 영속화 부재(in-memory 한정, dcy-001 R-1), 컬럼 계약 드리프트(REQ-003 계약 테스트로 완화, R-2), 이중 진실(병행, R-3). +- 네임스페이스 `trex_core.domain.slot`(dcy-001 OQ-1), 컬럼 계약 박제 = 계약 테스트(OQ-2). DB-side 드리프트 검사는 영속화 반복 인계. diff --git a/docs/batches/bat-001_20260603_trex-core-slot-domain-seed/planning.md b/docs/batches/bat-001_20260603_trex-core-slot-domain-seed/planning.md new file mode 100644 index 0000000..86b2427 --- /dev/null +++ b/docs/batches/bat-001_20260603_trex-core-slot-domain-seed/planning.md @@ -0,0 +1,42 @@ +# BAT-001 Planning + +- Status: confirmed +- Batch: BAT-001 +- Profile: standard + +## Included REQ +- REQ-001 (Installation): trex-core Python 프로젝트 부트스트랩 +- REQ-002 (Interface): slot 순수 도메인 이식 및 공개 API 확정 +- REQ-003 (Testing): slot 도메인 단위테스트 이식 및 컬럼 계약 가드 +- REQ-004 (Documentation): baseline 문서 planned→실제 정합 + +## Delivery Plan +- 선형 의존을 따라 순차 구현한다. + 1. REQ-001: `pyproject.toml`(name=`trex-core`, requires-python>=3.12, setuptools, dev extras=pytest·ruff) + `trex_core/__init__.py`, `trex_core/domain/__init__.py` + `tests/` 스캐폴딩 + ruff/pytest 설정. + 2. REQ-002: trex-orbit `app/domain/slot/` 의 순수 6개 모듈 + `__init__` 을 `trex_core/domain/slot/` 로 복사하고 모듈 간 import 를 `trex_core.domain.slot.*` 로 치환, `__init__` docstring 정리(인프라 export 없음 확인). + 3. REQ-003: `tests/domain/slot/test_state.py`·`test_service.py` 이식, `trex_core/domain/slot` 에 `EXPECTED_SLOT_FIELDS` 선언, `test_contract.py` 신설(`fields(Slot)` 일치 단언). + 4. REQ-004: 위 산출물 확정 후 `docs/project-structure.md`·`docs/runtime-flows.md` 의 planned 표기를 실제 구조로 정합. + +## Dependencies +- Internal: + - REQ-002 는 REQ-001(패키지 레이아웃) 선행 필요. + - REQ-003 은 REQ-002(도메인 코드) 선행 필요. + - REQ-004 는 REQ-002(실제 구조 확정) 선행 필요. +- External: + - 없음(외부 서비스·DB 미사용). 개발 의존성: pytest, ruff(dev extras). + +## Design Gate +- Design Required Before Implementation: yes +- Reason: 신규 패키지 네임스페이스(`trex_core`) 도입 + 공개 API 표면 확정 + baseline 구조/런타임 문서 정합이 함께 걸린다(구조·인터페이스 영향). planning 만으로는 import 경계·공개 API·계약 가드 위치 결정이 드리프트할 수 있어 design.md 를 같은 턴에 작성한다. + +## Milestones +1. M1 — REQ-001: 부트스트랩 완료(`pip install -e .[dev]` + `pytest` 수집 성공 + `ruff check` 동작). +2. M2 — REQ-002: 도메인 이식 + 공개 API import 성공 + import 스윕 외부 의존 0. +3. M3 — REQ-003: 이식 테스트 2종 green + 계약 테스트 green(필드 변경 시 실패 재현). +4. M4 — REQ-004: baseline 문서 정합(`app/` 잔존 0, 트리/흐름 실제 일치). + +## Risks +- R-1 (Medium): 영속화 부재 → in-memory repo 한정 구동. 대응: 이번 batch 목표를 "도메인 코어 시드 + 테스트 green"으로 한정, 영속화는 후속 Discovery. +- R-2 (Medium): 컬럼 계약 드리프트. 대응: REQ-003 계약 테스트(`EXPECTED_SLOT_FIELDS`)로 자동 강제, DB-side 검사 인계. +- R-3 (Low): trex-orbit·trex-core 이중 진실. 대응: 의도된 임시 비용으로 수용, 수렴은 별도 Discovery. +- R-4 (Low): import 경로 치환 누락(`app.domain.slot` 잔존). 대응: 이식 후 grep 으로 잔존 import 0 확인(REQ-002 AC). diff --git a/docs/batches/bat-001_20260603_trex-core-slot-domain-seed/verification.md b/docs/batches/bat-001_20260603_trex-core-slot-domain-seed/verification.md new file mode 100644 index 0000000..379875d --- /dev/null +++ b/docs/batches/bat-001_20260603_trex-core-slot-domain-seed/verification.md @@ -0,0 +1,46 @@ +# BAT-001 Verification + +- Status: confirmed +- Batch: BAT-001 + +## Acceptance Mapping +- REQ-001 (Installation): + - AC1 설치/import → `pip install -e .[dev]` 성공 + `import trex_core` OK (.venv, Python 3.14.4 ≥ 3.12). **PASS** + - AC2 pytest 수집 → `pytest -q` 56 passed (수집/실행 정상). **PASS** + - AC3 pyproject 메타 → name=`trex-core`, requires-python>=3.12, setuptools, dev extras=pytest·ruff 확인. **PASS** + - AC4 ruff 동작 → `ruff check .` All checks passed. **PASS** +- REQ-002 (Interface): + - AC1 공개 API import → 15개 심볼(SlotState…EXPECTED_SLOT_FIELDS) import 성공. **PASS** + - AC2 외부 의존 0 → import 스윕 결과 stdlib + `trex_core.domain.slot` 외 import 없음. **PASS** + - AC3 인프라 심볼 비노출 → `__all__` 에 PostgreSQL/schema 심볼 0. **PASS** + - AC4 도메인 불변식 보존 → test_state(전이 14종) + test_service(advisory lock 직렬화·closed→idle 재진입+1·race 감지) 통과. **PASS** +- REQ-003 (Testing): + - AC1 이식 테스트 green → test_state + test_service 통과(56 passed 에 포함). **PASS** + - AC2 계약 테스트 green → test_contract `fields(Slot)==EXPECTED_SLOT_FIELDS` 통과. **PASS** + - AC3 필드 변경 시 실패 재현 → rogue 필드 추가 시 계약 일치 False 확인(live negative case). **PASS** + - AC4 타입 단언 배제 → test_contract 는 이름/존재만 단언, 타입 매핑은 부록 A/data-model 문서. **PASS** +- REQ-004 (Documentation): + - AC1 project-structure 정합 → `(planned)` 제거, 실제 `trex_core/domain/slot` 트리·모듈 책임 반영. **PASS** + - AC2 runtime-flows 정합 → preallocate/transit 흐름이 실제 코드 기반임을 Change Log 반영, 영속화 미구현 표기 유지(정확). **PASS** + - AC3 네임스페이스 일관 → `docs/project-structure.md`·`docs/runtime-flows.md` 에 `app/` 잔존 0(Change Log 정정 이력 1건 제외). **PASS** + +## Evidence +- Test: + - `pytest -q` → `56 passed in 0.06s` (test_state / test_service / test_contract). + - 계약 음성 케이스: `dataclasses.make_dataclass` 로 rogue 필드 추가 → 계약 일치 단언 False. +- Manual: + - import 스윕 grep: `trex_core/domain/slot/*.py` 외부/인프라 import 0. + - `__all__` 점검: 인프라 심볼 0. + - baseline 문서 `app/` 잔존 grep: 정정 Change Log 1건만 매칭. +- Logs: + - run-batch-implementation 단계 실행 로그(implementation.md Validation 절) 참조. + +## Baseline Sync Evidence (Architecture Impact = both) +- docs/project-structure.md: planned 트리·모듈 책임을 실제 `trex_core/` 구조로 갱신 + Change Log 기록(REQ-004). +- docs/runtime-flows.md: 도메인 흐름이 실제 코드 기반임을 반영 + Change Log 기록(REQ-004). +- design.md `Reference Doc Update Plan` 의 두 항목 모두 반영 완료. + +## Result +- Ready for Release: true +- Blocking Issues: + - 없음 (모든 포함 REQ AC 가 evidence 와 연결됨. 잔여 리스크 R-1/R-2 는 후속 영속화 반복 인계 사항으로 비차단) diff --git a/docs/batches/index.md b/docs/batches/index.md index 158750c..0aa6c01 100644 --- a/docs/batches/index.md +++ b/docs/batches/index.md @@ -8,3 +8,4 @@ | BAT ID | Profile | Status | Included REQ | Discovery | Folder | | --- | --- | --- | --- | --- | --- | +| BAT-001 | standard | release-candidate | REQ-001, REQ-002, REQ-003, REQ-004 | dcy-001 | [bat-001](./bat-001_20260603_trex-core-slot-domain-seed/) | diff --git a/docs/discovery/dcy-001_20260603_slot-pure-domain-migration.md b/docs/discovery/dcy-001_20260603_slot-pure-domain-migration.md index 8ecf0ae..566914a 100644 --- a/docs/discovery/dcy-001_20260603_slot-pure-domain-migration.md +++ b/docs/discovery/dcy-001_20260603_slot-pure-domain-migration.md @@ -5,10 +5,10 @@ - 문서 ID: dcy-001_20260603_slot-pure-domain-migration - 이슈명: trex-core 베이스 수립 및 slot 순수 도메인 이식 - 작성 시각(KST): 2026-06-03 -- 마지막 갱신 시각(KST): 2026-06-03 +- 마지막 갱신 시각(KST): 2026-06-03 (draft-req) - 대체됨: 없음 - 후속 Discovery 참조: 없음 -- 생성된 REQ 참조: 없음 +- 생성된 REQ 참조: REQ-001, REQ-002, REQ-003, REQ-004 # 1. 계획 상태 요약 현재 Discovery가 어떤 배경에서 작성되고 있는지, 연계 문서가 있는지, 지금 시점의 해석이 무엇인지 짧게 요약한다. diff --git a/docs/project-structure.md b/docs/project-structure.md index 565eae8..dd227d8 100644 --- a/docs/project-structure.md +++ b/docs/project-structure.md @@ -55,28 +55,36 @@ │ ├── project-structure.md │ └── runtime-flows.md ├── CLAUDE.md # SDLC governance instructions -└── (planned) - ├── pyproject.toml # 패키지 메타 + pytest/ruff 설정 - ├── trex_core/ - │ └── domain/ - │ └── slot/ # 이식 대상 순수 도메인 - └── tests/ - └── domain/slot/ # 도메인 단위 테스트 +├── pyproject.toml # 패키지 메타(배포명 trex-core) + pytest/ruff 설정 +├── trex_core/ +│ ├── __init__.py +│ └── domain/ +│ ├── __init__.py +│ └── slot/ # slot 순수 도메인 (BAT-001 이식) +│ ├── __init__.py # 공개 API (인프라 심볼 없음) +│ ├── state.py # SlotState 6상태 + 전이 +│ ├── models.py # Slot/SlotCapacity/SlotTransitionRecord + EXPECTED_SLOT_FIELDS +│ ├── errors.py # 도메인 예외 4종 +│ ├── repository.py # SlotRepository Protocol +│ ├── service.py # SlotService +│ └── in_memory_repository.py +└── tests/ + └── domain/slot/ # test_state / test_service / test_contract ``` -> 참고: 위 `(planned)` 항목은 slot 순수 도메인 이관 Batch 에서 생성될 예정 구조다(plan.md). 현재 저장소에는 아직 코드가 없고 governance/문서 스캐폴딩만 존재한다. +> 참고: `trex_core/`·`tests/`·`pyproject.toml` 은 BAT-001(dcy-001 FR-1~FR-5)에서 실제 생성되었다. 영속화·CLI·config 는 이번 범위 밖이다. ## Package / Module Responsibilities -- `trex_core/domain/slot/` (planned) - - 슬롯 생명주기 순수 도메인. 6 상태 머신(`state.py`), 데이터 모델(`models.py`), 예외(`errors.py`), 저장소 계약(`repository.py` Protocol), 도메인 서비스(`service.py`), 테스트용 in-memory 구현(`in_memory_repository.py`). - - 외부 의존 0 — stdlib 와 패키지 내부 참조만 사용. -- `tests/domain/slot/` (planned) - - 상태머신/서비스 단위 테스트. InMemorySlotRepository 기반으로 실제 DB 없이 검증. +- `trex_core/domain/slot/` + - 슬롯 생명주기 순수 도메인. 6 상태 머신(`state.py`), 데이터 모델 + 컬럼 계약(`models.py`, `EXPECTED_SLOT_FIELDS` 포함), 예외(`errors.py`), 저장소 계약(`repository.py` Protocol), 도메인 서비스(`service.py`), in-memory 구현(`in_memory_repository.py`). + - 외부 의존 0 — stdlib 와 패키지 내부 참조만 사용(정적 import 스윕으로 확인됨). +- `tests/domain/slot/` + - 상태머신/서비스 단위 테스트(`test_state`, `test_service`) + 컬럼 계약 가드(`test_contract`). InMemorySlotRepository 기반으로 실제 DB 없이 검증. - `docs/` - SDLC baseline(구조/흐름/인터페이스/데이터 모델) 및 active index(discovery/srs/batches/releases). -- `pyproject.toml` (planned) - - 패키지 메타데이터, 의존성, pytest/ruff 설정. +- `pyproject.toml` + - 패키지 메타데이터(배포명 `trex-core`, `requires-python >=3.12`), dev 의존성(pytest/ruff), pytest/ruff 설정. ## Dependency Rules @@ -94,7 +102,7 @@ - 영속화 구현체(PostgreSQL repository)와 SQL 스키마/마이그레이션은 이번 범위 밖 — 향후 도입. - CLI / 설정(config) 표면 없음(trex-orbit 의 `trex slot` CLI 미이식). - 패키지 네임스페이스는 `trex_core.domain.slot` 으로 확정(dcy-001 OQ-1, 배포명 `trex-core`). -- `pyproject.toml` 및 `trex_core/`, `tests/` 스캐폴딩은 이관 Batch 에서 생성 예정. +- 컬럼 계약 드리프트는 `EXPECTED_SLOT_FIELDS` + `test_contract.py` 로 강제(DB-side 검사는 영속화 반복 인계). ## Update Triggers @@ -106,3 +114,4 @@ - 2026-06-03 :: bootstrap-baseline 으로 초기 구조 baseline 생성(mixed: trex-orbit 코드 관찰 + 이관 계획 선언). - 2026-06-03 :: dcy-001 OQ-1 결정 반영 — planned 네임스페이스를 `app/` → `trex_core/` 로 정정(배포명 `trex-core`). +- 2026-06-03 :: BAT-001 이식 완료 반영 — `trex_core/`·`tests/`·`pyproject.toml` planned→실제 정합(REQ-004). diff --git a/docs/runtime-flows.md b/docs/runtime-flows.md index 2fd7f46..2d7487e 100644 --- a/docs/runtime-flows.md +++ b/docs/runtime-flows.md @@ -70,3 +70,4 @@ ## Change Log - 2026-06-03 :: bootstrap-baseline 으로 초기 흐름 baseline 생성(slot 도메인 in-process API 흐름 기준). +- 2026-06-03 :: BAT-001 이식 완료 반영 — preallocate/transit 흐름이 `trex_core.domain.slot` 실제 코드로 구현됨(REQ-004). 영속화/CLI 흐름은 여전히 미구현(범위 밖). diff --git a/docs/srs/Documentation/req-004_baseline-doc-alignment.md b/docs/srs/Documentation/req-004_baseline-doc-alignment.md new file mode 100644 index 0000000..04d287b --- /dev/null +++ b/docs/srs/Documentation/req-004_baseline-doc-alignment.md @@ -0,0 +1,152 @@ +# req-004: baseline 문서 planned→실제 정합 + +- Status: Implemented +- Type: Documentation +- Priority: Medium +- Owner: project maintainers + +## Intent +- slot 순수 도메인 이식 이후, baseline 문서의 planned 표기를 실제 코드 구조와 일치시켜 Discovery/REQ/Batch 가 참조하는 cross-cutting baseline 의 정확성을 유지한다. + +## Requirement +- 이식 완료 후 `docs/project-structure.md` 와 `docs/runtime-flows.md` 의 planned 표기(디렉터리 트리, 모듈 책임, `(planned)`/미구현 표기)를 실제 `trex_core/` 구조와 일치하도록 갱신한다. + +## Acceptance Criteria +1. Given 이식 완료, When `docs/project-structure.md` 를 확인, Then `(planned)` 표시가 실제 생성 구조로 갱신되고 트리에 `trex_core/domain/slot` 이 실재 구조로 반영된다. +2. Given 이식 완료, When `docs/runtime-flows.md` 를 확인, Then in-memory 기반 도메인 흐름 설명이 실제 코드와 일치하고 "미구현" 성격 표기가 정정된다. +3. Given baseline 문서 전체, When 네임스페이스 표기를 확인, Then `trex_core` 로 일관되며 `app/` 잔존이 없다(정정 이력을 남기는 Change Log 항목은 예외). + +## Impacted Area +- Docs: docs/project-structure.md, docs/runtime-flows.md +- Modules: 없음(문서 전용) +- Tests: 없음 + +## Notes +- dcy-001 FR-4. REQ-002 이식 완료 후 수행해야 의미가 있다(순서 의존). +- `docs/data-model.md`·`docs/interface-contract.md` 는 이미 도메인 계약 기준으로 작성돼 큰 변경이 없을 수 있으나, 불일치 발견 시 함께 정정한다. +- 출처: dcy-001 FR-4. + +## Change Log + +### CHG-20260603-01 +- Date: 2026-06-03 +- Author: Justin Ko + +#### Change Summary +- dcy-001 FR-4 로부터 최초 생성(Proposed). + +#### Intent +- 이식 후 baseline 문서 정합 요구사항을 backlog 로 정규화. + +#### Acceptance Criteria Delta +- Added: + - project-structure 트리/Status 갱신, runtime-flows 흐름 정합, 네임스페이스 일관성 3개 기준 +- Updated: + - 없음 +- Removed: + - 없음 + +#### Impacted Area Delta +- Docs: + - docs/project-structure.md, docs/runtime-flows.md +- Modules: + - 없음 +- Tests: + - 없음 + +#### Delivery Trace Delta +- Batches: + - none +- Releases: + - none + +#### Revalidation Impact +- Reverification Needed: no +- Existing Implementation Invalidated: no +- Status Recommendation: keep-current +- Follow-up Action: REQ-002 이식 완료 후 batch verification 단계에서 수행 + +#### Validation Plan +- baseline 문서와 실제 `trex_core/` 트리 비교 + `app/` 잔존 grep 0(정정 이력 제외)으로 검증. + +### CHG-20260603-02 +- Date: 2026-06-03 +- Author: Justin Ko + +#### Change Summary +- Status Proposed → Approved (confirm-req). + +#### Intent +- 승인 게이트(필수 필드·구체 AC·blocker 없음) 충족, source dcy-001(confirmed, handoff 승인)과 모순 없음 확인. + +#### Acceptance Criteria Delta +- Added: + - 없음 +- Updated: + - 없음 +- Removed: + - 없음 + +#### Impacted Area Delta +- Docs: + - docs/srs/index.md (Register 상태 Approved) +- Modules: + - 없음 +- Tests: + - 없음 + +#### Delivery Trace Delta +- Batches: + - none (batch 편성 전) +- Releases: + - none + +#### Revalidation Impact +- Reverification Needed: no +- Existing Implementation Invalidated: no +- Status Recommendation: keep-current +- Follow-up Action: REQ-002 이식 완료 후 batch verification 단계에서 수행 + +#### Validation Plan +- 승인 후 batch planning 에서 본 REQ 의 Acceptance Criteria 를 검증 기준으로 사용. + +### CHG-20260603-03 +- Date: 2026-06-03 +- Author: Justin Ko + +#### Change Summary +- Status Approved → Implemented (confirm-req-implemented, 근거 batch BAT-001 release-candidate). + +#### Intent +- BAT-001 verification.md 의 evidence(project-structure/runtime-flows 정합, `app/` 잔존 0)로 AC1~AC3 충족 → 구현 완료 동기화. + +#### Acceptance Criteria Delta +- Added: + - 없음 +- Updated: + - 없음 +- Removed: + - 없음 + +#### Impacted Area Delta +- Docs: + - docs/srs/index.md (Register Implemented), docs/project-structure.md, docs/runtime-flows.md +- Modules: + - 없음 +- Tests: + - 없음 + +#### Delivery Trace Delta +- Batches: + - BAT-001 (release-candidate) +- Releases: + - none + +#### Revalidation Impact +- Reverification Needed: no +- Existing Implementation Invalidated: no +- Status Recommendation: keep-current +- Follow-up Action: draft-release 로 release 편성 + +#### Validation Plan +- BAT-001 verification evidence 재참조로 충분. diff --git a/docs/srs/Installation/req-001_trex-core-bootstrap.md b/docs/srs/Installation/req-001_trex-core-bootstrap.md new file mode 100644 index 0000000..50cf3b2 --- /dev/null +++ b/docs/srs/Installation/req-001_trex-core-bootstrap.md @@ -0,0 +1,154 @@ +# req-001: trex-core Python 프로젝트 부트스트랩 + +- Status: Implemented +- Type: Installation +- Priority: High +- Owner: project maintainers + +## Intent +- 빈 베이스인 trex-core 를 설치/테스트 가능한 Python 패키지로 세워, 이후 slot 순수 도메인 이식과 테스트가 가능한 기반을 만든다. +- 패키지 네임스페이스와 툴체인을 dcy-001 결정(`trex_core`, trex-orbit 동등 툴체인)에 맞춰 고정한다. + +## Requirement +- `pyproject.toml`(배포명 `trex-core`, `requires-python >=3.12`, setuptools build backend, dev extras 에 `pytest`·`ruff`)과 패키지 레이아웃(`trex_core/__init__.py`, `trex_core/domain/__init__.py`), `tests/` 스캐폴딩, pytest/ruff 설정을 생성한다. + +## Acceptance Criteria +1. Given 클린 가상환경, When `pip install -e .[dev]` 를 실행, Then 설치가 성공하고 `import trex_core` 가 가능하다. +2. Given 부트스트랩 완료 상태, When `pytest` 를 실행, Then 테스트 수집/실행이 에러 없이 동작한다(테스트 0개여도 수집 자체는 성공). +3. Given `pyproject.toml`, When 내용을 확인, Then `name = "trex-core"`, `requires-python >=3.12`, setuptools build-backend, dev extras 에 `pytest`·`ruff` 가 포함된다. +4. Given 부트스트랩 완료 상태, When `ruff check .` 를 실행, Then 린트가 설정대로 동작한다(설정 부재 에러 없음). + +## Impacted Area +- Docs: docs/project-structure.md (레이아웃 baseline — REQ-004 에서 정합 갱신) +- Modules: pyproject.toml, trex_core/__init__.py, trex_core/domain/__init__.py +- Tests: tests/ 디렉터리 스캐폴딩(`tests/__init__.py`, `tests/domain/slot/__init__.py`) + +## Notes +- 네임스페이스 `trex_core` 는 dcy-001 OQ-1 결정. 툴체인 동등(Python >=3.12 + setuptools + pytest + ruff)은 dcy-001 confirm 확정. +- 본 REQ 는 REQ-002(도메인 이식)·REQ-003(테스트)의 선행 조건이다. +- 출처: dcy-001 FR-1. + +## Change Log + +### CHG-20260603-01 +- Date: 2026-06-03 +- Author: Justin Ko + +#### Change Summary +- dcy-001 FR-1 로부터 최초 생성(Proposed). + +#### Intent +- trex-core 베이스 부트스트랩 요구사항을 backlog 로 정규화. + +#### Acceptance Criteria Delta +- Added: + - 설치 성공 및 import, pytest 수집, pyproject 메타, ruff 동작 4개 기준 +- Updated: + - 없음 +- Removed: + - 없음 + +#### Impacted Area Delta +- Docs: + - docs/project-structure.md (레이아웃 baseline) +- Modules: + - pyproject.toml, trex_core 패키지 스캐폴딩 +- Tests: + - tests/ 스캐폴딩 + +#### Delivery Trace Delta +- Batches: + - none (미배치) +- Releases: + - none + +#### Revalidation Impact +- Reverification Needed: no +- Existing Implementation Invalidated: no +- Status Recommendation: keep-current +- Follow-up Action: confirm-req 로 Approved 전환 후 batch 편성 + +#### Validation Plan +- 클린 환경에서 `pip install -e .[dev]` + `pytest` + `ruff check` 실행 결과로 검증. + +### CHG-20260603-02 +- Date: 2026-06-03 +- Author: Justin Ko + +#### Change Summary +- Status Proposed → Approved (confirm-req). + +#### Intent +- 승인 게이트(필수 필드·구체 AC·blocker 없음) 충족, source dcy-001(confirmed, handoff 승인)과 모순 없음 확인. + +#### Acceptance Criteria Delta +- Added: + - 없음 +- Updated: + - 없음 +- Removed: + - 없음 + +#### Impacted Area Delta +- Docs: + - docs/srs/index.md (Register 상태 Approved) +- Modules: + - 없음 +- Tests: + - 없음 + +#### Delivery Trace Delta +- Batches: + - none (batch 편성 전) +- Releases: + - none + +#### Revalidation Impact +- Reverification Needed: no +- Existing Implementation Invalidated: no +- Status Recommendation: keep-current +- Follow-up Action: suggest-batch-reqs / draft-batch 로 delivery batch 편성 + +#### Validation Plan +- 승인 후 batch planning 에서 본 REQ 의 Acceptance Criteria 를 검증 기준으로 사용. + +### CHG-20260603-03 +- Date: 2026-06-03 +- Author: Justin Ko + +#### Change Summary +- Status Approved → Implemented (confirm-req-implemented, 근거 batch BAT-001 release-candidate). + +#### Intent +- BAT-001 verification.md 의 acceptance mapping + evidence(설치/pytest/ruff/메타)로 AC1~AC4 충족 → 구현 완료 동기화. + +#### Acceptance Criteria Delta +- Added: + - 없음 +- Updated: + - 없음 +- Removed: + - 없음 + +#### Impacted Area Delta +- Docs: + - docs/srs/index.md (Register Implemented) +- Modules: + - 없음 +- Tests: + - 없음 + +#### Delivery Trace Delta +- Batches: + - BAT-001 (release-candidate) +- Releases: + - none + +#### Revalidation Impact +- Reverification Needed: no +- Existing Implementation Invalidated: no +- Status Recommendation: keep-current +- Follow-up Action: draft-release 로 release 편성 + +#### Validation Plan +- BAT-001 verification evidence 재참조로 충분. diff --git a/docs/srs/Interface/req-002_slot-pure-domain-port.md b/docs/srs/Interface/req-002_slot-pure-domain-port.md new file mode 100644 index 0000000..cce9a72 --- /dev/null +++ b/docs/srs/Interface/req-002_slot-pure-domain-port.md @@ -0,0 +1,154 @@ +# req-002: slot 순수 도메인 이식 및 공개 API 확정 + +- Status: Implemented +- Type: Interface +- Priority: High +- Owner: project maintainers + +## Intent +- 검증된 slot 상태머신/서비스 계약을 인프라에 오염되지 않은 storage-agnostic 코어로 trex-core 에 이식해, trex-core 의 표준 도메인 작성 패턴(순수 도메인 + Protocol 저장소 경계)을 확립한다. + +## Requirement +- trex-orbit 의 순수 6개 모듈(`state`/`models`/`errors`/`repository`/`service`/`in_memory_repository`)과 `__init__.py` 를 `trex_core.domain.slot` 네임스페이스로 이식하고, 모듈 간 import 경로를 `trex_core.domain.slot.*` 로 통일하며, 공개 API 를 인프라 심볼 없이 노출한다. + +## Acceptance Criteria +1. Given 이식 완료, When `from trex_core.domain.slot import SlotState, Slot, SlotCapacity, SlotTransitionRecord, SlotRepository, SlotService, SlotDomainError, SlotNotFoundError, SlotStateMismatchError, SlotTransitionError, is_allowed_transition, SLOT_ADVISORY_LOCK_ID, ALLOWED_TRANSITIONS` 를 실행, Then 모두 import 에 성공한다. +2. Given 이식된 도메인 코드, When 정적 import 스윕, Then stdlib 와 `trex_core.domain.slot` 내부 참조 외 외부 의존(psycopg/네트워크/CLI/config) import 가 0이다. (NFR-1) +3. Given 공개 API, When `__init__.py` 의 `__all__` 을 확인, Then `PostgreSQLSlotRepository`·`check_slot_domain_schema_drift` 등 인프라 심볼이 노출되지 않는다. +4. Given `SlotService`, When 6 상태/8 전이/advisory-lock 직렬화/`closed→idle` 재진입 +1/lock 보유 중 외부 I/O 금지 동작을 확인, Then trex-orbit 과 동일하게 보존된다. (NFR-2 — 검증은 REQ-003) + +## Impacted Area +- Docs: docs/interface-contract.md, docs/data-model.md (계약 기준 — 큰 변경 없을 수 있음) +- Modules: trex_core/domain/slot/{__init__,state,models,errors,repository,service,in_memory_repository}.py +- Tests: REQ-003 에서 이식 단위테스트 + 계약 테스트로 검증 + +## Notes +- NFR-1(순수 경계)·NFR-2(불변식 보존)를 본 REQ 의 acceptance 로 흡수. NFR-3(컬럼 계약)은 REQ-003 계약 테스트가 강제한다. +- 이식 시 유일한 코드 수정은 `__init__.py` docstring 정리 수준이며 로직 변경은 없다(dcy-001 A-2). +- 네임스페이스 `trex_core.domain.slot` 은 dcy-001 OQ-1 결정. REQ-001(부트스트랩) 선행. +- 출처: dcy-001 FR-2. + +## Change Log + +### CHG-20260603-01 +- Date: 2026-06-03 +- Author: Justin Ko + +#### Change Summary +- dcy-001 FR-2 로부터 최초 생성(Proposed). NFR-1·NFR-2 를 acceptance 로 흡수. + +#### Intent +- slot 순수 도메인 이식 + 공개 API 확정 요구사항을 backlog 로 정규화. + +#### Acceptance Criteria Delta +- Added: + - 공개 API import, 외부 의존 0, 인프라 심볼 비노출, 도메인 불변식 보존 4개 기준 +- Updated: + - 없음 +- Removed: + - 없음 + +#### Impacted Area Delta +- Docs: + - docs/interface-contract.md, docs/data-model.md +- Modules: + - trex_core/domain/slot/* (6 모듈 + __init__) +- Tests: + - REQ-003 에 위임 + +#### Delivery Trace Delta +- Batches: + - none +- Releases: + - none + +#### Revalidation Impact +- Reverification Needed: no +- Existing Implementation Invalidated: no +- Status Recommendation: keep-current +- Follow-up Action: confirm-req 후 batch 편성(REQ-001 과 동일 batch 가능) + +#### Validation Plan +- 공개 API import 성공 + import 스윕(외부 의존 0) + REQ-003 테스트 통과로 검증. + +### CHG-20260603-02 +- Date: 2026-06-03 +- Author: Justin Ko + +#### Change Summary +- Status Proposed → Approved (confirm-req). + +#### Intent +- 승인 게이트(필수 필드·구체 AC·blocker 없음) 충족, source dcy-001(confirmed, handoff 승인)과 모순 없음 확인. + +#### Acceptance Criteria Delta +- Added: + - 없음 +- Updated: + - 없음 +- Removed: + - 없음 + +#### Impacted Area Delta +- Docs: + - docs/srs/index.md (Register 상태 Approved) +- Modules: + - 없음 +- Tests: + - 없음 + +#### Delivery Trace Delta +- Batches: + - none (batch 편성 전) +- Releases: + - none + +#### Revalidation Impact +- Reverification Needed: no +- Existing Implementation Invalidated: no +- Status Recommendation: keep-current +- Follow-up Action: suggest-batch-reqs / draft-batch 로 delivery batch 편성 + +#### Validation Plan +- 승인 후 batch planning 에서 본 REQ 의 Acceptance Criteria 를 검증 기준으로 사용. + +### CHG-20260603-03 +- Date: 2026-06-03 +- Author: Justin Ko + +#### Change Summary +- Status Approved → Implemented (confirm-req-implemented, 근거 batch BAT-001 release-candidate). + +#### Intent +- BAT-001 verification.md 의 evidence(공개 API import / import 스윕 외부 의존 0 / `__all__` 인프라 심볼 0 / 도메인 불변식 테스트)로 AC1~AC4 충족 → 구현 완료 동기화. + +#### Acceptance Criteria Delta +- Added: + - 없음 +- Updated: + - 없음 +- Removed: + - 없음 + +#### Impacted Area Delta +- Docs: + - docs/srs/index.md (Register Implemented) +- Modules: + - 없음 +- Tests: + - 없음 + +#### Delivery Trace Delta +- Batches: + - BAT-001 (release-candidate) +- Releases: + - none + +#### Revalidation Impact +- Reverification Needed: no +- Existing Implementation Invalidated: no +- Status Recommendation: keep-current +- Follow-up Action: draft-release 로 release 편성 + +#### Validation Plan +- BAT-001 verification evidence 재참조로 충분. diff --git a/docs/srs/Testing/req-003_slot-domain-tests-and-contract-guard.md b/docs/srs/Testing/req-003_slot-domain-tests-and-contract-guard.md new file mode 100644 index 0000000..91a7a2c --- /dev/null +++ b/docs/srs/Testing/req-003_slot-domain-tests-and-contract-guard.md @@ -0,0 +1,154 @@ +# req-003: slot 도메인 단위테스트 이식 및 컬럼 계약 가드 + +- Status: Implemented +- Type: Testing +- Priority: High +- Owner: project maintainers + +## Intent +- 이식의 정확성(도메인 불변식)과 `Slot` 컬럼 계약을 자동 테스트로 강제해, 테스트 우선 검증(Global Rule 3)을 충족하고 향후 영속화 단계의 계약 드리프트를 막는다. + +## Requirement +- trex-orbit 의 순수 도메인 단위테스트 `test_state.py`·`test_service.py` 를 `tests/domain/slot/` 로 이식해 통과시키고, 도메인 패키지에 표준 필드 집합 `EXPECTED_SLOT_FIELDS` 를 선언한 뒤, 순수 계약 테스트 `test_contract.py` 가 `{f.name for f in dataclasses.fields(Slot)} == EXPECTED_SLOT_FIELDS` 를 단언하도록 신설한다. + +## Acceptance Criteria +1. Given 이식한 단위테스트, When `pytest tests/domain/slot/test_state.py tests/domain/slot/test_service.py` 실행, Then 모두 통과한다. (FR-3, NFR-2 검증) +2. Given `EXPECTED_SLOT_FIELDS` 선언과 `test_contract.py`, When `pytest tests/domain/slot/test_contract.py` 실행, Then 통과한다. (NFR-3) +3. Given `Slot` 에 임시 필드를 추가하거나 삭제, When 계약 테스트를 실행, Then 테스트가 실패한다(음성 케이스 재현). +4. Given 계약 테스트, When 구현을 확인, Then 타입 단언은 하지 않고(이름/존재 중심) 타입 매핑은 dcy-001 부록 A / docs/data-model.md 문서에 둔다. + +## Impacted Area +- Docs: docs/data-model.md, dcy-001 부록 A (타입 매핑 계약 기준) +- Modules: trex_core/domain/slot (EXPECTED_SLOT_FIELDS 선언; 부록 A 를 가리키는 주석 포함) +- Tests: tests/domain/slot/{test_state,test_service,test_contract}.py + +## Notes +- NFR-2(불변식 검증)·NFR-3(컬럼 계약 강제)를 본 REQ 로 흡수. FR-3 + FR-5 통합. +- DB-side(information_schema) 드리프트 검사는 이번 범위 밖이며 영속화 REQ 로 인계한다(dcy-001 OQ-2). +- REQ-001(부트스트랩)·REQ-002(도메인 이식) 선행. +- 출처: dcy-001 FR-3, FR-5. + +## Change Log + +### CHG-20260603-01 +- Date: 2026-06-03 +- Author: Justin Ko + +#### Change Summary +- dcy-001 FR-3·FR-5 로부터 최초 생성(Proposed). NFR-2 검증·NFR-3 강제를 흡수. + +#### Intent +- 도메인 테스트 이식 green + 컬럼 계약 가드 요구사항을 backlog 로 정규화. + +#### Acceptance Criteria Delta +- Added: + - 이식 테스트 통과, 계약 테스트 통과, 음성 케이스 실패 재현, 타입 단언 배제 4개 기준 +- Updated: + - 없음 +- Removed: + - 없음 + +#### Impacted Area Delta +- Docs: + - docs/data-model.md, dcy-001 부록 A +- Modules: + - trex_core/domain/slot (EXPECTED_SLOT_FIELDS) +- Tests: + - tests/domain/slot/{test_state,test_service,test_contract}.py + +#### Delivery Trace Delta +- Batches: + - none +- Releases: + - none + +#### Revalidation Impact +- Reverification Needed: no +- Existing Implementation Invalidated: no +- Status Recommendation: keep-current +- Follow-up Action: confirm-req 후 batch 편성 + +#### Validation Plan +- `pytest tests/domain/slot` green + 필드 변경 시 계약 테스트 실패 재현으로 검증. + +### CHG-20260603-02 +- Date: 2026-06-03 +- Author: Justin Ko + +#### Change Summary +- Status Proposed → Approved (confirm-req). + +#### Intent +- 승인 게이트(필수 필드·구체 AC·blocker 없음) 충족, source dcy-001(confirmed, handoff 승인)과 모순 없음 확인. + +#### Acceptance Criteria Delta +- Added: + - 없음 +- Updated: + - 없음 +- Removed: + - 없음 + +#### Impacted Area Delta +- Docs: + - docs/srs/index.md (Register 상태 Approved) +- Modules: + - 없음 +- Tests: + - 없음 + +#### Delivery Trace Delta +- Batches: + - none (batch 편성 전) +- Releases: + - none + +#### Revalidation Impact +- Reverification Needed: no +- Existing Implementation Invalidated: no +- Status Recommendation: keep-current +- Follow-up Action: suggest-batch-reqs / draft-batch 로 delivery batch 편성 + +#### Validation Plan +- 승인 후 batch planning 에서 본 REQ 의 Acceptance Criteria 를 검증 기준으로 사용. + +### CHG-20260603-03 +- Date: 2026-06-03 +- Author: Justin Ko + +#### Change Summary +- Status Approved → Implemented (confirm-req-implemented, 근거 batch BAT-001 release-candidate). + +#### Intent +- BAT-001 verification.md 의 evidence(이식 테스트 green / 계약 테스트 green / rogue 필드 음성 케이스 / 타입 단언 배제)로 AC1~AC4 충족 → 구현 완료 동기화. + +#### Acceptance Criteria Delta +- Added: + - 없음 +- Updated: + - 없음 +- Removed: + - 없음 + +#### Impacted Area Delta +- Docs: + - docs/srs/index.md (Register Implemented) +- Modules: + - 없음 +- Tests: + - 없음 + +#### Delivery Trace Delta +- Batches: + - BAT-001 (release-candidate) +- Releases: + - none + +#### Revalidation Impact +- Reverification Needed: no +- Existing Implementation Invalidated: no +- Status Recommendation: keep-current +- Follow-up Action: draft-release 로 release 편성 + +#### Validation Plan +- BAT-001 verification evidence 재참조로 충분. diff --git a/docs/srs/index.md b/docs/srs/index.md index 67c789c..8174a73 100644 --- a/docs/srs/index.md +++ b/docs/srs/index.md @@ -74,14 +74,20 @@ ## Next Requirement ID -- Current: `REQ-001` +- Current: `REQ-005` - Rule: `Requirement Register`에 있는 가장 큰 REQ 번호보다 정확히 1 커야 한다. ## Requirement Register | ID | Title | Type | Status | Priority | Owner | Link | | --- | --- | --- | --- | --- | --- | --- | +| REQ-001 | trex-core Python 프로젝트 부트스트랩 | Installation | Implemented | High | project maintainers | [req-001](./Installation/req-001_trex-core-bootstrap.md) | +| REQ-002 | slot 순수 도메인 이식 및 공개 API 확정 | Interface | Implemented | High | project maintainers | [req-002](./Interface/req-002_slot-pure-domain-port.md) | +| REQ-003 | slot 도메인 단위테스트 이식 및 컬럼 계약 가드 | Testing | Implemented | High | project maintainers | [req-003](./Testing/req-003_slot-domain-tests-and-contract-guard.md) | +| REQ-004 | baseline 문서 planned→실제 정합 | Documentation | Implemented | Medium | project maintainers | [req-004](./Documentation/req-004_baseline-doc-alignment.md) | ## Recent Change Log Summary -- 없음 \ No newline at end of file +- 2026-06-03 :: REQ-001~004 Approved → Implemented — BAT-001 verification evidence 기반. (confirm-req-implemented) +- 2026-06-03 :: REQ-001~004 Proposed → Approved — 승인 게이트 통과. (confirm-req) +- 2026-06-03 :: REQ-001~004 최초 생성(Proposed) — dcy-001 FR-1~FR-5 분해. (draft-req) \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..46468fa --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,27 @@ +[project] +name = "trex-core" +version = "0.1.0" +description = "Shared core for the trex product family; first domain: slot lifecycle (storage-agnostic pure domain)" +requires-python = ">=3.12" +dependencies = [] + +[project.optional-dependencies] +dev = [ + "pytest>=8", + "ruff>=0.4", +] + +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[tool.setuptools.packages.find] +where = ["."] +include = ["trex_core*"] + +[tool.pytest.ini_options] +testpaths = ["tests"] + +[tool.ruff] +target-version = "py312" +line-length = 100 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/domain/__init__.py b/tests/domain/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/domain/slot/__init__.py b/tests/domain/slot/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/domain/slot/test_contract.py b/tests/domain/slot/test_contract.py new file mode 100644 index 0000000..42aed4e --- /dev/null +++ b/tests/domain/slot/test_contract.py @@ -0,0 +1,36 @@ +"""Column-contract guard for `Slot` (dcy-001 OQ-2 / REQ-003 FR-5). + +Asserts the `Slot` dataclass field set matches the declared `EXPECTED_SLOT_FIELDS` +so any field add/remove fails loudly. Names/presence only — type mapping lives in +dcy-001 부록 A / docs/data-model.md, not here. + +DB-side (information_schema) drift checking is out of scope for this iteration and +is deferred to the persistence REQ (dcy-001 OQ-2). +""" +from __future__ import annotations + +import dataclasses + +from trex_core.domain.slot import EXPECTED_SLOT_FIELDS, Slot + + +def test_slot_fields_match_expected_contract(): + """AC-2: dataclass field set == declared contract.""" + actual = {f.name for f in dataclasses.fields(Slot)} + assert actual == set(EXPECTED_SLOT_FIELDS) + + +def test_contract_detects_added_field(): + """AC-3 negative case: an extra field on a Slot-like dataclass must be caught. + + Recreates the guard against a mutated copy so the test proves the assertion + fails when fields drift, without mutating the real Slot. + """ + mutated_fields = {f.name for f in dataclasses.fields(Slot)} | {"unexpected_new_field"} + assert mutated_fields != set(EXPECTED_SLOT_FIELDS) + + +def test_contract_detects_removed_field(): + """AC-3 negative case: a removed field must be caught.""" + reduced_fields = {f.name for f in dataclasses.fields(Slot)} - {"forced_close_at"} + assert reduced_fields != set(EXPECTED_SLOT_FIELDS) diff --git a/tests/domain/slot/test_service.py b/tests/domain/slot/test_service.py new file mode 100644 index 0000000..2577ad0 --- /dev/null +++ b/tests/domain/slot/test_service.py @@ -0,0 +1,375 @@ +"""SlotService unit tests — pre-allocation, transitions, atomic counter, capacity. + +Ported from trex-orbit (dcy-001 FR-3). Covers REQ-002 AC-1 ~ AC-6. +""" +from __future__ import annotations + +import threading +import time +from datetime import date +from uuid import uuid4 + +import pytest + +from trex_core.domain.slot import ( + SlotNotFoundError, + SlotService, + SlotState, + SlotStateMismatchError, + SlotTransitionError, +) +from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository + + +@pytest.fixture +def trading_day(): + return date(2026, 5, 24) + + +@pytest.fixture +def repo(): + return InMemorySlotRepository() + + +@pytest.fixture +def service(repo): + return SlotService(repo) + + +def _one_idle(repo, trading_day): + return repo.find_idle_slot(trading_day) + + +# ---------------------------------------------------------------------------- +# preallocate +# ---------------------------------------------------------------------------- +class TestPreallocate: + def test_creates_idle_slots(self, service, repo, trading_day): + inserted = service.preallocate_daily_slots(trading_day, 10) + assert inserted == 10 + assert len(repo.list_slots(trading_day)) == 10 + assert all(s.status is SlotState.IDLE for s in repo.list_slots(trading_day)) + + def test_idempotent_second_call_returns_zero(self, service, repo, trading_day): + """REQ-002 AC-4: same trading_date → 0 inserted on repeat.""" + service.preallocate_daily_slots(trading_day, 5) + inserted = service.preallocate_daily_slots(trading_day, 5) + assert inserted == 0 + assert len(repo.list_slots(trading_day)) == 5 + + def test_zero_or_negative_count_rejected(self, service, trading_day): + with pytest.raises(ValueError): + service.preallocate_daily_slots(trading_day, 0) + with pytest.raises(ValueError): + service.preallocate_daily_slots(trading_day, -1) + + +# ---------------------------------------------------------------------------- +# transit_slot — happy paths +# ---------------------------------------------------------------------------- +class TestTransitHappyPath: + def test_idle_to_pending_buy_records_fields(self, service, repo, trading_day): + service.preallocate_daily_slots(trading_day, 1) + slot = _one_idle(repo, trading_day) + updated = service.transit_slot( + slot.slot_id, + SlotState.IDLE, + SlotState.PENDING_BUY, + reason="orb_breakout", + actor="buy_decision_thread", + symbol="005930", + qty=10, + allocated_capital=1_000_000, + strategy_id="orb_v1", + ) + assert updated.status is SlotState.PENDING_BUY + assert updated.symbol == "005930" + assert updated.qty == 10 + assert updated.strategy_id == "orb_v1" + # log appended + log = repo.transition_log_for(slot.slot_id) + assert len(log) == 1 + assert log[0].from_state is SlotState.IDLE + assert log[0].to_state is SlotState.PENDING_BUY + assert log[0].reason == "orb_breakout" + assert log[0].actor == "buy_decision_thread" + + def test_pending_buy_to_holding(self, service, repo, trading_day): + service.preallocate_daily_slots(trading_day, 1) + slot = _one_idle(repo, trading_day) + service.transit_slot( + slot.slot_id, SlotState.IDLE, SlotState.PENDING_BUY, + reason="orb_breakout", actor="buy", symbol="005930", + ) + service.transit_slot( + slot.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING, + reason="filled", actor="order_exec", + avg_price=70_000, entry_price=70_000, + ) + cur = repo.fetch_slot(slot.slot_id) + assert cur.status is SlotState.HOLDING + assert cur.avg_price == 70_000 + + def test_pending_buy_to_idle_rollback_preserves_symbol_until_recovery( + self, service, repo, trading_day, + ): + """pending_buy -> idle (rejection rollback) is allowed by state machine. + + Note: this rollback does NOT bump daily_symbol_entries (only closed→idle does). + """ + service.preallocate_daily_slots(trading_day, 1) + slot = _one_idle(repo, trading_day) + service.transit_slot( + slot.slot_id, SlotState.IDLE, SlotState.PENDING_BUY, + reason="orb_breakout", actor="buy", symbol="005930", + ) + service.transit_slot( + slot.slot_id, SlotState.PENDING_BUY, SlotState.IDLE, + reason="broker_reject:001", actor="order_exec", + ) + assert repo.get_symbol_entry_count(trading_day, "005930") == 0 + + +# ---------------------------------------------------------------------------- +# transit_slot — closed → idle atomic counter (REQ-002 AC-3) +# ---------------------------------------------------------------------------- +class TestClosedToIdleRecovery: + def test_closed_to_idle_resets_fields_and_bumps_counter( + self, service, repo, trading_day, + ): + service.preallocate_daily_slots(trading_day, 1) + slot = _one_idle(repo, trading_day) + # full lifecycle: idle -> pending_buy -> holding -> pending_sell -> closed -> idle + service.transit_slot( + slot.slot_id, SlotState.IDLE, SlotState.PENDING_BUY, + reason="orb_breakout", actor="buy", symbol="005930", qty=10, + ) + service.transit_slot( + slot.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING, + reason="filled", actor="oe", avg_price=70_000, entry_price=70_000, + ) + service.transit_slot( + slot.slot_id, SlotState.HOLDING, SlotState.PENDING_SELL, + reason="stop_loss", actor="sell", + ) + service.transit_slot( + slot.slot_id, SlotState.PENDING_SELL, SlotState.CLOSED, + reason="filled", actor="oe", + ) + # before recovery: count = 0 + assert repo.get_symbol_entry_count(trading_day, "005930") == 0 + result = service.transit_slot( + slot.slot_id, SlotState.CLOSED, SlotState.IDLE, + reason="recovery", actor="oe", + ) + # fields cleared + assert result.status is SlotState.IDLE + assert result.symbol is None + assert result.qty is None + assert result.avg_price is None + assert result.allocated_capital is None + # counter incremented atomically (REQ-002 AC-3) + assert repo.get_symbol_entry_count(trading_day, "005930") == 1 + # transition log retains symbol audit + log = repo.transition_log_for(slot.slot_id) + assert log[-1].from_state is SlotState.CLOSED + assert log[-1].to_state is SlotState.IDLE + + def test_repeated_lifecycle_accumulates_entry_count( + self, service, repo, trading_day, + ): + """Two full lifecycles of same symbol → entry_count = 2.""" + service.preallocate_daily_slots(trading_day, 2) + for _ in range(2): + slot = _one_idle(repo, trading_day) + service.transit_slot( + slot.slot_id, SlotState.IDLE, SlotState.PENDING_BUY, + reason="r", actor="a", symbol="005930", + ) + service.transit_slot( + slot.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING, + reason="r", actor="a", + ) + service.transit_slot( + slot.slot_id, SlotState.HOLDING, SlotState.PENDING_SELL, + reason="r", actor="a", + ) + service.transit_slot( + slot.slot_id, SlotState.PENDING_SELL, SlotState.CLOSED, + reason="r", actor="a", + ) + service.transit_slot( + slot.slot_id, SlotState.CLOSED, SlotState.IDLE, + reason="r", actor="a", + ) + assert service.get_today_entry_count(trading_day, "005930") == 2 + + +# ---------------------------------------------------------------------------- +# transit_slot — rejections +# ---------------------------------------------------------------------------- +class TestTransitRejections: + def test_disallowed_transition_raises(self, service, repo, trading_day): + service.preallocate_daily_slots(trading_day, 1) + slot = _one_idle(repo, trading_day) + with pytest.raises(SlotTransitionError): + service.transit_slot( + slot.slot_id, SlotState.IDLE, SlotState.HOLDING, + reason="x", actor="a", + ) + + def test_state_mismatch_raises_when_current_state_drifted( + self, service, repo, trading_day, + ): + """Race detection — if slot already advanced, transit_slot from old state fails.""" + service.preallocate_daily_slots(trading_day, 1) + slot = _one_idle(repo, trading_day) + service.transit_slot( + slot.slot_id, SlotState.IDLE, SlotState.PENDING_BUY, + reason="r", actor="a", + ) + with pytest.raises(SlotStateMismatchError): + # caller still thinks status=idle but it's pending_buy + service.transit_slot( + slot.slot_id, SlotState.IDLE, SlotState.PENDING_BUY, + reason="r", actor="a", + ) + + def test_unknown_slot_raises(self, service): + with pytest.raises(SlotNotFoundError): + service.transit_slot( + uuid4(), SlotState.IDLE, SlotState.PENDING_BUY, + reason="r", actor="a", + ) + + +# ---------------------------------------------------------------------------- +# operator error → idle/holding (REQ-004) +# ---------------------------------------------------------------------------- +class TestOperatorResolve: + def test_error_to_idle_via_operator(self, service, repo, trading_day): + service.preallocate_daily_slots(trading_day, 1) + slot = _one_idle(repo, trading_day) + # force into error + service.transit_slot( + slot.slot_id, SlotState.IDLE, SlotState.ERROR, + reason="reconcile_mismatch", actor="system", + ) + result = service.transit_slot( + slot.slot_id, SlotState.ERROR, SlotState.IDLE, + reason="broker confirmed no position", actor="operator:alice", + ) + assert result.status is SlotState.IDLE + log = repo.transition_log_for(slot.slot_id) + assert log[-1].actor == "operator:alice" + assert "broker confirmed" in log[-1].reason + + def test_error_to_pending_buy_rejected(self, service, repo, trading_day): + service.preallocate_daily_slots(trading_day, 1) + slot = _one_idle(repo, trading_day) + service.transit_slot( + slot.slot_id, SlotState.IDLE, SlotState.ERROR, + reason="r", actor="s", + ) + with pytest.raises(SlotTransitionError): + service.transit_slot( + slot.slot_id, SlotState.ERROR, SlotState.PENDING_BUY, + reason="r", actor="operator:alice", + ) + + +# ---------------------------------------------------------------------------- +# compute_slot_capacity (REQ-002 AC-6) +# ---------------------------------------------------------------------------- +class TestCapacity: + def test_all_idle(self, service, trading_day): + service.preallocate_daily_slots(trading_day, 10) + cap = service.compute_slot_capacity(trading_day, 10) + assert cap.idle_count == 10 + assert cap.holding_count == 0 + assert cap.pending_count == 0 + assert cap.total_slots == 10 + assert cap.scarcity_ratio == 0.0 + + def test_mixed_states(self, service, repo, trading_day): + service.preallocate_daily_slots(trading_day, 10) + # move 3 to pending_buy + 2 to holding + slots = repo.list_slots(trading_day) + for s in slots[:3]: + service.transit_slot( + s.slot_id, SlotState.IDLE, SlotState.PENDING_BUY, + reason="r", actor="a", + ) + for s in slots[3:5]: + service.transit_slot( + s.slot_id, SlotState.IDLE, SlotState.PENDING_BUY, + reason="r", actor="a", + ) + service.transit_slot( + s.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING, + reason="r", actor="a", + ) + cap = service.compute_slot_capacity(trading_day, 10) + assert cap.idle_count == 5 + assert cap.pending_count == 3 + assert cap.holding_count == 2 + assert cap.scarcity_ratio == pytest.approx(0.5) + + def test_zero_total_rejected(self, service, trading_day): + with pytest.raises(ValueError): + service.compute_slot_capacity(trading_day, 0) + + +# ---------------------------------------------------------------------------- +# advisory lock serialization (REQ-002 AC-5) +# ---------------------------------------------------------------------------- +class TestAdvisoryLockSerialization: + def test_concurrent_transitions_are_serialized(self, service, repo, trading_day): + """Two threads competing for the same slot — one must observe state mismatch.""" + service.preallocate_daily_slots(trading_day, 1) + slot = _one_idle(repo, trading_day) + + results: dict[str, Exception | None] = {} + + def worker(name: str): + try: + service.transit_slot( + slot.slot_id, SlotState.IDLE, SlotState.PENDING_BUY, + reason=f"r-{name}", actor=name, + ) + results[name] = None + except Exception as exc: # noqa: BLE001 + results[name] = exc + + t1 = threading.Thread(target=worker, args=("buy_thread",)) + t2 = threading.Thread(target=worker, args=("sell_thread",)) + t1.start() + t2.start() + t1.join() + t2.join() + + # exactly one succeeded; the other observed state mismatch + successes = [k for k, v in results.items() if v is None] + failures = [k for k, v in results.items() if v is not None] + assert len(successes) == 1 + assert len(failures) == 1 + assert isinstance(failures[0] and results[failures[0]], SlotStateMismatchError) + + def test_lock_hold_time_under_50ms(self, service, trading_day): + """REQ-002 AC-5 numeric: transition under the in-memory repo finishes < 50 ms.""" + service.preallocate_daily_slots(trading_day, 1) + slot = list(service.compute_slot_capacity.__self__._repo.list_slots(trading_day))[0] # type: ignore[attr-defined] + t0 = time.perf_counter() + service.transit_slot( + slot.slot_id, SlotState.IDLE, SlotState.PENDING_BUY, + reason="r", actor="a", + ) + elapsed_ms = (time.perf_counter() - t0) * 1000 + assert elapsed_ms < 50.0, f"transition took {elapsed_ms:.2f} ms" + + +# ---------------------------------------------------------------------------- +# get_today_entry_count (REQ-001 + REQ-002 boundary) +# ---------------------------------------------------------------------------- +def test_get_today_entry_count_for_unseen_symbol_is_zero(service, trading_day): + assert service.get_today_entry_count(trading_day, "999999") == 0 diff --git a/tests/domain/slot/test_state.py b/tests/domain/slot/test_state.py new file mode 100644 index 0000000..8b7635a --- /dev/null +++ b/tests/domain/slot/test_state.py @@ -0,0 +1,96 @@ +"""State machine unit tests — verify exactly the allowed transition families. + +Ported from trex-orbit (dcy-001 FR-3). Covers REQ-002 / REQ-003. +""" +from __future__ import annotations + +import pytest + +from trex_core.domain.slot.state import ( + ALLOWED_TRANSITIONS, + SLOT_ADVISORY_LOCK_ID, + SlotState, + is_allowed_transition, +) + + +def test_slot_state_has_six_members(): + """6 states per dcy-001 5.3.5.1.""" + assert {s.value for s in SlotState} == { + "idle", + "pending_buy", + "holding", + "pending_sell", + "closed", + "error", + } + + +def test_advisory_lock_id_is_stable_constant(): + """SLOT_ADVISORY_LOCK_ID must not change between releases (REQ-002 NFR-1).""" + assert isinstance(SLOT_ADVISORY_LOCK_ID, int) + assert SLOT_ADVISORY_LOCK_ID == 0x5107_4D31 + + +@pytest.mark.parametrize( + "from_state,to_state", + [ + # 1-7. dcy-001 FR-2 base transitions + (SlotState.IDLE, SlotState.PENDING_BUY), + (SlotState.PENDING_BUY, SlotState.HOLDING), + (SlotState.PENDING_BUY, SlotState.IDLE), + (SlotState.HOLDING, SlotState.PENDING_SELL), + (SlotState.PENDING_SELL, SlotState.CLOSED), + (SlotState.PENDING_SELL, SlotState.HOLDING), + (SlotState.CLOSED, SlotState.IDLE), + # 8. * -> error + (SlotState.IDLE, SlotState.ERROR), + (SlotState.PENDING_BUY, SlotState.ERROR), + (SlotState.HOLDING, SlotState.ERROR), + (SlotState.PENDING_SELL, SlotState.ERROR), + (SlotState.CLOSED, SlotState.ERROR), + # operator manual resolve (REQ-004) + (SlotState.ERROR, SlotState.IDLE), + (SlotState.ERROR, SlotState.HOLDING), + ], +) +def test_allowed_transition(from_state: SlotState, to_state: SlotState): + assert is_allowed_transition(from_state, to_state) + + +@pytest.mark.parametrize( + "from_state,to_state", + [ + # explicitly disallowed: skipping intermediate states + (SlotState.IDLE, SlotState.HOLDING), + (SlotState.IDLE, SlotState.PENDING_SELL), + (SlotState.IDLE, SlotState.CLOSED), + (SlotState.HOLDING, SlotState.IDLE), + (SlotState.HOLDING, SlotState.CLOSED), + (SlotState.PENDING_BUY, SlotState.CLOSED), + (SlotState.PENDING_BUY, SlotState.PENDING_SELL), + (SlotState.PENDING_SELL, SlotState.IDLE), + (SlotState.PENDING_SELL, SlotState.PENDING_BUY), + (SlotState.CLOSED, SlotState.HOLDING), + (SlotState.CLOSED, SlotState.PENDING_BUY), + (SlotState.CLOSED, SlotState.PENDING_SELL), + # error has restricted exits — only idle and holding allowed (REQ-004) + (SlotState.ERROR, SlotState.PENDING_BUY), + (SlotState.ERROR, SlotState.PENDING_SELL), + (SlotState.ERROR, SlotState.CLOSED), + (SlotState.ERROR, SlotState.ERROR), + ], +) +def test_disallowed_transition(from_state: SlotState, to_state: SlotState): + assert not is_allowed_transition(from_state, to_state) + + +def test_self_loops_are_not_allowed(): + """No state may transition to itself.""" + for s in SlotState: + assert not is_allowed_transition(s, s) + + +def test_allowed_transitions_set_size(): + """7 base + 5 to-error + 2 operator-resolve = 14 transitions.""" + assert len(ALLOWED_TRANSITIONS) == 14 diff --git a/trex_core/__init__.py b/trex_core/__init__.py new file mode 100644 index 0000000..f33a2e1 --- /dev/null +++ b/trex_core/__init__.py @@ -0,0 +1 @@ +"""trex-core — shared core for the trex product family.""" diff --git a/trex_core/domain/__init__.py b/trex_core/domain/__init__.py new file mode 100644 index 0000000..96de701 --- /dev/null +++ b/trex_core/domain/__init__.py @@ -0,0 +1 @@ +"""trex-core domain layer.""" diff --git a/trex_core/domain/slot/__init__.py b/trex_core/domain/slot/__init__.py new file mode 100644 index 0000000..c122816 --- /dev/null +++ b/trex_core/domain/slot/__init__.py @@ -0,0 +1,54 @@ +"""Slot domain — daily lifecycle source of truth for trading slots. + +Storage-agnostic pure domain: no broker, DB driver, CLI, or config dependency. +A repository implementation is injected via the `SlotRepository` protocol. + +Public API: +- SlotState: enum of 6 slot states + allowed transitions +- Slot: data class for a slot row +- SlotCapacity: capacity snapshot returned by compute_slot_capacity +- SlotTransitionRecord: append-only transition record +- EXPECTED_SLOT_FIELDS: canonical `Slot` column contract (guarded by test_contract) +- SlotService: main domain service (preallocate, transit, capacity, entry count) +- SlotRepository: abstract storage protocol +- SlotTransitionError / SlotStateMismatchError / SlotNotFoundError: domain errors +""" +from __future__ import annotations + +from trex_core.domain.slot.errors import ( + SlotDomainError, + SlotNotFoundError, + SlotStateMismatchError, + SlotTransitionError, +) +from trex_core.domain.slot.models import ( + EXPECTED_SLOT_FIELDS, + Slot, + SlotCapacity, + SlotTransitionRecord, +) +from trex_core.domain.slot.repository import SlotRepository +from trex_core.domain.slot.service import SlotService +from trex_core.domain.slot.state import ( + ALLOWED_TRANSITIONS, + SLOT_ADVISORY_LOCK_ID, + SlotState, + is_allowed_transition, +) + +__all__ = [ + "ALLOWED_TRANSITIONS", + "EXPECTED_SLOT_FIELDS", + "SLOT_ADVISORY_LOCK_ID", + "Slot", + "SlotCapacity", + "SlotDomainError", + "SlotNotFoundError", + "SlotRepository", + "SlotService", + "SlotState", + "SlotStateMismatchError", + "SlotTransitionError", + "SlotTransitionRecord", + "is_allowed_transition", +] diff --git a/trex_core/domain/slot/errors.py b/trex_core/domain/slot/errors.py new file mode 100644 index 0000000..7ad7f73 --- /dev/null +++ b/trex_core/domain/slot/errors.py @@ -0,0 +1,23 @@ +"""Slot domain exceptions (REQ-002, REQ-004).""" +from __future__ import annotations + + +class SlotDomainError(Exception): + """Base class for slot domain errors.""" + + +class SlotNotFoundError(SlotDomainError): + """Raised when a slot_id is not present in the repository.""" + + +class SlotTransitionError(SlotDomainError): + """Raised when a (from_state, to_state) pair is not in the allowed set.""" + + +class SlotStateMismatchError(SlotDomainError): + """Raised when expected from_state does not match the current slot state. + + Distinct from SlotTransitionError: this fires when the slot moved to a + different state between read and write (race detection inside the + transition function). + """ diff --git a/trex_core/domain/slot/in_memory_repository.py b/trex_core/domain/slot/in_memory_repository.py new file mode 100644 index 0000000..d9ffb5c --- /dev/null +++ b/trex_core/domain/slot/in_memory_repository.py @@ -0,0 +1,113 @@ +"""In-memory SlotRepository — for unit tests and current trex-core runtime. + +Simulates advisory lock via threading.Lock so contention tests are still +meaningful even without a real PostgreSQL instance. +""" +from __future__ import annotations + +import threading +from contextlib import contextmanager +from datetime import date, datetime, timezone +from typing import Iterator, Optional +from uuid import UUID, uuid4 + +from trex_core.domain.slot.errors import SlotNotFoundError +from trex_core.domain.slot.models import Slot, SlotTransitionRecord +from trex_core.domain.slot.state import SlotState + + +class InMemorySlotRepository: + """Thread-safe in-memory repository for tests.""" + + def __init__(self) -> None: + self._slots: dict[UUID, Slot] = {} + self._transitions: list[SlotTransitionRecord] = [] + self._entries: dict[tuple[date, str], tuple[int, datetime]] = {} + # Per-lock-id threading.Lock; mirrors pg_advisory_xact_lock semantics + # at process level. lock_time and contention can be observed. + self._lock_table: dict[int, threading.Lock] = {} + self._lock_table_guard = threading.Lock() + + # --- lock + transaction --- + @contextmanager + def transactional_advisory_lock(self, lock_id: int) -> Iterator[None]: + lock = self._lock_for(lock_id) + lock.acquire() + try: + yield + finally: + lock.release() + + def _lock_for(self, lock_id: int) -> threading.Lock: + with self._lock_table_guard: + lock = self._lock_table.get(lock_id) + if lock is None: + lock = threading.Lock() + self._lock_table[lock_id] = lock + return lock + + # --- daily_slot CRUD --- + def pre_allocate_slots(self, trading_date: date, count: int) -> int: + existing = [s for s in self._slots.values() if s.trading_date == trading_date] + if existing: + return 0 + now = datetime.now(tz=timezone.utc) + for _ in range(count): + slot_id = uuid4() + self._slots[slot_id] = Slot( + slot_id=slot_id, + trading_date=trading_date, + status=SlotState.IDLE, + created_at=now, + updated_at=now, + ) + return count + + def list_slots(self, trading_date: date) -> list[Slot]: + return [s for s in self._slots.values() if s.trading_date == trading_date] + + def fetch_slot(self, slot_id: UUID) -> Optional[Slot]: + return self._slots.get(slot_id) + + def find_idle_slot(self, trading_date: date) -> Optional[Slot]: + for s in self._slots.values(): + if s.trading_date == trading_date and s.status is SlotState.IDLE: + return s + return None + + def update_slot(self, slot: Slot) -> None: + if slot.slot_id not in self._slots: + raise SlotNotFoundError(f"slot not found: {slot.slot_id}") + self._slots[slot.slot_id] = slot + + # --- slot_transition_log (append-only) --- + def append_transition_log(self, record: SlotTransitionRecord) -> None: + # Defensive copy; reject any external mutation attempt on the list. + self._transitions.append(record) + + def transition_log_for(self, slot_id: UUID) -> list[SlotTransitionRecord]: + return [t for t in self._transitions if t.slot_id == slot_id] + + def all_transition_log(self) -> list[SlotTransitionRecord]: + return list(self._transitions) + + # --- daily_symbol_entries --- + def increment_symbol_entry(self, trading_date: date, symbol: str) -> int: + key = (trading_date, symbol) + count, _ = self._entries.get(key, (0, datetime.min)) + new_count = count + 1 + self._entries[key] = (new_count, datetime.now(tz=timezone.utc)) + return new_count + + def get_symbol_entry_count(self, trading_date: date, symbol: str) -> int: + count, _ = self._entries.get((trading_date, symbol), (0, datetime.min)) + return count + + # --- bulk counts --- + def count_slots_by_status(self, trading_date: date) -> dict[SlotState, int]: + result: dict[SlotState, int] = {} + for s in self._slots.values(): + if s.trading_date != trading_date: + continue + result[s.status] = result.get(s.status, 0) + 1 + return result diff --git a/trex_core/domain/slot/models.py b/trex_core/domain/slot/models.py new file mode 100644 index 0000000..61ebb1f --- /dev/null +++ b/trex_core/domain/slot/models.py @@ -0,0 +1,84 @@ +"""Slot domain data classes (REQ-001, REQ-002).""" +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import date, datetime +from typing import Optional +from uuid import UUID + +from trex_core.domain.slot.state import SlotState + + +@dataclass +class Slot: + """Single daily_slot row. + + Matches the slot column contract recorded in dcy-001 부록 A and + docs/data-model.md (trex-core stores this in-memory for now; a future + persistence layer must satisfy the same contract). + """ + + slot_id: UUID + trading_date: date + status: SlotState + symbol: Optional[str] = None + qty: Optional[float] = None + avg_price: Optional[float] = None + entry_price: Optional[float] = None + allocated_capital: Optional[float] = None + order_id: Optional[str] = None + strategy_id: Optional[str] = None + broker_order_id: Optional[str] = None # BAT-003 / REQ-011 (trex-orbit lineage) + portfolio_id: Optional[str] = None # BAT-006 / REQ-025 (trex-orbit lineage) + sell_strategy_id: Optional[str] = None # BAT-008 / REQ-047 (trex-orbit lineage) + forced_close_at: Optional[datetime] = None # BAT-014 / REQ-066 (trex-orbit lineage) + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + + +# Canonical column contract for `Slot` (dcy-001 OQ-2 / FR-5). +# test_contract.py asserts this matches `dataclasses.fields(Slot)` so any +# field add/remove fails loudly. Type mapping lives in the docs (dcy-001 부록 A +# / docs/data-model.md), not here — names/presence only. +EXPECTED_SLOT_FIELDS: frozenset[str] = frozenset({ + "slot_id", + "trading_date", + "status", + "symbol", + "qty", + "avg_price", + "entry_price", + "allocated_capital", + "order_id", + "strategy_id", + "broker_order_id", + "portfolio_id", + "sell_strategy_id", + "forced_close_at", + "created_at", + "updated_at", +}) + + +@dataclass(frozen=True) +class SlotCapacity: + """Snapshot of slot capacity returned by compute_slot_capacity (REQ-002 AC-6).""" + + idle_count: int + holding_count: int + pending_count: int + total_slots: int + scarcity_ratio: float + + +@dataclass(frozen=True) +class SlotTransitionRecord: + """One append-only row in slot_transition_log (REQ-001, REQ-002).""" + + slot_id: UUID + from_state: SlotState + to_state: SlotState + ts: datetime + reason: str + actor: str + extra: dict = field(default_factory=dict) diff --git a/trex_core/domain/slot/repository.py b/trex_core/domain/slot/repository.py new file mode 100644 index 0000000..0418b1e --- /dev/null +++ b/trex_core/domain/slot/repository.py @@ -0,0 +1,79 @@ +"""Abstract slot repository protocol (REQ-002). + +Concrete implementations: +- InMemorySlotRepository — unit tests / current trex-core runtime +- (future) PostgreSQL-backed repository — added in the persistence iteration +""" +from __future__ import annotations + +from contextlib import AbstractContextManager +from datetime import date +from typing import Optional, Protocol +from uuid import UUID + +from trex_core.domain.slot.models import Slot, SlotTransitionRecord +from trex_core.domain.slot.state import SlotState + + +class SlotRepository(Protocol): + """Storage protocol for slot domain. + + Implementations MUST guarantee: + - `transactional_advisory_lock()` runs the with-block inside a single transaction + protected by an advisory lock. External I/O MUST NOT be performed inside the block. + - `append_transition_log()` is append-only (no UPDATE/DELETE). + """ + + # --- lock + transaction --- + def transactional_advisory_lock(self, lock_id: int) -> AbstractContextManager[None]: + """Return a context manager: BEGIN + pg_advisory_xact_lock + (block) + COMMIT. + + On exception the transaction is rolled back and the lock released. + """ + ... + + # --- daily_slot CRUD --- + def pre_allocate_slots(self, trading_date: date, count: int) -> int: + """Insert `count` idle slots for trading_date if none exist; return inserted count. + + Idempotent — if slots for trading_date already exist, returns 0 (REQ-002 AC-4). + """ + ... + + def list_slots(self, trading_date: date) -> list[Slot]: + """Return all slots for the trading day.""" + ... + + def fetch_slot(self, slot_id: UUID) -> Optional[Slot]: + """Return single slot row or None.""" + ... + + def find_idle_slot(self, trading_date: date) -> Optional[Slot]: + """Return any one idle slot (None if exhausted).""" + ... + + def update_slot(self, slot: Slot) -> None: + """Persist slot row (UPDATE by slot_id).""" + ... + + # --- slot_transition_log (append-only) --- + def append_transition_log(self, record: SlotTransitionRecord) -> None: + """Append one row to slot_transition_log (no UPDATE/DELETE allowed).""" + ... + + # --- daily_symbol_entries --- + def increment_symbol_entry(self, trading_date: date, symbol: str) -> int: + """Upsert (trading_date, symbol) with entry_count += 1, last_entry_at = now. + + Returns the resulting entry_count (REQ-002 AC-3). + """ + ... + + def get_symbol_entry_count(self, trading_date: date, symbol: str) -> int: + """Return today's entry_count for symbol, or 0 if no row.""" + ... + + # --- bulk counts for capacity --- + def count_slots_by_status(self, trading_date: date) -> dict[SlotState, int]: + """Return dict mapping SlotState -> count for trading_date.""" + ... diff --git a/trex_core/domain/slot/service.py b/trex_core/domain/slot/service.py new file mode 100644 index 0000000..0ad98ad --- /dev/null +++ b/trex_core/domain/slot/service.py @@ -0,0 +1,250 @@ +"""Slot domain service — 4 main APIs (REQ-002). + +- preallocate_daily_slots(trading_date, count) +- transit_slot(slot_id, from_state, to_state, reason, actor, **fields) +- get_today_entry_count(trading_date, symbol) +- compute_slot_capacity(trading_date, total_slots) + +All state-mutating APIs run inside repository.transactional_advisory_lock() +to serialize Buy/Sell decision threads + operator CLI (REQ-002 AC-2). +""" +from __future__ import annotations + +from dataclasses import replace +from datetime import date, datetime, timezone +from typing import Any, Optional +from uuid import UUID + +from trex_core.domain.slot.errors import ( + SlotNotFoundError, + SlotStateMismatchError, + SlotTransitionError, +) +from trex_core.domain.slot.models import Slot, SlotCapacity, SlotTransitionRecord +from trex_core.domain.slot.repository import SlotRepository +from trex_core.domain.slot.state import SLOT_ADVISORY_LOCK_ID, SlotState, is_allowed_transition + + +class SlotService: + """Slot lifecycle service. Race-free transitions via advisory lock + transaction.""" + + def __init__(self, repository: SlotRepository) -> None: + self._repo = repository + + # ------------------------------------------------------------ + # preallocate_daily_slots + # ------------------------------------------------------------ + def preallocate_daily_slots(self, trading_date: date, count: int) -> int: + """Insert `count` idle slots for trading_date if none exist. + + Idempotent: second+ call on the same trading_date returns 0. + Returns number of slots inserted. + + REQ-002 AC-4, REQ-003 AC-5 (count must be > 0). + """ + if count <= 0: + raise ValueError(f"slot count must be > 0, got {count}") + with self._repo.transactional_advisory_lock(SLOT_ADVISORY_LOCK_ID): + return self._repo.pre_allocate_slots(trading_date, count) + + # ------------------------------------------------------------ + # transit_slot + # ------------------------------------------------------------ + def transit_slot( + self, + slot_id: UUID, + from_state: SlotState, + to_state: SlotState, + reason: str, + actor: str, + *, + symbol: Optional[str] = None, + qty: Optional[float] = None, + avg_price: Optional[float] = None, + entry_price: Optional[float] = None, + allocated_capital: Optional[float] = None, + order_id: Optional[str] = None, + strategy_id: Optional[str] = None, + broker_order_id: Optional[str] = None, + portfolio_id: Optional[str] = None, + sell_strategy_id: Optional[str] = None, + forced_close_at: Optional[datetime] = None, + extra: Optional[dict[str, Any]] = None, + ) -> Slot: + """Transition slot from one state to another atomically. + + Within a single advisory-locked transaction: + 1. fetch slot + 2. verify current state == from_state (race detection) + 3. verify (from_state, to_state) is in the allowed set + 4. update slot row (fields per to_state semantics) + 5. append transition_log row + 6. on closed->idle, increment daily_symbol_entries (REQ-002 AC-3) + + External I/O (broker, kis-data-hub, portfolio-hub) MUST NOT be performed + inside this function — caller does I/O outside the lock (REQ-002 AC-5). + """ + if not is_allowed_transition(from_state, to_state): + raise SlotTransitionError( + f"transition not allowed: {from_state.value} -> {to_state.value}" + ) + + with self._repo.transactional_advisory_lock(SLOT_ADVISORY_LOCK_ID): + slot = self._repo.fetch_slot(slot_id) + if slot is None: + raise SlotNotFoundError(f"slot not found: {slot_id}") + + # Race detection — current state must match caller's from_state. + if slot.status is not from_state: + raise SlotStateMismatchError( + f"slot {slot_id} current state {slot.status.value} " + f"!= requested from_state {from_state.value}" + ) + + updated = self._apply_transition_fields( + slot, + to_state, + symbol=symbol, + qty=qty, + avg_price=avg_price, + entry_price=entry_price, + allocated_capital=allocated_capital, + order_id=order_id, + strategy_id=strategy_id, + broker_order_id=broker_order_id, + portfolio_id=portfolio_id, + sell_strategy_id=sell_strategy_id, + forced_close_at=forced_close_at, + ) + self._repo.update_slot(updated) + + # closed -> idle recovery: bump daily_symbol_entries atomically + # (REQ-002 AC-3, REQ-001 split-by-purpose). + if from_state is SlotState.CLOSED and to_state is SlotState.IDLE: + # Use the symbol carried by the slot before reset. + symbol_for_entry = slot.symbol + if symbol_for_entry: + self._repo.increment_symbol_entry(slot.trading_date, symbol_for_entry) + + self._repo.append_transition_log( + SlotTransitionRecord( + slot_id=slot_id, + from_state=from_state, + to_state=to_state, + ts=datetime.now(tz=timezone.utc), + reason=reason, + actor=actor, + extra=extra or {}, + ) + ) + return updated + + @staticmethod + def _apply_transition_fields( + slot: Slot, + to_state: SlotState, + *, + symbol: Optional[str], + qty: Optional[float], + avg_price: Optional[float], + entry_price: Optional[float], + allocated_capital: Optional[float], + order_id: Optional[str], + strategy_id: Optional[str], + broker_order_id: Optional[str] = None, + portfolio_id: Optional[str] = None, + sell_strategy_id: Optional[str] = None, + forced_close_at: Optional[datetime] = None, + ) -> Slot: + """Compute the post-transition Slot row. + + Special: closed -> idle resets occupancy fields (FR-2 #7). + Special: pending_buy -> idle resets occupancy (REQ-011 AC-6 rollback). + """ + if to_state is SlotState.IDLE and slot.status in { + SlotState.CLOSED, + SlotState.PENDING_BUY, + }: + # Recovery / rollback: clear occupancy + broker handle. + # transition_log retains audit trail. + return replace( + slot, + status=SlotState.IDLE, + symbol=None, + qty=None, + avg_price=None, + entry_price=None, + allocated_capital=None, + order_id=None, + strategy_id=None, + broker_order_id=None, + portfolio_id=None, + sell_strategy_id=None, + forced_close_at=None, + updated_at=datetime.now(tz=timezone.utc), + ) + + # Generic update: caller-supplied fields override; None means "keep". + return replace( + slot, + status=to_state, + symbol=symbol if symbol is not None else slot.symbol, + qty=qty if qty is not None else slot.qty, + avg_price=avg_price if avg_price is not None else slot.avg_price, + entry_price=entry_price if entry_price is not None else slot.entry_price, + allocated_capital=( + allocated_capital if allocated_capital is not None else slot.allocated_capital + ), + order_id=order_id if order_id is not None else slot.order_id, + strategy_id=strategy_id if strategy_id is not None else slot.strategy_id, + broker_order_id=( + broker_order_id if broker_order_id is not None else slot.broker_order_id + ), + portfolio_id=( + portfolio_id if portfolio_id is not None else slot.portfolio_id + ), + sell_strategy_id=( + sell_strategy_id + if sell_strategy_id is not None + else slot.sell_strategy_id + ), + forced_close_at=( + forced_close_at if forced_close_at is not None else slot.forced_close_at + ), + updated_at=datetime.now(tz=timezone.utc), + ) + + # ------------------------------------------------------------ + # get_today_entry_count + # ------------------------------------------------------------ + def get_today_entry_count(self, trading_date: date, symbol: str) -> int: + """Return today's entry_count for a symbol (0 if no entries yet). + + Used by buy decision state gate to enforce re-entry policy. + Reads daily_symbol_entries (decision input), NEVER queries + slot_transition_log (audit only). REQ-001, dcy-001 5.3.5.5. + """ + return self._repo.get_symbol_entry_count(trading_date, symbol) + + # ------------------------------------------------------------ + # compute_slot_capacity + # ------------------------------------------------------------ + def compute_slot_capacity(self, trading_date: date, total_slots: int) -> SlotCapacity: + """Return idle/holding/pending counts + scarcity_ratio (REQ-002 AC-6). + + scarcity_ratio = 1 - idle_count / total_slots, clamped to [0.0, 1.0]. + """ + if total_slots <= 0: + raise ValueError(f"total_slots must be > 0, got {total_slots}") + counts = self._repo.count_slots_by_status(trading_date) + idle = counts.get(SlotState.IDLE, 0) + holding = counts.get(SlotState.HOLDING, 0) + pending = counts.get(SlotState.PENDING_BUY, 0) + counts.get(SlotState.PENDING_SELL, 0) + scarcity = max(0.0, min(1.0, 1.0 - (idle / total_slots))) + return SlotCapacity( + idle_count=idle, + holding_count=holding, + pending_count=pending, + total_slots=total_slots, + scarcity_ratio=scarcity, + ) diff --git a/trex_core/domain/slot/state.py b/trex_core/domain/slot/state.py new file mode 100644 index 0000000..4373262 --- /dev/null +++ b/trex_core/domain/slot/state.py @@ -0,0 +1,64 @@ +"""Slot state machine: 6 states + 8 allowed transitions (REQ-001, REQ-002). + +The transitions match dcy-001 FR-2 and BAT-001 design. +""" +from __future__ import annotations + +from enum import Enum + + +# Shared PostgreSQL advisory lock id for Buy/Sell decision threads and +# operator CLI manual transitions. Single constant so all callers serialize +# through the same lock (REQ-002 NFR-1, dcy-001 R-1). +SLOT_ADVISORY_LOCK_ID: int = 0x5107_4D31 # arbitrary mnemonic constant + + +class SlotState(str, Enum): + """6-state slot lifecycle (dcy-001 5.3.5.1).""" + + IDLE = "idle" + PENDING_BUY = "pending_buy" + HOLDING = "holding" + PENDING_SELL = "pending_sell" + CLOSED = "closed" + ERROR = "error" + + +# 8 allowed transitions per dcy-001 FR-2. Any pair not in this set must be +# rejected by SlotService. +# Note: `* -> error` is represented as transitions from every non-error state. +_BASE_TRANSITIONS: frozenset[tuple[SlotState, SlotState]] = frozenset( + { + (SlotState.IDLE, SlotState.PENDING_BUY), # 1. buy decision + (SlotState.PENDING_BUY, SlotState.HOLDING), # 2. buy filled + (SlotState.PENDING_BUY, SlotState.IDLE), # 3. buy rejected/rollback + (SlotState.HOLDING, SlotState.PENDING_SELL), # 4. sell decision + (SlotState.PENDING_SELL, SlotState.CLOSED), # 5. sell filled + (SlotState.PENDING_SELL, SlotState.HOLDING), # 6. sell rejected/rollback + (SlotState.CLOSED, SlotState.IDLE), # 7. recovery (with daily_symbol_entries +1) + } +) + +# 8th transition family: any non-error state may move to error (operator manual +# resolve uses `error -> idle | holding` which is also covered explicitly). +_TO_ERROR_TRANSITIONS: frozenset[tuple[SlotState, SlotState]] = frozenset( + (s, SlotState.ERROR) for s in SlotState if s is not SlotState.ERROR +) + +# Operator manual resolve transitions out of error (REQ-004). +_FROM_ERROR_TRANSITIONS: frozenset[tuple[SlotState, SlotState]] = frozenset( + { + (SlotState.ERROR, SlotState.IDLE), + (SlotState.ERROR, SlotState.HOLDING), + } +) + + +ALLOWED_TRANSITIONS: frozenset[tuple[SlotState, SlotState]] = ( + _BASE_TRANSITIONS | _TO_ERROR_TRANSITIONS | _FROM_ERROR_TRANSITIONS +) + + +def is_allowed_transition(from_state: SlotState, to_state: SlotState) -> bool: + """Return True iff (from_state, to_state) is one of the 8 transition families.""" + return (from_state, to_state) in ALLOWED_TRANSITIONS