diff --git a/backend/app/engine/median.py b/backend/app/engine/median.py index 97a43a4..15c7996 100644 --- a/backend/app/engine/median.py +++ b/backend/app/engine/median.py @@ -5,12 +5,22 @@ Computes the element-wise median of (vinf, a, b, c, d, e) across all active vote This parametric median is chosen over geometric median because: - It's transparent and politically explainable - The result is itself a valid set of Bézier parameters + +Post-processing: the median is sanitized to guarantee monotonicity of tier 2. +The condition for tier 2 to be non-decreasing is e <= 1/3. +If the raw median violates this, e is clamped to E_MAX_MONOTONE. """ import numpy as np from dataclasses import dataclass +# Maximum value of e that guarantees tier-2 price monotonicity. +# prix_m3_2 = p0 + (pmax-p0) * ((1-3e)t³ + 3e t²) +# The cubic is monotone non-decreasing on [0,1] iff (1-3e) >= 0, i.e. e <= 1/3. +E_MAX_MONOTONE = 1.0 / 3.0 + + @dataclass class VoteParams: """The 6 citizen-adjustable parameters.""" @@ -22,23 +32,49 @@ class VoteParams: e: float +def _is_tier2_monotone(e: float) -> bool: + """Check if tier-2 price curve is monotone non-decreasing.""" + return e <= E_MAX_MONOTONE + + +def sanitize_median(params: "VoteParams") -> "VoteParams": + """ + Ensure the median curve is physically valid: + - Tier 2 must be monotone non-decreasing (penalizes high consumption) + - If e violates monotonicity, clamp it to E_MAX_MONOTONE + """ + e = params.e + if not _is_tier2_monotone(e): + e = E_MAX_MONOTONE + + return VoteParams( + vinf=params.vinf, + a=params.a, + b=params.b, + c=params.c, + d=params.d, + e=e, + ) + + def compute_median(votes: list[VoteParams]) -> VoteParams | None: """ Compute element-wise median of vote parameters. Returns None if no votes provided. + The result is sanitized for tier-2 monotonicity. """ if not votes: return None vinfs = [v.vinf for v in votes] - a_s = [v.a for v in votes] - b_s = [v.b for v in votes] - c_s = [v.c for v in votes] - d_s = [v.d for v in votes] - e_s = [v.e for v in votes] + a_s = [v.a for v in votes] + b_s = [v.b for v in votes] + c_s = [v.c for v in votes] + d_s = [v.d for v in votes] + e_s = [v.e for v in votes] - return VoteParams( + raw = VoteParams( vinf=float(np.median(vinfs)), a=float(np.median(a_s)), b=float(np.median(b_s)), @@ -46,3 +82,5 @@ def compute_median(votes: list[VoteParams]) -> VoteParams | None: d=float(np.median(d_s)), e=float(np.median(e_s)), ) + + return sanitize_median(raw) \ No newline at end of file diff --git a/docker-compose.jetson.yml b/docker-compose.jetson.yml new file mode 100644 index 0000000..cae8e6d --- /dev/null +++ b/docker-compose.jetson.yml @@ -0,0 +1,47 @@ +# docker-compose.jetson.yml +# Ports décalés (8001/3001) pour ne pas entrer en conflit +# avec les services existants sur le Jetson. +# Nginx fait le reverse proxy depuis sejeteraleau.nicolasboyer.com + +services: + + backend: + build: + context: . + dockerfile: docker/backend.Dockerfile + target: production + environment: + DATABASE_URL: sqlite+aiosqlite:////data/sejeteralo.db + SECRET_KEY: CHANGEZ-MOI-cle-longue-et-aleatoire-32-chars-min + DEBUG: "false" + CORS_ORIGINS: '["https://sejeteraleau.nicolasboyer.com"]' + ports: + - "127.0.0.1:8010:8000" + volumes: + - backend-data:/data + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/api/health"] + interval: 30s + timeout: 5s + retries: 3 + + frontend: + build: + context: . + dockerfile: docker/frontend.Dockerfile + target: production + args: + # URL vue depuis le navigateur du visiteur + NUXT_PUBLIC_API_BASE: https://sejeteraleau.nicolasboyer.com/api/v1 + environment: + NUXT_PUBLIC_API_BASE: https://sejeteraleau.nicolasboyer.com/api/v1 + PORT: "3000" + ports: + - "127.0.0.1:3010:3000" + depends_on: + - backend + restart: unless-stopped + +volumes: + backend-data: