Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,13 @@ mkdocs serve

Web env vars: `DATALAB_WEB_SECRET` (**required in production** — a missing secret
is a hard failure; tests/dev set `DATALAB_DEBUG=1` to get a random key instead),
`DATALAB_HOST`, `DATALAB_PORT`, `DATALAB_DEBUG`.
`DATALAB_HOST`, `DATALAB_PORT`, `DATALAB_DEBUG`. Behind a trusted reverse proxy,
set `DATALAB_TRUST_PROXY_HEADERS=1` (wraps the app in werkzeug `ProxyFix` so the
per-IP SSE rate limiter sees the real client IP); `DATALAB_SSE_DISABLE_RATE_LIMIT`
turns that limiter off (dev only). LaTeX sandbox limits (`app_web/latex_security.py`):
`DATALAB_LATEX_TIMEOUT`, `DATALAB_LATEX_MAX_CPU`, `DATALAB_LATEX_MAX_MEM`,
`DATALAB_LATEX_MAX_FILE`, `DATALAB_LATEX_MAX_PROC` — see `docs/web/deploy.en.md`
for defaults and meanings.

## Architecture: layered, one core → two frontends

Expand Down
12 changes: 4 additions & 8 deletions app_desktop/fitting_latex_writer.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,6 @@ def latex_escape(text: str) -> str:
return "".join(mapping.get(ch, ch) for ch in str(text))


def _latex_escape_text(value: str) -> str:
return latex_escape(value)


