Skip to content

Commit d548f25

Browse files
feat: store estimation inputs for re-estimation [code:qws] [system:MiyabiG]
1 parent 5d3c26e commit d548f25

8 files changed

Lines changed: 272 additions & 14 deletions

File tree

‎docs/cx/REESTIMATION_SPEC.md‎

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,19 @@ In the current implementation, the following already exist:
164164
- a distinction between `requested_estimation_package` and the actually applied `estimation_package`
165165
- final-state applicability recording through `applicable`, `partially_applicable`, `fallback`, and `not_applicable`
166166

167+
現時点では、re-estimation で再取得できるのは source result JSON までであり、
168+
server 側に保存された detailed estimation-input artifact の復元はまだ未実装である。
169+
そのため現状では、
170+
171+
- artifact 不要な lightweight 再推定は成立しうる
172+
- artifact を要求する detailed 再推定は `not_applicable` で終わりうる
173+
174+
At present, re-estimation can re-fetch the source result JSON itself, but restoration of stored detailed estimation-input artifacts is not yet implemented.
175+
Therefore:
176+
177+
- lightweight or artifact-free re-estimation may succeed
178+
- detailed re-estimation that depends on missing artifacts may terminate as `not_applicable`
179+
167180
## 7. 現行実装と仕様のギャップ / Gaps Between Current Implementation and the Intended Specification
168181

169182
### 7.1 UUID 取得 API の仕様 / UUID-Based Result Retrieval API
@@ -262,6 +275,23 @@ Re-estimation in BenchKit should preferably satisfy at least:
262275
4. different estimation methods can coexist for the same benchmark result
263276
5. lightweight and detailed estimation can be compared along the same comparison axis
264277
6. insufficient inputs can be reported explicitly as not applicable, fallback, or re-measurement required
278+
7. detailed re-estimation should be able to restore estimation-input artifacts associated with the source result
279+
280+
## 8.1 estimation input artifact の復元 / Restoration of Estimation Input Artifacts
281+
282+
当面の再推定では、artifact 復元を次の流れで扱う。
283+
284+
1. `estimate_result_uuid` から開始する場合は、estimate JSON から `source_result_uuid` を解決する
285+
2. `result_uuid` から開始する場合は、そのまま source result JSON を取得する
286+
3. `received_estimation_inputs/<result-stem>/` が存在する場合は、その内容を `results/estimation_inputs/` に復元する
287+
4. server 側に estimation input artifact が無い場合は、artifact 不要な推定のみを許可し、必要な推定は `not_applicable` とする
288+
289+
Near-term restoration should follow this flow:
290+
291+
1. if starting from `estimate_result_uuid`, resolve `source_result_uuid` from the estimate JSON
292+
2. fetch the source result JSON
293+
3. if `received_estimation_inputs/<result-stem>/` exists, restore its contents into `results/estimation_inputs/`
294+
4. if no stored estimation inputs exist, allow only methods that do not require them; otherwise terminate as `not_applicable`
265295

266296
## 9. 次の実装候補 / Next Implementation Candidates
267297

@@ -276,5 +306,6 @@ Candidate next steps include:
276306

277307
1. specify and verify the UUID-based estimate/result retrieval APIs
278308
2. document the retrieval endpoint and authentication conditions for re-estimation
279-
3. define a display specification for comparing re-estimation results using `source_result_uuid`
280-
4. define a portal-driven request flow for starting re-estimation
309+
3. persist estimation-input artifacts under `received_estimation_inputs/` and restore them during re-estimation fetch
310+
4. define a display specification for comparing re-estimation results using `source_result_uuid`
311+
5. define a portal-driven request flow for starting re-estimation

‎result_server/app.py‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,13 +79,19 @@ def create_app(prefix="", base_dir=None):
7979
base_issuer = os.environ.get("TOTP_ISSUER", "BenchKit")
8080
app.config["TOTP_ISSUER"] = f"{base_issuer}-Dev" if prefix == "/dev" else base_issuer
8181

82-
# make dir: received & estimated_results
82+
# make dir: received, received_padata, received_estimation_inputs & estimated_results
8383
received_dir = os.path.join(base_dir, "received")
84+
received_padata_dir = os.path.join(base_dir, "received_padata")
85+
received_estimation_inputs_dir = os.path.join(base_dir, "received_estimation_inputs")
8486
estimated_dir = os.path.join(base_dir, "estimated_results")
8587
os.makedirs(received_dir, exist_ok=True)
88+
os.makedirs(received_padata_dir, exist_ok=True)
89+
os.makedirs(received_estimation_inputs_dir, exist_ok=True)
8690
os.makedirs(estimated_dir, exist_ok=True)
8791

