Source code for GUIBRUSHR.Retrieval.debug_log
"""
Shared JSONL debug logger for the Retrieval pipeline.
Used by both ``ExofastMCMC.exofast_demc`` (parent MCMC driver) and
``ModelCalculation.ModelData.parallel_chain`` (parallel workers) so that
high-level events (setup, convergence, finalization) and low-level events
(per-core proposal / accept / chain_end) land in a single chronologically
ordered file suitable for offline analysis.
Concurrency
-----------
Multiprocessing workers append independently. Under POSIX a single
``write()`` call whose buffer is smaller than ``PIPE_BUF`` (4096 B on
Linux) is atomic — so by keeping each JSON line under ~3800 bytes we
avoid interleaved writes without any lock.
Robustness
----------
``emit_event`` never raises: a broken logger must never kill the MCMC.
"""
import json
import os
import time
from pathlib import Path
_DEBUG_LOG_MAX_LINE_BYTES = 3800 # headroom below POSIX PIPE_BUF (4096)
[docs]
def get_log_path(model_obj):
"""Return the JSONL debug file path for this retrieval, or None.
Args:
model_obj: any object exposing ``retrieval_data.path_results``.
Returns:
pathlib.Path or None.
"""
try:
base = model_obj.retrieval_data.path_results
except Exception:
return None
if not base:
return None
return Path(base) / "debug" / "retrieval_debug.jsonl"
[docs]
def init_log(path, header):
"""Create the parent dir, truncate the file, write a header line."""
if path is None:
return
try:
p = Path(path)
p.parent.mkdir(parents=True, exist_ok=True)
with p.open("w", encoding="utf-8") as f:
header = dict(header)
header.setdefault("ts", time.time())
header.setdefault("pid", os.getpid())
f.write(json.dumps(header, default=str, ensure_ascii=False) + "\n")
except Exception:
pass
def _serialize_record(record):
"""Render one event dict to a single JSON line respecting the PIPE_BUF cap."""
record.setdefault("ts", time.time())
record.setdefault("pid", os.getpid())
line = json.dumps(record, default=str, ensure_ascii=False) + "\n"
if len(line.encode("utf-8")) > _DEBUG_LOG_MAX_LINE_BYTES:
safe = {}
for k, v in record.items():
try:
length = len(v)
except TypeError:
length = 0
if length > 8 and hasattr(v, "__iter__"):
safe[k] = f"<len={length}>"
else:
safe[k] = v
safe["_truncated"] = True
line = json.dumps(safe, default=str, ensure_ascii=False) + "\n"
return line
[docs]
def emit_event(path, record):
"""Append one JSON line to ``path`` atomically (POSIX, <3800 B).
Args:
path: str/Path of the JSONL log file, or None to disable.
record: dict to serialize. 'ts' and 'pid' auto-filled if missing.
"""
if path is None:
return
try:
line = _serialize_record(record)
with open(str(path), "a", encoding="utf-8") as f:
f.write(line)
except Exception:
pass
[docs]
class BufferedLogger:
"""In-memory batch of JSON events, flushed on demand.
Use this to decouple ``emit`` frequency from disk I/O: the parent MCMC
driver accumulates events during a burnin block and flushes once at the
distribution-save checkpoint (every 10 outer_steps). Workers use a
short-lived instance and flush once before exiting their outer_step.
Thread/process safety
---------------------
Not shared across processes. Each process owns its own buffer. The single
flush call is one ``write()`` per line under ``open(..., "a")``, so the
POSIX <PIPE_BUF atomicity guarantee still holds line-by-line — workers
flushing concurrently with the parent do not interleave within a line.
"""
__slots__ = ("_lines",)
[docs]
def __init__(self):
self._lines: list[str] = []
[docs]
def emit(self, record):
"""Append a record to the in-memory buffer. Never raises."""
try:
self._lines.append(_serialize_record(record))
except Exception:
pass
def __len__(self):
return len(self._lines)
[docs]
def flush(self, path):
"""Write all buffered lines to ``path`` (append mode) and clear.
If ``path`` is None or the write fails, the buffer is cleared anyway
to prevent unbounded memory growth on long runs.
"""
if not self._lines:
return
lines = self._lines
self._lines = []
if path is None:
return
try:
with open(str(path), "a", encoding="utf-8") as f:
f.writelines(lines)
except Exception:
pass