def build_fit_latex_preamble(*, use_dcolumn: bool, digits: int, latex_group_size: int) -> list[str]:
group_size = max(1, int(latex_group_size))
lines = [
Expand Down Expand Up @@ -262,9 +258,9 @@ def _format_diagnostic_value(value: object) -> str:
implicit_equation = str(fit_result.details.get("equation") or "").strip()
implicit_output = str(fit_result.details.get("output_expression") or "").strip()
if implicit_equation:
lines.append(f"Implicit equation: \\texttt{{{_latex_escape_text(implicit_equation)}}}\\\\")
lines.append(f"Implicit equation: \\texttt{{{latex_escape(implicit_equation)}}}\\\\")
if implicit_output:
lines.append(f"Implicit output: \\texttt{{{_latex_escape_text(implicit_output)}}}\\\\")
lines.append(f"Implicit output: \\texttt{{{latex_escape(implicit_output)}}}\\\\")

cleaned_expr = (expression or "").strip().replace("**", "^")
cleaned_sub = cleaned_substituted
Expand All @@ -279,7 +275,7 @@ def _format_diagnostic_value(value: object) -> str:
solver_details: list[str] = []
optimizer_backend = fit_result.details.get("optimizer_backend") or fit_result.details.get("optimizer")
if optimizer_backend:
solver_details.append(f"Solver: \\texttt{{{_latex_escape_text(str(optimizer_backend))}}}")
solver_details.append(f"Solver: \\texttt{{{latex_escape(str(optimizer_backend))}}}")
if "scipy_safety_passed" in fit_result.details:
status = "passed" if bool(fit_result.details.get("scipy_safety_passed")) else "not used"
solver_details.append(f"SciPy precision check: {status}")
Expand Down Expand Up @@ -328,7 +324,7 @@ def _format_diagnostic_value(value: object) -> str:
)
for key, val, unit in table_rows:
if include_unit_column:
lines.append(f"{_format_key(key)} & {_latex_escape_text(unit)} & {val} \\\\")
lines.append(f"{_format_key(key)} & {latex_escape(unit)} & {val} \\\\")
else:
lines.append(f"{_format_key(key)} & {val} \\\\")
lines.extend(
Expand Down
19 changes: 10 additions & 9 deletions app_desktop/window.py
Original file line number Diff line number Diff line change
Expand Up @@ -1363,14 +1363,14 @@ def _on_stats_mode_change(self):
widget = getattr(self, name, None)
if widget is not None and hasattr(widget, "setVisible"):
widget.setVisible(is_bootstrap)
method = (
self.stats_time_series_method_combo.currentData()
if hasattr(self, "stats_time_series_method_combo")
else None
)
for name in ("stats_sigma_column_edit", "stats_sigma_column_label"):
widget = getattr(self, name, None)
if widget is not None and hasattr(widget, "setVisible"):
method = (
self.stats_time_series_method_combo.currentData()
if hasattr(self, "stats_time_series_method_combo")
else None
)
widget.setVisible(
(not is_bootstrap and not is_hypothesis and not is_time_series and not is_matrix)
or (is_time_series and method == "rolling_mean")
Expand All @@ -1384,11 +1384,12 @@ def _on_stats_mode_change(self):
widget = getattr(self, name, None)
if widget is not None and hasattr(widget, "setVisible"):
widget.setVisible(not is_hypothesis and not is_time_series)
trim_visible = (not is_bootstrap and mode == "descriptive") or (
is_bootstrap and bootstrap_target == "trimmed_mean"
trim_visible = (
((not is_bootstrap and mode == "descriptive") or (is_bootstrap and bootstrap_target == "trimmed_mean"))
and not is_hypothesis
and not is_time_series
and not is_matrix
)
trim_visible = trim_visible and not is_hypothesis and not is_time_series
trim_visible = trim_visible and not is_matrix
if hasattr(self, "stats_trim_fraction_edit"):
self.stats_trim_fraction_edit.setVisible(trim_visible)
if hasattr(self, "stats_trim_fraction_label"):
Expand Down
17 changes: 6 additions & 11 deletions app_desktop/window_fitting_mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,12 @@
The original ``WindowFittingMixin`` was a 1852-line monolith
covering parameter editing, model dispatch, worker orchestration,
result rendering, residual plotting, and LaTeX output. Phase 7
split it by responsibility into 4 sibling files so each unit fits
split it by responsibility into sibling files so each unit fits
in a single editor view (~200-700 lines):

- ``window_fitting_formatters_mixin.py`` — pure(-ish) formatters
used by every other concern (substituted-expression rendering,
result text, CSV row builder, LaTeX preamble + table block).
- ``window_fitting_params_mixin.py`` — the dynamic parameter-row
table (add / remove / extract).
- ``window_fitting_residuals_mixin.py`` — post-compute side:
worker-result handlers, fit-plot rendering, residual-diff view,
LaTeX file output, batch-result re-formatting.
Expand All @@ -25,14 +23,13 @@
natural call flow (left = "called more often", right = "called by
others"):

Params (leaf — no calls into other mixins)
Residuals (calls Formatters for display)
Models (calls Params, calls Formatters)
Formatters (called by all three above; rightmost so a future
override in any of the above can shadow a formatter
Models (calls Formatters)
Formatters (called by both above; rightmost so a future
override in either of the above can shadow a formatter
cleanly — none do today)

All four mixins live INSIDE this shim's inheritance chain, so
All three mixins live INSIDE this shim's inheritance chain, so
``app_desktop/window.py`` continues to inherit only
``WindowFittingMixin``. This file remains the public entry point;
existing imports
Expand All @@ -43,20 +40,18 @@

from .window_fitting_formatters_mixin import WindowFittingFormattersMixin
from .window_fitting_models_mixin import WindowFittingModelsMixin
from .window_fitting_params_mixin import WindowFittingParamsMixin
from .window_fitting_residuals_mixin import WindowFittingResidualsMixin

__all__ = ["WindowFittingMixin"]


class WindowFittingMixin(
WindowFittingParamsMixin,
WindowFittingResidualsMixin,
WindowFittingModelsMixin,
WindowFittingFormattersMixin,
):
"""Composed mixin that delegates every method to one of the
four split files. Defined as an empty subclass so existing
split files. Defined as an empty subclass so existing
imports (``from app_desktop.window_fitting_mixin import
WindowFittingMixin``) keep working without any caller changes.
"""
Expand Down
12 changes: 0 additions & 12 deletions app_desktop/window_fitting_params_mixin.py

This file was deleted.

17 changes: 1 addition & 16 deletions datalab_core/statistics.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
analysis_rows_to_json,
)
from .session import check_cancelled
from .statistics_helpers import _bool_option, _string_option
from .statistics_bootstrap import (
BOOTSTRAP_PAYLOAD_SCHEMA,
BOOTSTRAP_WORKFLOW_MODE,
Expand Down Expand Up @@ -2056,14 +2057,6 @@ def _parse_mpf(value: Any, *, field_name: str) -> mp.mpf:
raise ValueError(f"{field_name} is not a valid number: {value!r}.") from exc


def _string_option(value: Any, *, default: str, field_name: str) -> str:
if value is None:
return default
if not isinstance(value, str):
raise ValueError(f"{field_name} must be a string.")
return value.strip() or default


def _optional_numeric_string_option(value: Any, *, field_name: str) -> str | None:
if value is None:
return None
Expand All @@ -2072,14 +2065,6 @@ def _optional_numeric_string_option(value: Any, *, field_name: str) -> str | Non
return value


def _bool_option(value: Any, *, default: bool) -> bool:
if value is None:
return default
if not isinstance(value, bool):
raise ValueError("boolean statistics options must be booleans.")
return value


def _optional_int_option(value: Any, *, field_name: str) -> int | None:
if value is None:
return None
Expand Down
9 changes: 1 addition & 8 deletions datalab_core/statistics_grouped.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from .jobs import ComputeJobRequest, JobMode, JobOptions
from .results import AnalysisRow, ResultStatus, analysis_rows_from_json
from .session import check_cancelled
from .statistics_helpers import _bool_option

GROUPED_WORKFLOW_MODE = "grouped_statistics"
GROUPED_RESULT_CACHE_KIND = "statistics_grouped"
Expand Down Expand Up @@ -753,14 +754,6 @@ def _text_option(value: Any, *, default: str, field_name: str) -> str:
return value.strip() or default


def _bool_option(value: Any, *, default: bool) -> bool:
if value is None:
return default
if not isinstance(value, bool):
raise TypeError(_dual_msg("分组统计的布尔选项必须是布尔值。", "grouped statistics boolean options must be booleans."))
return value


def _diagnostic(
code: str,
message: str,
Expand Down
30 changes: 30 additions & 0 deletions datalab_core/statistics_helpers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
"""Shared option-parsing helpers for the statistics service modules.

These strict parsers reject non-bool / non-string inputs. Frontend payloads
already normalize checkbox and text controls to real booleans and strings
(see ``app_web/logic/common._is_checked`` and the desktop mixin's
``bool(...)`` / ``str(...)`` coercions), so no production caller relies on the
older string-variant acceptance (``"true"``/``"1"``).
"""

from __future__ import annotations

from typing import Any

from shared.bilingual import _dual_msg


def _bool_option(value: Any, *, default: bool) -> bool:
if value is None:
return default
if not isinstance(value, bool):
raise ValueError(_dual_msg("布尔统计选项必须是布尔值。", "boolean statistics options must be booleans."))
return value


def _string_option(value: Any, *, default: str, field_name: str) -> str:
if value is None:
return default
if not isinstance(value, str):
raise ValueError(_dual_msg(f"{field_name} 必须是字符串。", f"{field_name} must be a string."))
return value.strip() or default
15 changes: 1 addition & 14 deletions datalab_core/statistics_matrix.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from shared.unit_annotations import first_unit_annotation_text

from ._payload import normalize_json_payload
from .statistics_helpers import _bool_option

MATRIX_WORKFLOW_MODE = "covariance_correlation"
MATRIX_RESULT_CACHE_KIND = "statistics_matrix"
Expand Down Expand Up @@ -650,20 +651,6 @@ def _choice(value: Any, choices: set[str], *, field_name: str, default: str | No
return text


def _bool_option(value: Any, *, default: bool) -> bool:
if value is None or value == "":
return default
if isinstance(value, bool):
return value
if isinstance(value, str):
lowered = value.strip().lower()
if lowered in {"1", "true", "yes", "on"}:
return True
if lowered in {"0", "false", "no", "off"}:
return False
raise TypeError(_dual_msg("布尔选项必须是布尔值或布尔字符串。", "boolean option must be a boolean or boolean string."))


def _format_mpf(value: mp.mpf, precision_digits: int) -> str:
return str(mp.nstr(value, n=max(2, precision_digits), strip_zeros=False))

Expand Down
22 changes: 1 addition & 21 deletions datalab_core/statistics_time_series.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from ._payload import normalize_json_payload
from .results import AnalysisRow, analysis_rows_from_json
from .statistics_compute import type7_quantile
from .statistics_helpers import _bool_option, _string_option
from shared.bilingual import _dual_msg
from shared.precision import precision_guard
from shared.unit_annotations import first_unit_annotation_text, normalize_display_only_family_units
Expand Down Expand Up @@ -683,27 +684,6 @@ def _positive_int_option(value: Any, *, default: int, field_name: str) -> int:
return parsed


def _bool_option(value: Any, *, default: bool) -> bool:
if value in (None, ""):
return default
if isinstance(value, bool):
return value
text = str(value).strip().lower()
if text in {"1", "true", "yes", "on"}:
return True
if text in {"0", "false", "no", "off"}:
return False
raise ValueError(_dual_msg(f"无效的布尔值:{value!r}。", f"Invalid boolean value: {value!r}."))


def _string_option(value: Any, *, default: str, field_name: str) -> str:
if value is None:
return default
if not isinstance(value, str):
raise TypeError(_dual_msg(f"{field_name} 必须是文本。", f"{field_name} must be text."))
return value.strip() or default


def _optional_text(value: Any) -> str | None:
if value is None:
return None
Expand Down
30 changes: 30 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,36 @@ A TeX distribution providing `pdflatex` or `xelatex` is required for PDF export.
> *frontend-glue bridge* (a LaTeX generator that consumes both core results and
> `datalab_latex` renderers), not compute — its name predates the layering split.

### `datalab_core/` service layer

> This section postdates the original guide: the `datalab_core/` package is the
> UI-neutral service/model boundary between the computation modules above and the
> two frontends. See the root `CLAUDE.md` ("Architecture" §2) for the layering
> rules — this is a concise map, not a duplicate.

- **`service_factory.create_core_session_service(...)`** builds a
`session.SessionService` pre-populated with the migrated core handlers (one per
`JobMode`).
- **`jobs.JobMode`** enumerates the five job kinds — `EXTRAPOLATION`,
`UNCERTAINTY`, `STATISTICS`, `FITTING`, `ROOT_SOLVING`. Callers submit a
`ComputeJobRequest` (with a `mode`) via `SessionService.submit(request)`, which
dispatches to the handler registered for that mode and returns a
`ResultEnvelope`.
- **Handlers** are the `run_*` functions (`run_extrapolation`, `run_uncertainty`,
`run_statistics`, `run_fitting`, `run_root_solving`) wired in
`service_factory._CORE_HANDLER_REGISTRY`; each lives in its same-named module
(`datalab_core/extrapolation.py`, `fitting.py`, …) and depends only on the
computation modules, never on a frontend.
- **Workspaces & recipes** — `workspace_v2.py` (schema `datalab.workspace.v2`,
persisted to `.datalab` files) and `recipes.py` (schema `datalab.recipe.v1`)
are the serialization/replay layer. History/compare, report bundles, the
uncertainty budget, and the extended statistics modules
(`statistics_{bootstrap,grouped,hypothesis,matrix,time_series}.py`) also live
here.
- Both frontends call into this layer rather than the computation modules
directly. Cooperative cancellation flows through
`session.external_cancellation_scope` / `check_cancelled`.

### Cross-cutting conventions (follow these)

- **Bilingual messages**: user-facing errors and labels use `_dual_msg(zh, en)` returning `"汉语 / English"`. The locale layer splits on `" / "` to extract the active language. Don't return single-language strings from new code paths.
Expand Down
Loading
Loading