8892
app.config["RECEIVED_DIR"] = received_dir
93+
app.config["RECEIVED_PADATA_DIR"] = received_padata_dir
94+
app.config["RECEIVED_ESTIMATION_INPUTS_DIR"] = received_estimation_inputs_dir
8995
app.config["ESTIMATED_DIR"] = estimated_dir
9096

9197
# Register route blueprints

‎result_server/app_dev.py‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,8 @@ def setup_dev_environment(base_dir):
2828
os.environ["DEV_MODE"] = "1"
2929

3030
# 必要なディレクトリを作成
31-
for sub in ["main/received", "main/estimated_results", "main/flask_session",
32-
"dev1/received", "dev1/estimated_results", "dev1/flask_session"]:
31+
for sub in ["main/received", "main/received_padata", "main/received_estimation_inputs", "main/estimated_results", "main/flask_session",
32+
"dev1/received", "dev1/received_padata", "dev1/received_estimation_inputs", "dev1/estimated_results", "dev1/flask_session"]:
3333
os.makedirs(os.path.join(base_dir, sub), exist_ok=True)
3434

3535

@@ -133,11 +133,17 @@ def create_dev_app(base_dir):
133133
app.config["TOTP_ISSUER"] = f"{os.environ.get('TOTP_ISSUER', 'BenchKit')}-Local"
134134

135135
received_dir = os.path.join(base_dir, "main", "received")
136+
received_padata_dir = os.path.join(base_dir, "main", "received_padata")
137+
received_estimation_inputs_dir = os.path.join(base_dir, "main", "received_estimation_inputs")
136138
estimated_dir = os.path.join(base_dir, "main", "estimated_results")
137139
os.makedirs(received_dir, exist_ok=True)
140+
os.makedirs(received_padata_dir, exist_ok=True)
141+
os.makedirs(received_estimation_inputs_dir, exist_ok=True)
138142
os.makedirs(estimated_dir, exist_ok=True)
139143

140144
app.config["RECEIVED_DIR"] = received_dir
145+
app.config["RECEIVED_PADATA_DIR"] = received_padata_dir
146+
app.config["RECEIVED_ESTIMATION_INPUTS_DIR"] = received_estimation_inputs_dir
141147
app.config["ESTIMATED_DIR"] = estimated_dir
142148

143149
# results_loaderはcurrent_app.configから取得するため、モジュール変数の書き換え不要

‎result_server/routes/api.py‎

