- purge_outdated — 다른 판 · 소스 지문은 안 읽고 남김 · STALE_DAYS 30 지난 파일만 미리 셈 때 지움(두 PC · 여러 워크트리가 storage 같이 씀) - drop_pool_results([재계산]) · prune_stored(종류별 두 벌) — 지금 판 · 지문 파일만 - test_route_initial — 시험마다 _POOLS 비움(autouse) · test_weight_ends_match_branches xfail(엔진 pick 정밀 · 빠른 판 섞어 견줌 · 419 에서 풂) - 시험 420_1 — 다른 지문 남음 · 30일 지난 것 지움 · 재계산 · 정리에서 다른 지문 안 건드림 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy
278 lines
11 KiB
Python
278 lines
11 KiB
Python
"""계획노선 추천 결과 영구 저장(PLAN 327 · 요구 224-3).
|
|
|
|
같은 입력이면 같은 결과 — 셈한 추천을 입력 지문 키로 프로젝트 폴더에 남기고, 같은 입력이 오면
|
|
셈하지 않고 꺼낸다. 바뀐 입력만 새로 셈해 더한다(덮어쓰기 없음 · 종류마다 최근 `KEEP_PER_KIND` 벌).
|
|
|
|
층 = 서버 계산 캐시(데이터 3층 밖 · 사용자 편집값 아님 — 자동저장 금지와 무관). 자리
|
|
`B05_Profile/route_recommend/<키>.json` — initial_snapshot 대상(`B05_Profile/route`) 밖이라
|
|
[초기화]에 안 휩쓸리고, 자료 교체(`purge_output_folders`) · 프로젝트 삭제 때 폴더째 지워진다.
|
|
|
|
키 = 후보 묶음 지문(`_pool_key` — 지형 · 예상노선 · 계류 · 상한 · 최소 R · 이격 · 고름 · 목표
|
|
기울기) + 갈래 · 비중 · 종단 반영 · 설계속도 · 표준 횡단면 · `ALGO_VERSION` · 엔진 소스 지문. 관
|
|
지점은 뺀다 — 덮개 수는 꺼낼 때 다시 센다. 추천 결과를 바꾸는 코드 고침은 `ALGO_VERSION` 을
|
|
올린다 — 빠뜨려도 엔진 소스 지문이 바뀌어 새로 셈. 다른 판 · 지문 파일은 안 읽고 남기며 30일 지난
|
|
것만 미리 셈 때 지운다(420-2 · storage 를 두 PC · 여러 워크트리가 같이 씀).
|
|
기본값 · 저장된 제약 결과(`default`)는 「최근 벌」을 따로 센다(351-1).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import functools
|
|
import hashlib
|
|
import json
|
|
import time
|
|
from contextlib import asynccontextmanager
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import Any, AsyncIterator
|
|
|
|
from B05_Profile.B05_Profile_Engine_RouteInitial import (
|
|
DEFAULT_WEIGHTS,
|
|
Terrain,
|
|
_pool_key,
|
|
resolve_inputs,
|
|
resolve_weights,
|
|
)
|
|
from common_util.common_util_json import atomic_write_json
|
|
|
|
# 3 = 곡선 MC 일반측점(351-2) · 4 = 다투는 곡선 끔(364-5) · 5 = 후보마다 맞춤(383-1) ·
|
|
# 6 = 같은 쪽 곡선 합치기 · 거리 한도 대신 직접 잼(406) · 7 = 측점 밖이 늘면 옛 차례 맞춤(406-4) ·
|
|
# 8 = 횡단 토공 · 표준 횡단면 = 저장 패널값 · 엔진 소스 지문(420)
|
|
ALGO_VERSION = 8
|
|
# 엔진 소스 지문에 드는 파일(저장소 뿌리 기준) — 추천 결과를 낳는 코드
|
|
SOURCE_GLOBS = (
|
|
"B05_Profile/B05_Profile_Engine_RouteInitial*.py",
|
|
"B05_Profile/B05_Profile_Engine_Sections_Core.py",
|
|
"B06_Section/B06_Section_Engine_Design*.py",
|
|
"common_util/common_util_route_polyline.py",
|
|
)
|
|
STORE_DIR = "B05_Profile/route_recommend"
|
|
# 종류(갈래 · 지형) 마다 남길 벌 수 — 꺼낼 때 수정시각을 새로 해 최근에 쓴 것이 남음
|
|
KEEP_PER_KIND = 2
|
|
# 다른 판 · 소스 지문 저장본을 이 날수 지나면 미리 셈 때 지움(420-2)
|
|
STALE_DAYS = 30
|
|
# 업로드 체인 끝에 미리 셈할 기본 다섯 알약(화면 차례) · 추적은 묶음이 선 뒤라 `similar` 가 채워짐
|
|
DEFAULT_MODES = ("gradeline", "earthwork", "gentle", "weighted", "follow")
|
|
|
|
# 묶음 지문마다 잠금 — 진행 중 같은 묶음(같은 키 포함)은 기다렸다 꺼냄(두 번 셈 막음)
|
|
_LOCKS: dict[str, asyncio.Lock] = {}
|
|
# 묶음 지문마다 그 묶음을 짤 때 표준 횡단면(`forget_pool_on_standard`)
|
|
_POOL_STANDARDS: dict[str, str] = {}
|
|
|
|
|
|
def store_dir(project_root: Path) -> Path:
|
|
return Path(project_root) / STORE_DIR
|
|
|
|
|
|
@functools.cache
|
|
def source_fingerprint() -> str:
|
|
"""엔진 소스 지문 — `SOURCE_GLOBS` 파일 내용(줄끝 LF 로 맞춤 · 두 PC 같은 코드 = 같은 지문)."""
|
|
root = Path(__file__).resolve().parents[1]
|
|
digest = hashlib.blake2b(digest_size=8)
|
|
for path in sorted({p for pattern in SOURCE_GLOBS for p in root.glob(pattern)}):
|
|
digest.update(path.name.encode())
|
|
digest.update(path.read_bytes().replace(b"\r\n", b"\n"))
|
|
return digest.hexdigest()
|
|
|
|
|
|
def request_fingerprint(
|
|
expected: list[tuple[float, float]],
|
|
terrain: Terrain,
|
|
streams: list[list[tuple[float, float]]],
|
|
*,
|
|
mode: str,
|
|
weights: dict[str, float] | None,
|
|
max_grade: float,
|
|
min_radius_m: float,
|
|
stream_offset_m: float | None,
|
|
grade_smooth_m: float | None,
|
|
target_grade: float | None,
|
|
with_profile: bool,
|
|
design_speed: int,
|
|
standard: dict[str, Any] | None = None,
|
|
) -> tuple[str, str]:
|
|
"""(저장 키, 묶음 지문) — `generate_initial_route` 와 같은 기본값 풀이로 같은 입력 = 같은 키."""
|
|
pool = _pool_key(
|
|
expected,
|
|
terrain,
|
|
streams,
|
|
max_grade,
|
|
min_radius_m,
|
|
*resolve_inputs(max_grade, stream_offset_m, grade_smooth_m, target_grade),
|
|
).hex()
|
|
used = resolve_weights(mode, weights)
|
|
tail = (
|
|
mode,
|
|
sorted(used.items()),
|
|
bool(with_profile),
|
|
int(design_speed),
|
|
json.dumps(standard, sort_keys=True, default=str) if standard else None,
|
|
ALGO_VERSION,
|
|
source_fingerprint(),
|
|
)
|
|
key = hashlib.blake2b((pool + repr(tail)).encode(), digest_size=16).hexdigest()
|
|
return key, pool
|
|
|
|
|
|
def is_default(
|
|
mode: str,
|
|
weights: dict[str, float] | None,
|
|
criteria: dict[str, Any] | None,
|
|
terrain: Any,
|
|
saved: dict[str, Any] | None = None,
|
|
) -> bool:
|
|
"""지킬 결과 — 기본값(빈 제약 · 50/25/25 · 설계 지형) 또는 저장된 제약(`initial_choice`) 그대로.
|
|
|
|
모달이 여는 값이라 「최근 2벌」 정리에서 따로 센다(351-1 · 사용자 시도에 안 밀림).
|
|
"""
|
|
if terrain is not None:
|
|
return False
|
|
filled = {key: value for key, value in (criteria or {}).items() if value is not None}
|
|
used = resolve_weights(mode, weights)
|
|
if not filled and (mode != "weighted" or used == DEFAULT_WEIGHTS):
|
|
return True
|
|
saved = saved or {}
|
|
return filled == (saved.get("criteria") or {}) and (
|
|
mode != "weighted" or used == saved.get("weights")
|
|
)
|
|
|
|
|
|
def _current(data: Any) -> bool:
|
|
return (
|
|
isinstance(data, dict)
|
|
and data.get("version") == ALGO_VERSION
|
|
and data.get("source") == source_fingerprint()
|
|
)
|
|
|
|
|
|
def read_stored(project_root: Path, key: str) -> dict[str, Any] | None:
|
|
"""저장본 한 벌 — 없거나 못 읽거나 판 · 소스 지문이 다르면 None."""
|
|
path = store_dir(project_root) / f"{key}.json"
|
|
try:
|
|
data = json.loads(path.read_text(encoding="utf-8"))
|
|
except (OSError, ValueError):
|
|
return None
|
|
if not _current(data):
|
|
return None
|
|
try:
|
|
path.touch() # 최근에 쓴 것 = 정리 때 남김
|
|
except OSError:
|
|
pass
|
|
return data
|
|
|
|
|
|
def write_stored(
|
|
project_root: Path,
|
|
key: str,
|
|
pool: str,
|
|
request: dict[str, Any],
|
|
result: dict[str, Any],
|
|
default: bool,
|
|
) -> bool:
|
|
"""새 키만 적는다 — 이미 있으면 그대로 두고 False(덮어쓰기 없음)."""
|
|
path = store_dir(project_root) / f"{key}.json"
|
|
if path.exists():
|
|
return False
|
|
atomic_write_json(
|
|
path,
|
|
{
|
|
"version": ALGO_VERSION,
|
|
"source": source_fingerprint(),
|
|
"key": key,
|
|
"pool": pool,
|
|
"default": default,
|
|
"created_at": datetime.now().isoformat(timespec="seconds"),
|
|
"request": request,
|
|
"result": result,
|
|
},
|
|
)
|
|
prune_stored(project_root, request, default)
|
|
return True
|
|
|
|
|
|
def _kind(request: Any, default: Any) -> tuple[Any, Any, bool]:
|
|
request = request if isinstance(request, dict) else {}
|
|
return request.get("mode"), request.get("terrain"), bool(default)
|
|
|
|
|
|
def prune_stored(project_root: Path, request: dict[str, Any], default: bool = False) -> int:
|
|
"""같은 종류(갈래 · 지형 · 기본값 여부) 저장본을 최근 `KEEP_PER_KIND` 벌만 남기고 지운다.
|
|
|
|
기본값 · 저장된 제약 결과(`default`)는 따로 셈 — 사용자 시도가 밀어내지 않음(351-1). 같은 판 ·
|
|
소스 지문 안에서만 셈 — 다른 지문(다른 PC · 워크트리 코드) 파일은 안 건드림(420-2). 지운 수.
|
|
"""
|
|
kind = _kind(request, default)
|
|
same: list[tuple[int, Path]] = []
|
|
for path in store_dir(project_root).glob("*.json"):
|
|
try:
|
|
data = json.loads(path.read_text(encoding="utf-8"))
|
|
if _current(data) and _kind(data.get("request"), data.get("default")) == kind:
|
|
same.append((path.stat().st_mtime_ns, path))
|
|
except (OSError, ValueError, AttributeError):
|
|
continue
|
|
same.sort(reverse=True)
|
|
for _, path in same[KEEP_PER_KIND:]:
|
|
path.unlink(missing_ok=True)
|
|
return max(0, len(same) - KEEP_PER_KIND)
|
|
|
|
|
|
def _remove_where(project_root: Path, drop: Any) -> int:
|
|
"""저장본 중 `drop(data, path)` 가 참인 파일을 지운다(못 읽는 파일 = data None) — 지운 수."""
|
|
removed = 0
|
|
for path in store_dir(project_root).glob("*.json"):
|
|
try:
|
|
data = json.loads(path.read_text(encoding="utf-8"))
|
|
except (OSError, ValueError):
|
|
data = None
|
|
if drop(data, path):
|
|
path.unlink(missing_ok=True)
|
|
removed += 1
|
|
return removed
|
|
|
|
|
|
def purge_outdated(project_root: Path, now: float | None = None) -> int:
|
|
"""다른 판 · 소스 지문 저장본 중 `STALE_DAYS` 지난 것만 지운다(미리 셈 첫머리 · 420-2).
|
|
|
|
storage 를 두 PC · 여러 워크트리가 같이 써 코드가 다르면 지문이 다름 — 새것은 안 읽고 둠.
|
|
지운 수."""
|
|
limit = (time.time() if now is None else now) - STALE_DAYS * 86400.0
|
|
|
|
def stale(data: Any, path: Path) -> bool:
|
|
try:
|
|
return not _current(data) and path.stat().st_mtime < limit
|
|
except OSError:
|
|
return False
|
|
|
|
return _remove_where(project_root, stale)
|
|
|
|
|
|
def drop_pool_results(project_root: Path, pool: str) -> int:
|
|
"""[재계산] — 이 후보 묶음(다섯 갈래 · 종단 켬끔)의 저장본을 지우고 메모리 묶음도 버린다.
|
|
|
|
`pool_lock(pool)` 안에서 부름(미리 셈이 다시 써 버리지 않게) · 지금 판 · 소스 지문 것만
|
|
(다른 지문 = 다른 PC · 워크트리 것 · 420-2). 지운 수."""
|
|
from B05_Profile.B05_Profile_Engine_RouteInitial import _POOLS
|
|
|
|
_POOLS.pop(bytes.fromhex(pool), None)
|
|
return _remove_where(
|
|
project_root, lambda data, _path: _current(data) and data.get("pool") == pool
|
|
)
|
|
|
|
|
|
def forget_pool_on_standard(pool: str, standard: dict[str, Any] | None) -> None:
|
|
"""메모리 묶음 후보에 붙은 균형 종단은 그때 표준 횡단면으로 셈 — 바뀌면 묶음을 버린다."""
|
|
from B05_Profile.B05_Profile_Engine_RouteInitial import _POOLS
|
|
|
|
mark = json.dumps(standard, sort_keys=True, default=str)
|
|
if _POOL_STANDARDS.get(pool, mark) != mark:
|
|
_POOLS.pop(bytes.fromhex(pool), None)
|
|
_POOL_STANDARDS[pool] = mark
|
|
|
|
|
|
@asynccontextmanager
|
|
async def pool_lock(pool: str) -> AsyncIterator[None]:
|
|
# ponytail: 잠금은 지우지 않음 — 입력 묶음 수만큼(작음) · 다중 프로세스면 파일 잠금으로
|
|
async with _LOCKS.setdefault(pool, asyncio.Lock()):
|
|
yield
|