Lines changed: 112 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,14 @@
1212
POST /upload-tgz → ingest_padata
1313
"""
1414

15-
from flask import Blueprint, request, abort, current_app, jsonify
15+
from flask import Blueprint, request, abort, current_app, jsonify, send_file
1616
import os
1717
import json
1818
import re
1919
import uuid
2020
import shutil
21+
import io
22+
import tarfile
2123
from datetime import datetime
2224

2325
api_bp = Blueprint("api", __name__)
@@ -124,6 +126,38 @@ def _load_json_by_uuid(directory, field_path, uuid_value):
124126
return None
125127

126128

129+
def _find_result_file_by_uuid(received_dir, uuid_value):
130+
json_files = sorted(
131+
[f for f in os.listdir(received_dir) if f.endswith(".json")],
132+
reverse=True,
133+
)
134+
135+
for json_file in json_files:
136+
path = os.path.join(received_dir, json_file)
137+
try:
138+
with open(path, "r", encoding="utf-8") as f:
139+
data = json.load(f)
140+
except Exception:
141+
continue
142+
143+
if data.get("_server_uuid") == uuid_value or uuid_value in json_file:
144+
return json_file, path, data
145+
146+
return None, None, None
147+
148+
149+
def _safe_extract_tar_bytes(file_storage, target_dir):
150+
with tarfile.open(fileobj=file_storage.stream, mode="r:*") as tar:
151+
for member in tar.getmembers():
152+
normalized = os.path.normpath(member.name)
153+
if os.path.isabs(normalized) or normalized.startswith(".."):
154+
abort(400, description="Unsafe archive entry")
155+
try:
156+
tar.extractall(target_dir, filter="data")
157+
except TypeError:
158+
tar.extractall(target_dir)
159+
160+
127161
# ==========================================
128162
# 新パス: /api/ingest/*
129163
# ==========================================
@@ -172,7 +206,7 @@ def ingest_padata():
172206
if not uploaded_file:
173207
abort(400, description="No file uploaded")
174208

175-
received_dir = current_app.config["RECEIVED_DIR"]
209+
received_dir = current_app.config["RECEIVED_PADATA_DIR"]
176210

177211
matched_files = [
178212
f for f in os.listdir(received_dir)
@@ -204,6 +238,43 @@ def ingest_padata():
204238
}, 200
205239

206240

241+
@api_bp.route("/api/ingest/estimation-inputs", methods=["POST"])
242+
def ingest_estimation_inputs():
243+
"""Estimation input archive (tgz) upload and expansion."""
244+
require_api_key()
245+
246+
uuid_str = request.form.get("id")
247+
if not uuid_str or not is_valid_uuid(uuid_str):
248+
abort(400, description="Invalid or missing UUID")
249+
250+
uploaded_file = request.files.get("file")
251+
if not uploaded_file:
252+
abort(400, description="No file uploaded")
253+
254+
received_dir = current_app.config["RECEIVED_DIR"]
255+
result_filename, _, _ = _find_result_file_by_uuid(received_dir, uuid_str)
256+
if not result_filename:
257+
abort(404, description=f"No result found for uuid={uuid_str}")
258+
259+
result_stem = os.path.splitext(result_filename)[0]
260+
inputs_root = current_app.config["RECEIVED_ESTIMATION_INPUTS_DIR"]
261+
target_dir = os.path.join(inputs_root, result_stem)
262+
replaced = os.path.isdir(target_dir)
263+
if replaced:
264+
shutil.rmtree(target_dir)
265+
os.makedirs(target_dir, exist_ok=True)
266+
267+
_safe_extract_tar_bytes(uploaded_file, target_dir)
268+
269+
print(f"Saved estimation inputs: {target_dir}", flush=True)
270+
return {
271+
"status": "uploaded",
272+
"id": uuid_str,
273+
"directory": result_stem,
274+
"replaced": replaced,
275+
}, 200
276+
277+
207278
# ==========================================
208279
# Query API: /api/query/*
209280
# ==========================================
@@ -285,6 +356,45 @@ def query_result():
285356
abort(404, description=f"No result found for system={system}, code={code}, exp={exp}")
286357

287358

359+
@api_bp.route("/api/query/estimation-inputs", methods=["GET"])
360+
def query_estimation_inputs():
361+
"""Return estimation input artifacts for a result UUID as a tar.gz archive."""
362+
require_api_key()
363+
364+
uuid_value = request.args.get("uuid")
365+
if not uuid_value or not is_valid_uuid(uuid_value):
366+
abort(400, description="Invalid UUID")
367+
368+
result_filename, _, _ = _find_result_file_by_uuid(
369+
current_app.config["RECEIVED_DIR"], uuid_value
370+
)
371+
if not result_filename:
372+
abort(404, description=f"No result found for uuid={uuid_value}")
373+
374+
result_stem = os.path.splitext(result_filename)[0]
375+
source_dir = os.path.join(
376+
current_app.config["RECEIVED_ESTIMATION_INPUTS_DIR"], result_stem
377+
)
378+
if not os.path.isdir(source_dir):
379+
abort(404, description=f"No estimation inputs found for uuid={uuid_value}")
380+
381+
buffer = io.BytesIO()
382+
with tarfile.open(fileobj=buffer, mode="w:gz") as tar:
383+
for entry in sorted(os.listdir(source_dir)):
384+
tar.add(
385+
os.path.join(source_dir, entry),
386+
arcname=entry,
387+
)
388+
buffer.seek(0)
389+
390+
return send_file(
391+
buffer,
392+
mimetype="application/gzip",
393+
as_attachment=True,
394+
download_name=f"estimation_inputs_{result_stem}.tgz",
395+
)
396+
397+
288398
@api_bp.route("/api/query/estimate", methods=["GET"])
289399
def query_estimate():
290400
"""UUID で単一 Estimate JSON を返す。"""

0 commit comments

Comments
 (0)