diff --git a/AGENTS.md b/AGENTS.md index f3ea997..d400ba2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,7 +34,7 @@ through an explicitly authorized, explicitly scoped human ticket: - F0 — PASS/FROZEN - Phase H v1 — PASS/FROZEN - MVS-M1 — PASS/FROZEN -- Project DB v22 — PASS/FROZEN +- Project DB v22 scientific/persistence foundation — PASS/FROZEN - Calibration Bootstrap v1 — PASS/FROZEN - Selected Scientific Execution — PASS/FROZEN - Photo Quality Triage / Acquisition Selection — PASS/FROZEN @@ -42,14 +42,26 @@ through an explicitly authorized, explicitly scoped human ticket: - S2 Capture-safe Standard Ingestion — PASS/FROZEN - S3 Capture / Acquisition Ingestion — PASS/FROZEN - Durable Acquisition-Campaign Execution — PASS/FROZEN +- Global Maintenance Audit — PASS/FROZEN Detailed subcontracts remain defined by their canonical documents. This file does not duplicate every S3 substage, scientific threshold, migration detail, or persistence format. -Historical references to older Project DB versions remain valid when they -describe the actual historical contract or migration path. Do not rewrite -legitimate v16/v17/v18/v19 history merely because v22 is current. +Project DB v23 is the current additive optical-context overlay. It preserves +the v22 scientific/persistence foundation and must not infer or backfill optical +identity from historical data. Historical references to older Project DB +versions remain valid when they describe the actual historical contract or +migration path. Do not rewrite legitimate v16–v22 history merely because v23 +is current. + +The global maintenance implementation, fresh portable/Vulkan/sanitizer/ +concurrency validation and independent final review are acquired. Its lifecycle +is `GLOBAL_MAINTENANCE_AUDIT=PASS/FROZEN`. The review independently passed the +portable build, 64/64 complete suite, 15/15 focused matrix, 76/76 strict header +probes across 19 modified/new public headers, ABI and production-seam checks, +retained-manifest verification and diff validation, with zero blocking findings. +Do not reopen this boundary or infer a new scientific policy from the freeze. When a ticket declares `NO_NEW_SUBSYSTEM`, do not introduce an unrelated: @@ -130,6 +142,11 @@ require a clean worktree unless the ticket explicitly requires one. termination semantics. - If a public function may be retried, its idempotency or conflict behavior must be explicit where non-obvious. +- Sparse SfM relative-pose and PnP `max_iterations`/`minimum_inliers` remain + fixed-width `uint32_t` scientific fields, but their public OpenCV boundary is + operationally limited to `INT_MAX`. Reject a larger value before narrowing, + allocation, solver execution or output mutation; do not alter the FROZEN + defaults, encodings or fingerprints to accommodate an unsafe cast. Compiler success alone is not proof of API, ABI, persistence, or scientific contract correctness. @@ -173,6 +190,21 @@ explicitly defines such an identity. to canonical architecture. - Keep ncurses on its designated/main thread wherever the canonical contract requires it. +- The current TUI is a validated operational observatory/control center. Keep + Queue/Task/host observation coalesced and bounded, keep durable scientific + progress distinct from generic runtime percentage, and keep unknown + provenance visibly UNKNOWN. Full layout starts at 100x30, compact is + supported through 60x15 (72x20 is the reference compact boundary), and only + the bounded "Terminal trop petit" fallback is allowed below that minimum. +- Preserve the contextual key contract. `F10 SSD` remains literally visible at + 60 columns in idle, text-input and import-running modes; text input owns only + Enter/Escape/F10 and a running import owns only cancel (`X`) and F10 while + quit/Escape are visibly disabled. ncurses input and rendering remain on the + main thread. +- Opening, closing, or switching a project is a Queue/DB lifetime boundary: + destroy and join the sole Queue, including finished callbacks, before Project + DB close; then recreate one empty Queue and rebind observers. Never sample + running/pending counts as a substitute for that boundary. - Extend validated abstractions rather than rewriting validated modules. - Reuse the existing Task / Queue / Scheduler / Resource Governor ownership model rather than creating parallel runtime infrastructure. @@ -203,9 +235,29 @@ Resource-sensitive work must identify, where relevant: - cancellation cleanup; - whether the bound is operational or scientific. -External USB SSD scratch/swap support remains a planned resource-management -capability. Do not implement ad-hoc mounting, formatting, `swapon`, scratch -ownership, or device cleanup outside an explicitly authorized ticket. +The reviewed external USB SSD controller is the authorized physical-lifecycle +boundary for the exact UDisks Drive/label/UUID contract. Its validated snapshot +is registered with the Resource Governor; the Governor wrappers are the sole +production orchestrator for scratch-lease acquire/release. The controller does +not replace the Governor or invent Task scratch eligibility, and swap/scratch +never become RAM. The application lifetime order is strict: destroy/join the +Queue so every Task lease is released, checked-join/unregister the SSD binding, +destroy the controller, then destroy the Governor. The current fourteen Task +kinds have no scratch consumer, so availability is capability, not fabricated +usage. Do not add ad-hoc discovery, mounting, formatting, `swapon`, cleanup, +force-drain, shell commands, or a second resource/scheduling subsystem outside +the reviewed controller/Governor APIs. + +Snapshot conversion is fail-closed by physical state. Any pairing or authority +requires current detection of the Drive and both UUID-bearing partitions, +positive known partition extents, and coherent mount/activity/capability facts; +partial `DETECTED` state is observable but non-actionable. A disconnected sticky +hazard may retain identity only as non-allocating `ERROR`, and drain authority +requires the exact reconnected original tuple. Controller generation +`UINT64_MAX` is legal saturation: arbitrary equal-generation public updates +remain stale and cannot regrant authority, while only the serialized Governor +lease wrapper may reconcile its own exact completion and address-backed lease +count at that watermark. ## 7. Code quality and readability @@ -552,8 +604,8 @@ Persistence changes require explicit attention to: For Project DB: -- preserve current v22 semantics unless a ticket explicitly authorizes a schema - change; +- preserve the FROZEN v22 semantics and the current additive v23 optical + overlay unless a ticket explicitly authorizes a later schema change; - schema-version changes require explicit human authorization; - migrations must be additive unless a different migration is explicitly authorized; @@ -578,9 +630,11 @@ make retries convenient. behavior. - Roadmap documents may describe future behavior, but future capabilities must be clearly marked as planned/later/exploratory. -- Never describe future viewer, Capture Guidance, video/keyframe, SSD scratch, - or camera-control capabilities as implemented before they are actually - validated. +- Never describe future viewer, Capture Guidance, video/keyframe, Task scratch + consumption, or camera-control capabilities as implemented before they are + actually validated. The current TUI/F10 and controller-to-Governor registry + are validated operationally, but no current Task kind consumes scratch and + those interfaces do not make dense/scratch-consuming workflows complete. - Statuses such as `PLANNED`, `IMPLEMENTED`, `VALIDATION PENDING`, and `PASS/FROZEN` are authoritative lifecycle statements. - Update lifecycle state only when implementation, validation, and review diff --git a/README.md b/README.md index 40ca957..05f55b2 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # Lardon3D -Moteur de reconstruction géométrique persistante et incrémentale, piloté par une TUI ncursesw. +Moteur de photogrammétrie générique, persistant, incrémental et sensible aux +ressources, piloté par une TUI ncursesw. ## Vision @@ -35,9 +36,10 @@ persistante, enrichissable et versionnable. - **Task** : moteur de tâches avec pause/reprise, annulation et séquences - **Task Checkpoint v1** : snapshot durable, protocole `.chk.next` → SQLite → `.chk` sous verrou par tâche, et reprise sûre -- **Project Database v22** : fondations v20/v21 préservées, avec snapshot - d'exécution sélectionnée, publication bornée des représentations, attache de - scope de calibration et tâche durable mince `raw.develop` — PASS / FROZEN +- **Project Database v23** : overlay optique additif au-dessus de la fondation + v22 PASS / FROZEN ; profils de boîtier et d'objectif, configurations + body+lens+focale, affectations campagne/Capture et calibrations exactement + compatibles, sans inférence ni backfill — IMPLEMENTED / VALIDATED / REVIEWED - **[Photo Quality Triage](docs/architecture/photo_quality_triage.md)** : métriques JPEG - **[Calibration Bootstrap v1](docs/architecture/calibration_bootstrap.md)** : import borné d'une calibration optimisée et traçable avant le Sparse SfM à @@ -73,9 +75,21 @@ persistante, enrichissable et versionnable. - **Hardware Profile** : détection des capacités matérielles - **Resource Snapshot** : capture instantanée des ressources - **Resource Governor** : arbitrage centralisé des budgets et réservations +- **TUI observatoire / centre de contrôle** : modèle de vue pur et borné, + observation coalescée, progression durable/ETA honnête, écrans Tasks, + Resources, Optique et SSD, avec ncurses exclusivement sur le thread principal + — CURRENT / VALIDATED OPERATIONAL +- **Contrôleur SSD externe optionnel** : frontière physique UDisks2/GDBus, + identité Drive+labels+UUIDs, drain sûr et capacités de contrôle exactes ; son + état physique est enregistré auprès du Governor, seul orchestrateur des + leases scratch de production — CURRENT / VALIDATED OPERATIONAL ### Intégration réelle validée +Sony A6000 et Samsung S21 FE sont des preuves de validation de la chaîne +générique. Ils ne définissent ni l'identité produit, ni un profil caméra +hardcodé, ni une limite de CPU ou de dataset. + - **Intégration multi-campagne A6000 + S21 FE Engine Bay** : PASS — les plans réels A6000 (953 paires confirmées `CALLER_EXPLICIT`) et Samsung SM-G990B (3544 JPEG singleton) ont été validés dans deux ScanSets d'un même projet @@ -89,8 +103,8 @@ persistante, enrichissable et versionnable. ### Plus tard / différé -- publication durable dense/mesh et scratch SSD externe optionnel gouverné ; -- workflow TUI de confirmation/progression ; +- publication durable dense/mesh et consommation Task explicite du scratch + SSD optionnel ; - vidéo/keyframes et **Capture Guidance / Live Coverage** : analyse et viewer de couverture, suggestions de prises de vue puis assistance live, après reconstruction mature ; @@ -102,11 +116,11 @@ persistante, enrichissable et versionnable. ```text TUI / Projet ↓ -Scheduler +Task Queue bornée (un worker, ordre/backpressure) ↓ -Resource Governor +Resource Governor (admission et réservation) ↓ -Workers +Task callback admis (parallélisme interne borné si prouvé) ↓ Résultats atomiques / persistants ↓ @@ -116,12 +130,37 @@ Viewer (consommation passive de snapshots) ### Invariants fondamentaux - Aucun callback de tâche sans réservation active validée -- Le scheduler ne décide jamais des ressources +- La Queue/runtime ne décide jamais des ressources - Le Resource Governor est l'unique propriétaire des budgets - ncurses appartient exclusivement au thread principal - Les estimations de ressources sont immuables - Les buffers et files sont strictement bornés +### TUI opérationnelle + +La TUI sépare le modèle de vue pur du rendu ncurses. Son observateur copie au +plus 129 entrées Queue (64 pending, une active, 64 historiques) et coalesce les +captures hôte autour d'une seconde ; aucun scan DB ou `/proc` volumineux n'a +lieu par frame. La progression scientifique exacte provient seulement des +compteurs durables typés. Le taux EWMA et l'ETA restent « calcul » jusqu'à deux +intervalles positifs, excluent le préfixe repris et deviennent explicitement +indéterminés, stalled ou throttled lorsque l'évidence l'exige. + +Les tailles supportées sont 100×30 et plus en vue complète, 72×20 en compacte +de référence, et jusqu'au minimum 60×15 ; en dessous, seul « Terminal trop +petit » est affiché. Les couleurs ont toujours un équivalent textuel/bold/dim. +`F1` à `F7` ouvrent aide, projets, import, viewer futur, tâches, ressources et +optique. Le segment littéral `F10 SSD` reste visible à 60 colonnes et déclenche +uniquement l'action autorisée par le contrôleur. Pendant une saisie, seules +Enter, Échap et F10 sont actives ; pendant un import, seules `X` et F10 le sont, +et quitter/retour accueil sont explicitement désactivés. + +Ouvrir, fermer ou changer de projet détruit et joint d'abord l'unique Queue, +callbacks terminaux inclus, puis ferme Project DB et recrée une Queue vide. Le +workflow optique utilise les alias metadata exacts, accepte normalement les +objectifs manuels sans EXIF, crée des profils/configurations immuables et exige +une affectation/sélection de calibration explicite et exactement compatible. + ## Pipeline cible ```text @@ -150,7 +189,7 @@ Acquisitions - [Resource Governor](docs/architecture/resource_governor.md) - [Parallélisme interne borné](docs/architecture/internal_parallelism.md) - [Pipeline sensible aux ressources](docs/architecture/resource_aware_pipeline.md) -- [Intégration Scheduler ↔ Governor](docs/architecture/scheduler_resource_integration.md) +- [Intégration Queue/runtime ↔ Governor](docs/architecture/scheduler_resource_integration.md) - [Pipeline de reconstruction](docs/architecture/reconstruction_pipeline.md) - [Persistance](docs/architecture/persistence.md) - [Base de données projet](docs/architecture/project_database.md) @@ -167,7 +206,8 @@ Acquisitions - [Backend Vulkan ORB](docs/architecture/vulkan_matcher.md) - [Viewer](docs/architecture/viewer.md) - [Resource Boundary — No New Resource Subsystem](docs/architecture/resource_boundary.md) -- [Revue des fondations](docs/architecture/foundation_review.md) +- [Audit global de maintenance — état consolidé](docs/architecture/global_maintenance_audit.md) +- [Revue historique des fondations](docs/architecture/foundation_review.md) ### Concepts - [Scan Sets](docs/concepts/scan_sets.md) @@ -223,17 +263,32 @@ les paramètres dans `L3DMDID2` v2 (220 octets). Chaque appel utilise un espace travail privé neuf sous le staging appelant, sans réemploi d'une scène, profondeur, cache ou sortie antérieure. Le DAG, le viewer et les autres étapes denses restent des tickets séparés planifiés. -Project DB v22, `raw.develop` et Calibration Bootstrap v1 sont **PASS / -FROZEN** : la suite normale 53/53, les contrôles syntaxiques C17, `git diff ---check`, la validation ciblée ASan/UBSan et l'audit final ont passé. La suite -ASan/UBSan complète demeure qualifiée par le comportement LSan du pilote tiers -RADV ; elle n'est pas présentée comme un PASS complet du dépôt. Les campagnes +La fondation Project DB v22, `raw.develop` et Calibration Bootstrap v1 reste +**PASS / FROZEN**. La tête courante v23 ajoute seulement le contexte optique +générique : neuf tables, migration transactionnelle sans backfill, objectifs +manuels sans EXIF, configurations multiples par campagne et sélection de +calibration exactement compatible. Les migrations de copies réelles S21/A6000 +ont conservé leurs lignes scientifiques et laissé l'overlay vide. Les campagnes réelles S21 et A6000 Engine Bay sont `CALIBRATION_UNAVAILABLE` par non-identifiabilité scientifique des données de calibration connues ; le Sparse SfM réel reste `BLOCKED_BY_KNOWN_CALIBRATION_DATA`, sans pseudo-calibration ni import inféré. -Le Resource Governor ne constitue pas un Resource System générique : voir la décision -d’architecture. +Le Resource Governor ne constitue pas un Resource System générique : il reste +l'unique propriétaire des budgets et le seul orchestrateur des leases scratch +de production. Le contrôleur SSD UDisks2 est une frontière physique séparée, +jamais un second scheduler ou Governor. Les quatorze Task kinds actuels ne +consomment encore aucun scratch : l'espace disponible est une capacité +observable, pas un usage fabriqué, et scratch/swap ne deviennent jamais de la +RAM. La TUI/F10 et cette intégration sont validées opérationnellement. L'audit +global est désormais `GLOBAL_MAINTENANCE_AUDIT=PASS/FROZEN` : après les builds +Clang frais portable/Vulkan, les suites normales 64/64 et 65/65, les sanitizers +applicables, TSan et les contrôles ABI, l'unique revue finale indépendante +GPT-5.6 SOL/ULTRA a conclu PASS sans finding bloquant. Elle a indépendamment +rejoué le build portable, la suite 64/64, une matrice focalisée 15/15 et les +76/76 probes strictes C17/C++17 couvrant 19 headers publics modifiés/nouveaux, +ainsi que l'ABI, les négatifs de seams production, le SHA du manifest GV et le +diff-check. Ce gel clôt la gate de maintenance ; il n'exécute pas à lui seul la +tranche scientifique suivante. ## Licence diff --git a/docs/architecture/candidate_pair.md b/docs/architecture/candidate_pair.md index 7179029..f1278a6 100644 --- a/docs/architecture/candidate_pair.md +++ b/docs/architecture/candidate_pair.md @@ -238,18 +238,21 @@ registry production et resoumise à la queue. Les anciens snapshots v1 produits avec l'estimation opérationnelle exacte 128 Kio fixes, 64 Kio par item, lot 1–64, CPU 1, IO 1 et GPU 0 sont normalisés -éphémèrement par la registry à la forme courante CPU12 avant admission. Le +éphémèrement par la registry à la forme courante CPU64 avant admission. La +forme historique immédiatement précédente CPU12/256 Kio fixes/64 Kio par item +est elle aussi reconnue exactement. Le snapshot durable original reste la source du reconstructeur ; aucun checkpoint d'estimation seule n'est stagé, promu ou publié sous le même résumé. Une panne pré-terminale répète donc cette normalisation exacte. Aucun autre snapshot, curseur ou paramètre scientifique n'est réinterprété. -### Intégration scheduler +### Intégration Task/Queue/Governor -La tâche utilise le scheduler générique via le pattern standard : -- Estimation opérationnelle (256 Kio fixes, 64 Kio par item, lot 1–64). - La Queue conserve un callback ; jusqu'à douze participants CPU admis peuvent - calculer une fenêtre interne bornée sans modifier l'identité scientifique. +La tâche utilise le runtime générique via le pattern standard : +- Estimation opérationnelle (256 Kio fixes, 8 Mio par item, lot 1–64). + La Queue conserve un callback ; jusqu'à soixante-quatre participants CPU + admis peuvent calculer une fenêtre interne bornée sans modifier l'identité + scientifique. - Réservation CPU + IO avant exécution - `lardon3d_task_sequence_break()` entre chaque lot pour réadmission Governor - Callback terminal checkpoint après `COMPLETED`/`FAILED`/`CANCELLED` @@ -365,7 +368,7 @@ canonicalisation, idempotence, batch projet, fingerprint et réutilisation/invalidation. **IMPLEMENTED** — tâche durable `candidate_pair.generate` v1 -via le scheduler générique, avec estimation immuable, +via le runtime/Queue générique, avec estimation immuable, checkpoint par curseur, reprise idempotente et intégration dans la registry production. diff --git a/docs/architecture/feature_store.md b/docs/architecture/feature_store.md index 19d34c0..a057acb 100644 --- a/docs/architecture/feature_store.md +++ b/docs/architecture/feature_store.md @@ -28,13 +28,12 @@ porte sur les 24 octets canoniques `L3DORBP1`, version et trois entiers little-endian. Il ne dépend ni du padding, ni de la locale. Le contrat ORB v1 produit 32 octets binaires par point. -Au démarrage, avant la création des workers, Lardon3D configure la limite de -threads interne process-wide d'OpenCV à -`min(12, logical_cpu_count - system_cpu_reserve)`. La borne douze est -opérationnelle et correspond au plafond audité. `features.extract` demande -jusqu'à douze threads au Resource Governor ; celui-ci réduit l'admission au -budget hôte, donc à la limite OpenCV configurée. La Queue conserve un callback actif ; aucun pool Lardon3D -supplémentaire n'est créé pour ORB. +Au démarrage, avant la création du worker Queue, Lardon3D configure la baseline +OpenCV process-wide depuis le compute-pool réel. `features.extract` publie la +borne positive `int` acceptée par OpenCV ; le Resource Governor réduit toujours +l'admission au budget hôte. Douze threads demeure une cohorte de validation, +pas un plafond portable. La Queue conserve un callback actif ; aucun pool +Lardon3D supplémentaire n'est créé pour ORB. Cette limite est une configuration opérationnelle, jamais un paramètre scientifique : elle n'entre ni dans le fingerprint ORB v1, ni dans l'identité diff --git a/docs/architecture/foundation_review.md b/docs/architecture/foundation_review.md index ffcf0df..f77cf74 100644 --- a/docs/architecture/foundation_review.md +++ b/docs/architecture/foundation_review.md @@ -1,4 +1,11 @@ -# Revue des fondations Lardon3D +# Revue historique des fondations Lardon3D + +Ce document conserve l'état de la revue de fondation à son époque. Les limites +« absence de persistance », tombstones et tickets recommandés ci-dessous ne +décrivent plus l'état courant. Pour l'architecture active, voir +[Task](task_system.md), [Queue](task_queue.md), +[Resource Governor](resource_governor.md) et le +[registre de maintenance](global_maintenance_audit.md). ## Objectif @@ -51,7 +58,7 @@ Documenter la revue technique de la phase de fondation : task, task_queue, hardw 6. Protection mutex unique du gouverneur 7. Séparation stricte des rôles (scheduler ne décide pas des ressources) -## Limites connues +## Limites connues lors de cette revue historique - File à worker unique avec FIFO strict - Absence de notification automatique de libération externe @@ -69,7 +76,7 @@ Documenter la revue technique de la phase de fondation : task, task_queue, hardw - Bornage de la mémoire - Indépendance de la publication atomique -## Feuille de route +## Feuille de route historique (désormais supersédée) ### Prochains tickets recommandés 1. Sélectionner une tâche admissible sans blocage par la tête de file ✓ @@ -87,4 +94,4 @@ Documenter la revue technique de la phase de fondation : task, task_queue, hardw - TSan passé - git diff --check propre -## Statut : DOCUMENTATION DE L'IMPLÉMENTATION ACTUELLE +## Statut : ARCHIVE D'ÉVIDENCE HISTORIQUE diff --git a/docs/architecture/geometric_verifier.md b/docs/architecture/geometric_verifier.md index d17f93d..c213673 100644 --- a/docs/architecture/geometric_verifier.md +++ b/docs/architecture/geometric_verifier.md @@ -13,7 +13,8 @@ les paramètres, l'ordre et l'acceptance v1, mais ajoute avant USAC le support m observations canoniques distinctes décrit ci-dessous. V3 est la policy de production courante : après les mêmes validations intégrales, elle compose ce support v2 avec la preuve exacte de faisabilité d'acceptation `match_count >= min_inlier_count`. Chaque policy possède sa version et son -fingerprint distincts ; Project DB v22 stocke déjà ces champs et ne nécessite aucune migration. +fingerprint distincts ; le schéma les stocke déjà depuis v12 et la tête courante +Project DB v23 ne nécessite aucune migration GV. ## Inputs @@ -233,14 +234,24 @@ environ 761 Kio hors petits objets et scratch OpenCV. Les cartes appartiennent libérées au retour ; leur borne Feature Store est opérationnelle et n'ajoute aucune limite scientifique. Aucun descriptor ni matrice A×B n'est lu. Massif mesure 2,445 Mio de heap au pic du test E2E complet, incluant SQLite, OpenCV, fixtures Feature Store et toutes les séquences de test. -Une réservation conservatrice de 4 Mio par job couvre ce profil mesuré. +La forme sérielle historique réservait 4 Mio fixes. La Task outer-parallel +courante réserve 8 Mio par item admis afin de couvrir aussi la pile enfant +bornée de 4 Mio, l'objet préparé, les readers et le scratch opaque. Avec un lot +maximal 16, cette charge reste bornée à 128 Mio et ne limite pas la cardinalité +du dataset. ## CPU policy -Le parallélisme OpenCV reste configuré process-wide. Le benchmark mesurera les threads réellement -consommés ; le verifier ne change pas `cv::setNumThreads()` par paire. +Le parallélisme scientifique interne OpenCV reste explicitement désactivé : +`UsacParams::isParallel=false` appartient au fingerprint FROZEN. Le verifier ne +change pas `cv::setNumThreads()` par paire. -La campagne a consommé environ 99 % d'un CPU logique : réservation v1 d'un thread et un worker. +Le parallélisme courant porte uniquement sur des Match Results indépendants. +Le Governor peut admettre CPU1..8 pour une fenêtre/lot d'au plus 16 ; le +callback Queue compte comme participant, crée au plus `cpu_threads-1` enfants, +les joint, puis publie seul dans l'ordre. La fenêtre participant a été validée +sûre jusqu'à 16, mais CPU8 est le maximum utile mesuré. Ce choix opérationnel +n'entre ni dans le fingerprint, ni dans le GVR. ## GPU policy @@ -270,9 +281,12 @@ sélectionne v3 ; le payload et l'ABI publique de configuration restent inchang démarre avec une identité neuve et ne reprend ni ne ré-étiquette une tâche v1/v2. Le Task Kind production est `geometric_verifier.run` version 1. Il pagine les Match Results par ID -strictement croissant avec une page de `batch + 1`, et traite des lots Governor 1/2/4/8. Les -parents autres que `MATCHED` avec `match_count > 0` sont seulement traversés par le curseur. Une -unité éligible appelle le core, qui reuse l'identité exacte avant toute lecture d'asset. +strictement croissant avec une page de `batch + 1`, et traite des lots Governor +1..16 avec CPU utile 1..8. Les préparations éligibles peuvent s'exécuter en +parallèle, mais la publication, l'avancement du curseur contigu et le checkpoint +restent owner-only et ordonnés. Les parents autres que `MATCHED` avec +`match_count > 0` sont seulement traversés par le curseur. Une unité éligible +appelle le core, qui reuse l'identité exacte avant toute lecture d'asset. WHY GENERIC TASK PERSISTENCE IS INSUFFICIENT: aucun champ de payload métier dans le snapshot v1. @@ -388,7 +402,8 @@ Le dernier contrat est GREEN, CPU 1, GPU 0, I/O 1, batch 8, hôte 4 Mio et GPU 0 compute `0-5,8-13` et reserve `6,7,14,15`. Sur l'échantillonnage coalescé de 21 590 changements, le minimum `MemAvailable` vaut 8 907 714 560 octets, le RSS/HWM processus maximal 45 690 880 octets, le PSI mémoire maximal 0,90 %, le PSI I/O maximal 50,17 % et les deltas swap-in/out sont -0/0. Les réserves 3 Gio/2 Gio restent respectées ; aucune voie GPU GV n'est créée. +0/0. Les réserves 3 Gio/2 Gio alors en vigueur pour ce run historique restent +respectées ; aucune voie GPU GV n'est créée. La seconde reprise complète crée la Task 2833, traverse le même curseur et crée zéro GVR. Les 172 275 lignes avant/après sont égales sur toutes leurs colonnes par `EXCEPT` dans les deux sens, @@ -406,6 +421,47 @@ test runner ciblé passe aussi sous ASan/UBSan. `REAL_S21_GV_V3`, `RESTART_IDEMP la preuve réelle de la policy v3 déjà gelée ; il ne rouvre ni algorithme, seuil, RNG, fingerprint, Project DB v22, Matcher/Governor v2, Track Builder ou Sparse SfM. +## Maintenance outer-parallel — preuve représentative réelle + +**IMPLEMENTED / VALIDATED / REVIEWED.** La maintenance sépare la préparation +scientifique de la publication sans modifier la policy v3. Une préparation +valide tout l'input immuable et produit un objet opaque borné ; elle n'écrit +jamais Project DB. Après jointure, le callback propriétaire publie ces objets +en ordre de `match_result_id`, détruit chacun exactement une fois et checkpoint +le seul préfixe contigu. Les pannes de création partielle, calcul, publication, +annulation et reprise ne peuvent donc ni publier un suffixe devant un trou, ni +laisser un enfant/handle vivant à la libération de réservation. + +Le corpus représentatif réel contient 4 113 parents, dont 4 102 applicables, +578 `GEOMETRIC_VERIFIED` et 3 524 `GEOMETRIC_REJECTED`. CPU1/2/4/8/12 +conservent les mêmes IDs 1..4102, toutes les colonnes scientifiques et le digest +`9401ef6168804b6f1d51f4cdf64cd6b33cbebd2934e5294c8feacc87f9c8ce86`. +Les walls Task complets sont 67,521078032/48,859141912/39,158236068/ +35,170176868/34,251675780 s, soit 60,7514/83,9556/104,7545/116,6329/ +119,7606 parents/s. Le gain CPU8→CPU12 vaut seulement 2,68 %, sous le seuil de +5 %. La capacité production est donc CPU utile 8, batch 16 et fenêtre sûre 16. + +Les tests focalisés finaux passent 8/8, les répétitions de stress 60/60, +ASan/UBSan 3/3 et TSan 3/3, avec contrôles C17 GCC/Clang. La preuve S21 +historique ci-dessus reste le run complet CPU1/batch8 acquis ; aucun rerun +complet de 3 221 s n'est revendiqué pour cette maintenance bornée. Le manifest +retenu de cette preuve a le SHA-256 +`52a4412299c74050a66d5690122a793c9451c79faf47e32b6e65a5958f804856`. +Il compare littéralement les 4 102 GVR applicables — ordre/IDs 1..4102, +statut, compteur/masque d'inliers, présence et octets binary64 du modèle — et +vérifie intégrité DB, clés étrangères, absence de replay amont et absence de +travail Tracks/Sparse. Le run S21 complet acquis couvre déjà la policy v3 et +sa persistance FROZEN ; la maintenance ne change que la préparation externe et +la publication owner-only. Cette combinaison réelle bornée + tests ciblés de +panne/checkpoint/reprise discrimine donc le changement sans payer un second run +scientifique intégral ni prétendre l'avoir exécuté. + +La validation globale fraîche qui englobe ce delta passe aussi dans le graphe +Clang portable Vulkan-off 931/931 et sa suite 64/64, puis le graphe Vulkan-on +939/939 et sa suite 65/65. Le TSan global reste volontairement Vulkan-disabled +et couvre les deux cibles GV dans sa matrice 14/14 plus répétitions ; il utilise +uniquement les suppressions externes OpenCV/TBB documentées par le projet. + ## Out of scope Tracks, model competition, classification planaire ou faible parallaxe, Essential, calibration, diff --git a/docs/architecture/global_maintenance_audit.md b/docs/architecture/global_maintenance_audit.md new file mode 100644 index 0000000..7f63438 --- /dev/null +++ b/docs/architecture/global_maintenance_audit.md @@ -0,0 +1,490 @@ +# Global maintenance audit — état consolidé + +## Lifecycle et portée + +`GLOBAL_MAINTENANCE_AUDIT=PASS/FROZEN`. + +Les tranches décrites ci-dessous, y compris l'observatoire TUI, F10 et le +raccordement SSD/Governor, sont implémentées, validées opérationnellement et +relues. Les builds Clang frais portable et Vulkan, les suites 64/64 et 65/65, +les sanitizers applicables, TSan, les probes de headers publics et d'ABI sont +acquis. L'unique revue finale indépendante GPT-5.6 SOL/ULTRA du dépôt consolidé +a ensuite conclu PASS sans finding bloquant. La gate de maintenance est donc +fermée et gelée ; ce statut ne réinterprète aucune science FROZEN et n'exécute +pas la tranche scientifique suivante. + +Ce record réconcilie l'état courant sans rouvrir les contrats scientifiques +FROZEN. Les détails normatifs restent dans les documents de domaine : + +- [Project Database](project_database.md) pour les migrations, identités et + transactions ; +- [Resource Governor](resource_governor.md) pour l'admission et la matrice des + quatorze Task kinds ; +- [Task](task_system.md), [Queue](task_queue.md) et + [runtime](runtime.md) pour ownership, reprise et concurrence ; +- [parallélisme interne](internal_parallelism.md) et + [Geometric Verifier](geometric_verifier.md) pour les bornes CPU et la science ; +- [Resource Boundary](resource_boundary.md) pour la séparation des systèmes ; +- [roadmap](../roadmap/roadmap.md) pour l'ordre des tranches. + +## Positionnement produit et identité des preuves + +Lardon3D est un moteur de photogrammétrie générique, persistant et sensible aux +ressources. Sony A6000, Samsung S21 FE, Ryzen 7 8845HS et Radeon 780M sont des +fixtures, corpus ou hôtes de validation. Ils ne sont ni une identité produit, +ni une branche de code métier, ni une limite portable de caméra, CPU ou GPU. + +Les identités restent celles des contrats existants : Capture, Asset, +`image_id`, Task ID, groupe de campagne, profil de boîtier, profil d'objectif, +configuration optique et calibration sont distincts. Un chemin, basename, +SHA-256, champ EXIF ou modèle d'appareil n'est jamais promu implicitement en une +autre identité. + +## Project Database v23 et contexte optique générique + +La tête de schéma courante est **Project DB v23**. La v23 est une migration +additive au-dessus de la fondation scientifique et de persistance v22 gelée. +Elle ne réécrit aucune ligne scientifique v16–v22 et crée neuf tables vides : + +| Table v23 | Concept possédé | +| --- | --- | +| `camera_body_profiles` | profil de boîtier explicite | +| `camera_body_aliases` | alias metadata make/model exact, binaire | +| `lens_profiles` | profil d'objectif manuel, électronique ou intégré | +| `lens_profile_aliases` | alias metadata d'objectif exact et optionnel | +| `optical_configurations` | boîtier + objectif + focale exacte optionnelle | +| `acquisition_campaign_group_optics` | configuration explicite d'un groupe de campagne | +| `capture_optical_configurations` | configuration retenue pour un Capture et sa provenance | +| `optical_calibration_profiles` | calibration existante compatible avec une configuration exacte | +| `capture_calibration_selections` | sélection explicite de calibration pour un Capture | + +La migration v22→v23 s'exécute dans la transaction de migration existante. Le +marqueur de version n'est publié qu'après création de toutes les tables ; tout +échec rollbacke l'ensemble. Elle n'infère et ne backfill aucun profil depuis +EXIF, chemin, nom, SHA-256, dimensions, calibration historique ou marque +d'appareil. Une base v22 migre donc avec les neuf tables optiques vides. + +Un objectif manuel sans EXIF est un cas normal : un profil explicite peut ne +posséder aucun alias metadata. Le Meike manuel utilisé dans les tests est une +preuve de ce chemin générique, pas un modèle hardcodé. Une campagne peut +assigner des configurations différentes à ses groupes ; lors de la rétention +du Capture, le binding campagne/configuration et l'avancement du curseur sont +atomiques. Un Capture importé hors campagne peut recevoir une affectation +explicite distincte, sans valeur « inconnue » fabriquée. + +Une calibration optique référence une calibration sparse immuable existante et +une seule configuration exacte. Il n'existe ni interpolation, ni fallback par +focale proche, ni sélection automatique. Le Capture et le profil doivent +référencer la même configuration ; plusieurs profils compatibles restent +ambigus jusqu'à une sélection explicite. Les retries exacts convergent, les +conflits valides sont des contraintes et les dépendances durables malformées +sont de la corruption. + +Les migrations exercées via l'API production sur des copies des projets S21 et +A6000 ont atteint v23 avec `integrity_check` et clés étrangères propres, comptes +scientifiques inchangés et tables optiques vides. Les SHA-256 des projets source +sont restés inchangés. La validation v23 retenue comprend la suite normale +57/57, dix tests focalisés, cinq cibles ASan/UBSan, les contrôles de headers C17 +et C++, les scénarios de rollback/retry et une revue indépendante après +correction. Cette preuve ne réinterprète aucune calibration réelle des deux +campagnes. + +## Resource Governor portable + +Le Resource Governor demeure l'unique propriétaire de l'admission RAM, CPU, +GPU et I/O et le seul orchestrateur des leases scratch de production. Le +contrôleur SSD décrit plus bas est une frontière physique ; il n'est ni un +scheduler, ni un second Governor, ni une source de capacité RAM. + +La politique hôte courante réserve d'abord le système : + +- quatre CPU logiques lorsque l'hôte le permet, tout en conservant au moins un + CPU de calcul sur un petit hôte ; +- quand la topologie est fiable, un ensemble déterministe de coeurs physiques + complets, frères SMT inclus, avec le dépassement minimal de la cible logique ; +- les CPU déjà exclus par une affinité externe comptent dans la réserve hôte, + afin de ne pas soustraire deux fois la même capacité ; +- sans topologie ou masque fiable, le fallback ne fabrique aucun ID ni masque : + il conserve seulement un budget de compte portable ; +- environ 3 Gio de `MemAvailable` comme réserve dure sur un hôte capable ; la + bande 3–4 Gio est une prudence YELLOW qui bloque la croissance mais ne + soustrait pas 4 Gio à la capacité ; +- sur un petit hôte RAM, une réserve fractionnaire déterministe permet une + dégradation sûre sans prétendre conserver 3 Gio inexistants ; +- PSI CPU/mémoire/I/O et deltas actifs de swap pilotent la pression ; + l'occupation historique du swap ne suffit pas ; +- la mémoire UMA est débitée exactement une fois et swap/zram/SSD ne deviennent + jamais un objectif de travail ni une extension de la RAM admise. + +Il n'existe plus de plafond CPU global à 12. Le Governor borne chaque contrat +par le compute-pool réel et par la capacité intrinsèque du Task kind. Les IDs +`0-5,8-13` et la réserve `6,7,14,15` restent la preuve historique correcte de +l'hôte 16-CPU validé, pas une politique portable. + +## Capacités des quatorze Task kinds + +La matrice chiffrée complète appartient au +[Resource Governor](resource_governor.md#audit-des-14-kinds-de-production). +La classification courante est : + +| Kind v1 | CPU et lot courants | Nature de la borne | +| --- | --- | --- | +| `raw.develop` | CPU 1, lot 1 | opération atomique ; OpenCV global appliqué/restauré, allowance RAW 2 Gio | +| `photo_quality.triage` | CPU 1, lot 1 | un groupe borné ; aucune preuve de scaling utile | +| `acquisition_campaign.run` | CPU 1, lot 1 | orchestration d'un groupe ; coût JPEG/RAW exact, aucun Task imbriqué | +| `import.images` | CPU 1, lot 1..32 | copie/hash I/O-bound, réadmission par lot | +| `features.extract` | CPU 1..compute-pool, lot 1 | API OpenCV positive-`int`, plafond hôte Governor | +| `features.extract.sift` | CPU 1..compute-pool, lot 1 | même contrat OpenCV ; historique CPU12/CPU1 accepté exactement | +| `features.extract.rootsift` | CPU 1..compute-pool, lot 1 | même contrat OpenCV ; aucune voie GPU validée | +| `visual_index.update` | CPU 1..16, lot 1..16 | maximum algorithmique du segment de 16 Feature Sets | +| `candidate_pair.generate` | CPU 1..64, lot 1..64 | maximum algorithmique/ressource d'un batch de 64 sources | +| `matcher.run` | CPU sûr 1..12 ; lot AUTO utile 1..8, sûr 1..12 | batch Matcher mesuré ; Vulkan ORB exact, SIFT/RootSIFT CPU | +| `geometric_verifier.run` | CPU utile 1..8, fenêtre CPU sûre 16, lot 1..16 | outer-parallelism opérationnel ; USAC interne reste `isParallel=false` | +| `track_builder.run` | CPU 1, lot 1 | rebuild DSU complet, publication atomique owner-only | +| `sparse_sfm.run` | CPU 1, lot 1 | contrat scientifique/atomique FROZEN, BA à un thread | +| `incremental_reconstruction.run` | CPU 1, lot 1 | recomputation atomique FROZEN depuis entrées immuables | + +Candidate utilise au plus `cpu_threads-1` enfants et un handle DB privé par +participant, le callback Queue étant lui-même participant. Ses piles et états +sont facturés à 8 Mio par item ; `batch=1` ne crée pas de travail vide. +Visual Index conserve ses buffers de segment fixes et publie seul après toutes +les jointures. ORB/SIFT/RootSIFT utilisent `INT_MAX` seulement comme borne de +l'API OpenCV : l'admission réelle reste le compute-pool. Les signatures CPU12 +historiques sont des formats de reprise exacts et ne réintroduisent aucun +plafond portable. + +La même discipline vaut à la frontière géométrique Sparse SfM : les champs +publics `uint32_t` `max_iterations` et `minimum_inliers` de relative pose et PnP +doivent être représentables par le `int` OpenCV, donc être au plus `INT_MAX` +(`max_iterations` reste strictement positif). Une valeur supérieure est +refusée avant narrowing, allocation, appel solveur ou mutation des sorties. +L'encodage F0 reste `u32`, et tous les paramètres FROZEN représentables, +valeurs de référence, fingerprints et résultats scientifiques restent +inchangés : cette borne est opérationnelle/ABI externe, pas une nouvelle +politique scientifique. + +Le Geometric Verifier prépare désormais des Match Results indépendants en +parallèle, puis le propriétaire unique publie dans l'ordre et checkpointe un +préfixe contigu. Sur le corpus représentatif réel de 4 113 parents, les sorties, +IDs canoniques et le digest scientifique +`9401ef6168804b6f1d51f4cdf64cd6b33cbebd2934e5294c8feacc87f9c8ce86` +restent identiques. Les débits Task complets CPU1/2/4/8/12 sont +60,7514/83,9556/104,7545/116,6329/119,7606 parents/s. CPU12 n'ajoute que +2,68 % sur CPU8, sous le seuil d'acceptation 5 % : le plafond utile est 8, +la fenêtre sûre et le lot maximal restent 16. La preuve S21 complète reste une +preuve historique CPU1/batch8 valide : 172 741 parents traversés, 172 275 +applicables, 24 065 vérifiés et 148 210 rejetés en 3 221,758 s, sans doublon, +avec reprise/SIGKILL validée. Elle n'est pas présentée comme un rerun du +nouveau chemin. Le manifest retenu de la preuve outer-parallel a le SHA-256 +`52a4412299c74050a66d5690122a793c9451c79faf47e32b6e65a5958f804856` : +il prouve littéralement les 4 102 lignes applicables, leurs IDs 1..4102, +statuts, modèles, masques et compteurs, pas seulement un wall. Un second run +complet S21 de 3 221 s n'est pas requis pour accepter cette maintenance : le +run S21 acquis porte déjà la science/persistance v3 gelée, tandis que la preuve +réelle bornée et les tests de publication/reprise discriminent exactement le +seul changement, l'ordonnancement externe de préparation. + +## Task, Queue, persistance et concurrence + +Une seule Queue possède un worker et un callback actif. Le Governor admet la +séquence ; la Task possède son état d'exécution ; la Queue possède seulement +ordre/backpressure et, après retour du callback terminal, une histoire de +64 snapshots maximum. Task et userdata sont détruits hors mutex Queue et +seulement après la fin du callback. + +La fermeture Queue ferme d'abord l'ingress, attend le worker et tous les appels +déjà enregistrés, puis détruit exactement une fois. Comme pour tout pointeur C +brut, le propriétaire doit empêcher le début de nouveaux appels après le début +de la destruction. Les callbacks terminaux peuvent observer les API read-only, +mais ne doivent pas retirer synchroniquement leur propre entrée, détruire leur +Queue ou attendre une opération dépendant de leur propre retour. + +Les IDs Queue générés sont monotones et leur épuisement à `UINT64_MAX` est +sticky ; ni un ID restauré plus petit, ni l'éviction de l'histoire ne réarme la +génération. La reprise restaure état métier et estimation durable, jamais +thread, callback, pointeur, réservation ou contrat actif. Les signatures de +ressources historiques explicitement reconnues sont normalisées seulement en +mémoire ; aucun checkpoint « estimate-only » n'est publié. Les curseurs métier +et la publication scientifique restent les seules frontières de progression. + +Ouvrir, fermer ou changer de projet constitue une frontière de session exacte. +Les vues libèrent leurs borrows, puis la Queue est annulée, jointe et détruite +avant Project DB ; une unique Queue vide est ensuite recréée et les observateurs +sont rebondés. Les callbacks terminaux peuvent donc finir avec leur DB encore +vivante, et histoire/namespace Queue d'un projet ne fuient pas dans le suivant. + +## Observatoire et centre de contrôle TUI + +**CURRENT / VALIDATED OPERATIONAL.** ncurses, l'entrée et le rendu appartiennent +uniquement au thread principal. Un modèle de vue pur reçoit des copies bornées +et ne mute ni Queue, ni Governor, ni DB, ni science. L'observateur coalesce les +captures ordinaires pendant au moins une seconde et couvre exactement le pire +cas production : 64 pending, une active et 64 historiques, soit 129 Tasks. Les +ABI historiques `TaskSnapshot`, `ResourceSnapshot`, `AppState` et layout sont +préservées ; les identités typées, comptes durables, contrats installés, +SwapTotal et vues riches passent par des types/fonctions additifs. + +La progression exacte emploie toujours le préfixe métier durable quand il est +connu. Un terminal 2/7 est une erreur d'intégrité et un terminal typé sans +compteur scientifique reste indéterminé ; aucun des deux ne devient un faux +100 %. L'EWMA/ETA exclut le préfixe repris et exige deux intervalles positifs ; +les états calculating, indeterminate, stalled, throttled et complete restent +explicites. Le pipeline expose Acquisition, RAW, Quality, Features, Visual +Index, Candidate, Matcher, GV, Tracks, Sparse SfM et Dense, avec +`NOT_READY/READY/QUEUED/RUNNING/THROTTLED/BLOCKED/COMPLETE/FAILED/NOT_APPLICABLE`. +Dense future reste `NOT_APPLICABLE` et n'est jamais montrée en cours. + +Le panneau ressources présente CPU actif/admis/disponible et sa raison, +GPU/mémoire/busy/backend, RAM/MemAvailable/réserve, swap total/utilisé/deltas, +lot/inflight/helpers/I/O/scratch et Governor GREEN/YELLOW/RED. Le contrat de +l'exacte Task active est prioritaire ; backend/inflight/helpers ou raison +restent `UNKNOWN` lorsqu'une association Task+séquence n'est pas prouvée. +L'espace scratch enregistré auprès du Governor est présenté séparément des +détails physiques contrôleur et n'est jamais additionné à la RAM. + +Les layouts validés sont full à partir de 100×30, compact à 72×20 et jusqu'au +minimum 60×15 ; en dessous, seul `Terminal trop petit` est rendu. Vert/jaune/ +rouge/cyan/bleu/magenta et dim/bold ont toujours des libellés de repli sans +couleur. `F10 SSD` est un segment littéral garanti à 60 colonnes dans les modes +idle, saisie et import. L'aide est contextuelle : saisie = Enter/Échap/F10, +import actif = X/F10 avec q/Échap désactivés, et les écrans idle n'annoncent que +les touches réellement traitées. + +L'écran optique effectue seulement des lookup metadata exacts, accepte le +Meike manuel sans EXIF comme cas normal, crée de nouveaux profils/configurations +immuables et affecte explicitement groupe de campagne ou Capture. Il liste et +sélectionne uniquement les calibrations exactement compatibles ; unresolved, +absence, ambiguïté, incompatibilité, BUSY, I/O et corruption restent visibles. +Les pages de 16 rapportent leur compte local et l'existence exacte d'une suite ; +`[` revient à la première page, `]` avance et `R` retente explicitement. + +## Contrôleur SSD externe optionnel + +Le contrôleur SSD est la frontière physique synchrone et bornée vers UDisks2 +via GIO/GDBus. Il découvre exactement les labels `LARDON_SWAP` et +`LARDON_SCRATCH`, exige des UUID stables et une même identité UDisks Drive, et +ne dépend jamais d'un `/dev/sdX`, modèle, numéro de série ou object path comme +identité produit. Un renommage de device node ne change donc pas l'identité. + +Les états publics sont `ABSENT`, `DETECTED`, `ENABLING`, `ENABLED`, `IN_USE`, +`DRAINING`, `SAFE_TO_UNPLUG` et `ERROR`. Les snapshots sont des copies bornées ; +les valeurs inconnues restent explicitement inconnues. Le poll ordinaire est +coalescé autour d'une seconde. Le chemin production n'appelle pas `statvfs` +depuis le poll synchrone : un scratch monté peut donc rapporter total/libre +inconnus. + +La conversion vers le Governor valide l'état complet et échoue fermée. Toute +paire ou autorité (`pairing_valid`, allocation, enable/disable/cancel, ou état +`ENABLING/ENABLED/IN_USE/DRAINING/SAFE_TO_UNPLUG`) exige la détection actuelle +du Drive et des deux partitions, les trois identités exactes non vides, des +tailles de partition connues strictement positives et des faits mount/activité/ +capacité cohérents. `ABSENT` rejette toute télémétrie active, montée, louée ou +capacitaire ; un `DETECTED` partiel reste visible mais non actionnable. Un +hazard `ERROR` déconnecté peut conserver son tuple pour la sécurité, jamais +pour allouer ; `can_disable` n'est acceptable qu'après reconnexion prouvée de +l'exact tuple original. + +L'activation appelle directement UDisks et exige le point de montage exact +`/mnt/lardon-scratch`. Elle ne formate, partitionne, répare, fsck, poweroff ni +supprime rien. Avant toute action potentiellement side-effecting, l'ownership +physique est lié conservativement au tuple Drive+deux UUID. Un timeout ou une +vérification indéterminée laisse un hazard sticky ; aucun disque de remplacement +ne reçoit alors Start/Mount/Stop/Unmount. Seul le même tuple reconnecté peut +être vérifié et drainé jusqu'à `SAFE_TO_UNPLUG`. + +Le scratch utilise au plus 64 leases explicites. L'adresse de l'objet lease +fait partie de son ownership process-local : copie, reconstruction, objet +étranger, stale ou double release ne décrémentent rien. `DRAINING` refuse de +nouvelles leases. Le swap n'est arrêté que si les leases sont nulles, que les +PSI et deltas swap sont calmes, et que son usage peut être absorbé tout en +conservant la réserve dure de 3 Gio. La propriété UDisks +`Swapspace.Active` doit être un booléen exact connu ; absence ou mauvais type +échoue fermé. Aucun arrêt forcé n'existe. + +Le contrôleur ne crée ni thread de polling, ni scheduler, ni capacité Task +scratch implicite. L'adaptateur TUI possède au plus un thread joinable pendant +un poll ou contrôle UDisks borné ; il ne touche jamais ncurses. F10 consomme +exclusivement `can_enable`, `can_disable` ou `can_cancel_drain` du snapshot +validé, jamais une inférence depuis l'état. Les huit états, identité stable, +modèle, lien, swap, scratch, mount, usage, drain et raison sont présentés ; une +valeur inconnue reste `UNKNOWN`, et `SAFE_TO_UNPLUG` est explicite. + +Après chaque snapshot ou contrôle validé, l'adaptateur enregistre l'état +physique auprès du Governor. Un snapshot malformé enregistre `ERROR` et bloque +les nouvelles allocations. Les wrappers Governor sont l'unique voie de lease +scratch de production ; le contrôleur conserve seulement son API physique +basse. Les quatorze Task kinds actuels n'ont aucun consommateur scratch, donc zéro +lease est la vérité courante et une capacité disponible n'est pas un usage. +La génération source peut légalement saturer à `UINT64_MAX`. Une update publique +matériellement différente au même watermark reste stale et ne peut pas rendre +de l'autorité ; seule la fin sérialisée du wrapper exact déjà engagé peut +réconcilier à ce watermark sa propre acquisition/libération et le compte de +leases fondé sur les adresses. Même son erreur ne peut recopier un compte stale. +Le shutdown suit Queue/leases → fermeture projet → join+unregister SSD → +contrôleur → Governor. Tous les tests SSD/TUI utilisent un provider factice et +n'exécutent aucune mutation réelle de l'hôte. + +## GPU, build et ABI + +Le backend Vulkan ORB Matcher est une voie production validée et GPU-first en +mode AUTO quand l'admission GPU/UMA est sûre. CPU reste le fallback portable. +Le batch AUTO utile est 8 ; batch 12 et inflight 2 restent des bornes de sûreté +et de benchmark rejetées pour la politique normale faute de gain supérieur à +5 %. Candidate, Feature et Visual Index restent CPU ; SIFT/RootSIFT Vulkan est +rejeté par l'évidence disponible ; GV reste CPU et conserve son USAC interne +non parallèle. Sur la Radeon 780M réelle RADV PHOENIX, la feasibility explicite +compare 24 160 requêtes SIFT et 24 161 RootSIFT : chaque mode produit une +divergence d'index, respectivement 20 251 et 20 824 divergences de bits de +distance, mais zéro divergence de décision Lowe. C'est une preuve de +feasibility décisionnelle, pas une équivalence binaire ; le hot path +SIFT/RootSIFT Vulkan reste donc rejeté. Les overrides CPU/Vulkan sont des outils +de debug, benchmark ou reproductibilité, pas un choix backend exigé de +l'utilisateur normal. + +Le dépôt est mixte C17/C++17. Les headers publics sous `include/lardon3d/` +restent compilables comme C17, n'exposent aucun type C++ et décrivent ownership, +bounds et erreurs. Aucune exception C++ ne traverse une frontière C. Meson et +Ninja sont le build de référence ; Clang est préféré et GCC reste couvert pour +les contrôles C17 pertinents. GIO/GDBus est la dépendance directe du contrôleur +SSD ; aucune commande shell `mount`, `swapon` ou équivalente n'est construite. + +## Évidence finale acquise et revue indépendante + +### Builds normaux, Vulkan et ABI + +Deux répertoires entièrement frais utilisent Clang/Clang++ 22.1.8, C17 et +C++17 : + +- portable `-Dvulkan_orb=disabled` : graphe complet **931/931** puis suite + sérielle **64/64** ; +- Vulkan `-Dvulkan_orb=enabled` : graphe complet **939/939** puis suite + sérielle **65/65**, dont `orb-vulkan-backend` sur l'iGPU réel + `AMD Radeon 780M Graphics (RADV PHOENIX)` ; la dépendance Vulkan détectée + vaut 1.4.357 ; +- la feasibility SIFT/RootSIFT, cible non enregistrée dans la suite normale, + a été compilée et exécutée explicitement ; ses divergences exactes sont + consignées ci-dessus et n'autorisent aucun backend production nouveau ; +- les probes autonomes stricts GCC/Clang C17 et C++17 passent **76/76** sur les + **19 headers publics modifiés ou nouveaux**, le fixture ABI historique, le + lien de l'application et `git diff --check` passent, et + `git status --short -- scan3d` reste vide. + +La correction de narrowing Sparse SfM, postérieure à la capture des deux logs +complets, a ensuite passé son test focalisé, 20 répétitions, ASan/UBSan ciblé, +les inclusions C17/C++17 et le lien application. Elle retire les conversions +publiques `uint32_t → int` sans changer les appels valides ni la science. + +### Déterminisme post-freeze du fixture Feature + +La validation post-freeze a exposé une dépendance exclusivement test à +l'activité de l'hôte : `test-feature-task` construisait des profils et +Governors synthétiques, mais leur admission capturait encore le vrai +`/proc/loadavg`, les PSI et les deltas de swap. Une charge minute suffisante +pouvait donc maintenir correctement la Queue en `WAIT` jusqu'à son délai de +30 s, sans divergence scientifique ni défaut de la politique production. + +Le fixture possède désormais un override privé par Governor qui copie sous +mutex un `ResourceSnapshot` complet et ne rafraîchit que son horodatage. Il est +compilé exclusivement dans `test-feature-task` ; la capture réelle et les +symboles production restent inchangés. Une régression directe prouve que la +charge synthétique 5 produit toujours `WAIT`, puis que la charge 0 produit +`START` : le seam contrôle la télémétrie, jamais la politique. La revue bornée +a ensuite identifié le second Governor créé par le helper runtime ; celui-ci +reçoit maintenant le même snapshot avant toute création de Queue, avec cleanup +explicite des échecs. + +Après cette correction et sa revue, le test Feature complet passe **100/100**, +la matrice ordonnée Candidate→Matcher→Matcher pipeline→Feature **4/4**, la suite +portable finale **64/64** et la suite Vulkan finale **65/65**. Les exécutions +ciblées ASan/UBSan avec `detect_leaks=0` et TSan passent également. La +qualification LSan externe décrite ci-dessous reste inchangée. Une suite +normale mixte lancée après une reconstruction large a aussi observé un unique +timeout `task` ; le ciblé immédiat et la matrice de revue **100/100** passent. +Ce cas reste qualifié non reproductible/environnemental : ni le timeout ni le +comportement Task n'ont été modifiés. + +### ASan, UBSan et LeakSanitizer + +Le build frais Clang 22.1.8 `address,undefined`, portable/Vulkan désactivé, a +d'abord été exécuté avec détection de fuites : **57 OK, 6 FAIL, 1 TIMEOUT**. +Cette première passe n'est pas masquée : + +- `feature-store`, `visual-index`, `candidate-pair-task`, + `precision-features` et `precision-consolidation` terminent chacun sur la + même fuite externe, exactement **3 808 octets en 68 allocations**, avec la + dernière frame `/opt/cuda/lib64/libOpenCL.so+0x2fd4`, Build ID + `b3217362255db6f1188e7596454ffe8bc4606b53`, résolue vers + `/opt/cuda/targets/x86_64-linux/lib/libOpenCL.so.1.0.0`, sans frame projet ; +- `feature-task` atteint une fois son attente adaptative interne de 30 s et + `task` le timeout Meson de 30 s, sans diagnostic ASan/UBSan/LSan. + +Après cette attribution, la matrice complète repasse avec +`detect_leaks=0` mais ASan et UBSan toujours actifs : **64/64**, zéro timeout. +Une matrice séparée de vingt cibles dont `ldd` prouve qu'elles ne chargent ni +OpenCV ni OpenCL passe **20/20** avec LeakSanitizer actif. Les anomalies de +temps initiales ne s'étaient pas reproduites dans le suivi initial : 20/20 +exécutions `feature-task` à affinité complète et 100/100 exécutions `task` avec +perturbations allocateur. Le cas Feature a été reproduit plus tard par la suite +portable ordonnée, puis déterminisé comme décrit ci-dessus ; le cas Task reste +non reproductible. La conclusion sanitizer exacte demeure : ASan/UBSan projet +**64/64**, chemins projet loader-free LSan **20/20**, limitation LSan du loader +OpenCL externe conservée explicitement — jamais un faux « LSan global 64/64 ». + +### Concurrence + +Le build frais GCC/G++ 16.2.1 TSan, volontairement Vulkan-disabled, passe les +quatorze cibles concurrentes Task/Project/Queue/Governor/SSD/TUI/Candidate/ +Visual/Feature/Matcher/GV **14/14**, puis **220/220** répétitions déterministes, +soit **234/234** exécutions. `tests/tsan-opencv.supp` ne supprime que les races +issues des objets externes non instrumentés `libopencv_features.so`, +`libopencv_core.so` et `libtbb.so`; aucune frame Lardon3D n'est supprimée. +Cette preuve ne couvre pas Vulkan sous TSan : le backend réel est couvert par +le build/suite Vulkan ci-dessus et ses tests propres. + +### Audit des avertissements + +Le build Vulkan complet et ses cibles non-default ont produit 785 émissions : +117 dans des sources projet/tests/benchmarks et 668 dans les headers OpenCV +installés. L'audit n'a trouvé qu'une famille matérielle pour la frontière +publique courante : le narrowing Sparse `max_iterations`/ +`minimum_inliers → int`, corrigé et revalidé comme décrit plus haut. Les +avertissements restants sont classés : + +| Classe | Emplacements | Qualification | +| --- | --- | --- | +| Baseline production, non matériel pour cette maintenance | `src/acquisition_campaign_task.cpp:68`, `src/raw_development.cpp:185`, `src/acquisition_pairing.cpp:549`, `src/feature_extractor_opencv.cpp:231` | conversions sur valeurs déjà bornées par leur structure/validation ; aucune identité ni sortie modifiée dans ce ticket | +| Baseline géométrique bornée | `src/sparse_sfm_geometry.cpp:312-313` | index OpenCV signé sur les deux vecteurs construits avec le même nombre de colonnes ; distinct du narrowing public corrigé | +| Tests/benchmarks seulement | `tests/test_dense_mvs.cpp:45`, `tests/test_precision_consolidation.c:846-847`, shadows dans `tests/test_sparse_sfm_incremental.cpp:441-723`, API d'initialisation OpenCV dépréciée et indices signés dans `tests/benchmark_geometric_verifier.cpp:101-190` | aucun chemin production ni format durable | +| GCC TSan, non matériel | `src/feature_task.c:209-215`, `src/sift_task.c:170-175` | `-Wmaybe-uninitialized` interprocédural ; le callback fournit une Task non nulle et le helper initialise le contrôle avant toute autre sortie d'échec | +| Externe | headers OpenCV 5 installés ; découverte CMake Ceres | conversions/extensions et warnings de generator expressions hors sources Lardon3D | + +Les catégories baseline ne sont pas promues en dette scientifique cachée : +elles sont consignées pour une maintenance ultérieure, tandis que le seul +narrowing pouvant accepter une valeur publique non représentable a été traité +avant la revue finale. + +### Revue finale et clôture de la gate + +Les tests focalisés TUI/runtime/SSD, les répétitions déterministes et la +validation F10 par providers factices restent acquis. La preuve GV finale +comprend 8/8 tests d'intégration focalisés, 60/60 répétitions de stress, 3/3 +ASan/UBSan et 3/3 TSan ; les détails Project DB v23 sont consignés plus haut. + +L'unique revue finale indépendante GPT-5.6 SOL/ULTRA a conclu **PASS**, avec +zéro finding bloquant. Elle a indépendamment exécuté le build portable, la +suite complète **64/64**, la matrice focalisée **15/15**, les probes strictes +GCC/Clang C17+C++17 **76/76** sur 19 headers publics modifiés/nouveaux, le +fixture ABI, les négatifs de symboles/strings des seams production, la +vérification du SHA-256 du manifest GV retenu et `git diff --check`. +Cette preuve indépendante ferme la gate sans effacer les qualifications +sanitizer consignées ci-dessus. L'ordre autorisé est désormais : + +```text +science réelle acquise jusqu'à GV +→ GLOBAL_MAINTENANCE_AUDIT (PASS/FROZEN) +→ prochaine tranche séparée : poursuite réelle depuis Tracks +→ Sparse SfM réel / Dense selon calibration et roadmap +``` + +`scan3d/` et les contrats scientifiques Tracks/SfM/Dense restent protégés et +n'ont pas été modifiés par cette réconciliation. diff --git a/docs/architecture/internal_parallelism.md b/docs/architecture/internal_parallelism.md index 714e87f..d719540 100644 --- a/docs/architecture/internal_parallelism.md +++ b/docs/architecture/internal_parallelism.md @@ -8,7 +8,8 @@ Ce document décrit le contrat validé pour `features.extract`, `features.extract.sift`, `visual_index.update`, `candidate_pair.generate` et -`matcher.run`. Le gel v1 porte sur le parallélisme interne borné, les +`matcher.run`, ainsi que la maintenance outer-parallel du +`geometric_verifier.run`. Le gel v1 porte sur le parallélisme interne borné, les réservations Governor et les sorties canoniques. Il ne préjuge pas du choix opérationnel CPU/Vulkan ajouté par la tranche v2. @@ -17,14 +18,18 @@ opérationnel CPU/Vulkan ajouté par la tranche v2. La Task Queue conserve un seul callback actif. Une Task peut exploiter à l'intérieur de ce callback les `cpu_threads` effectivement admis par le Resource Governor. Ce parallélisme interne ne crée ni pool global, ni deuxième -scheduler, ni file de travail persistante. Le Governor dérive désormais un -masque privé depuis l'affinité permise et la topologie package/core Linux. Il -réserve des coeurs physiques complets, frères SMT inclus, en ordre décroissant -package/core. Sur l'hôte unrestricted validé, cela donne `0-5,8-13` pour le -calcul lourd et `6,7,14,15` pour le desktop, l'audio et l'interaction. Un masque -caller déjà borné à la capacité de calcul est repris sans seconde réserve ; si -topologie ou affinité manque, seul le budget portable est conservé et aucun ID -n'est inventé. +scheduler, ni file de travail persistante. Le Governor dérive un masque privé +depuis l'affinité permise et la topologie package/core Linux. La cible par +défaut réserve quatre CPU logiques lorsque praticable et conserve au moins un +CPU de calcul sur les petits hôtes. Avec une topologie fiable, un sous-ensemble +déterministe de coeurs physiques complets, frères SMT inclus, satisfait cette +cible avec le dépassement minimal. Les CPU exclus par une affinité externe +comptent déjà dans la réserve. Si topologie ou affinité manque, seul le budget +de compte portable est conservé et aucun masque/ID n'est inventé. + +Sur l'hôte unrestricted 16-CPU validé, cette politique donne `0-5,8-13` pour le +calcul lourd et `6,7,14,15` pour le desktop, l'audio et l'interaction. Ces IDs +restent une preuve de cet hôte et ne bornent aucune autre machine. L'unique worker Queue applique et vérifie son propre masque (`pid=0`) avant les callbacks ; le creator/main/TUI reste inchangé et les enfants créés depuis le @@ -47,7 +52,8 @@ sortie partielle. `backend_info` et les requêtes vides restent non initialisant Cette défense couvre les consumers publics ou de feasibility qui ne traversent pas la Queue ; seuls leurs `main` autonomes peuvent prendre le défaut sûr avant tout pthread. -Le compute-pool borne `cpu_threads` à l'admission. Un échec d'application est +Le compute-pool borne `cpu_threads` à l'admission ; il n'existe aucun plafond +global 12. Un échec d'application est diagnostiqué sans altérer Task, Queue, durabilité ou science. Cette couture v2 est **PASS / FROZEN**, sans persistance ni ABI publique. Elle complète le gel v1 sans le redéfinir. @@ -157,9 +163,10 @@ CPU/Vulkan, le fallback CPU complet et les diagnostics bornés sont ## Extraction OpenCV Le processus conserve une limite OpenCV globale. Sous l'unique callback Queue, -RAW et Photo Quality appliquent/restaurent CPU1. ORB, SIFT et RootSIFT -Extraction appliquent/restaurent exactement le `cpu_threads` immutable admis -dans `1..min(12, compute_pool)`. Matcher applique OpenCV1 à l'intérieur de sa +RAW, Photo Quality et la campagne appliquent/restaurent leur CPU admis (CPU1 +dans les estimations courantes). ORB, SIFT et RootSIFT Extraction déclarent le +maximum `int` positif accepté par OpenCV, puis appliquent/restaurent exactement +le `cpu_threads` immutable borné au compute-pool. Matcher applique OpenCV1 à l'intérieur de sa propre fenêtre et utilise les participants Task admis autour de cette primitive. La réservation vit pendant l'exécution bornée et aucun second pool runtime n'est créé. @@ -167,12 +174,14 @@ runtime n'est créé. Le nombre admis est une politique de ressources, pas une identité scientifique. Les tests OpenCV 5.0.0 à 1/2/4/8/12 threads obtiennent les mêmes keypoints, descripteurs et métriques ORB, SIFT et RootSIFT. Aucun fingerprint, format ou -schéma n'est modifié. Les utilisateurs imbriqués d'OpenCV ne peuvent pas faire +schéma n'est modifié ; 12 est une preuve de l'hôte courant, pas un maximum +portable. Les utilisateurs imbriqués d'OpenCV ne peuvent pas faire varier cette configuration process-wide pendant une extraction. Le callback assure donc qu'une admission CPU est réellement consommée, sans confondre cette dimension avec le lot admis. -Le Governor slow-start les dimensions CPU validées selon `1/2/4/8/12`. Chaque +Le Governor slow-start les dimensions CPU réductibles par doubles depuis 1, +puis le maximum exact de la capacité, toujours borné au compute-pool. Chaque palier utilise deux observations et exige au moins 5 % de débit durable en plus. Un seul essai CPU ou lot est actif à la fois ; après plafonnement ou refus CPU, un kind dont le lot est adaptable peut seulement alors explorer le lot. Les @@ -183,8 +192,8 @@ essai. Visual Index suit la même règle par segment, tandis que Candidate et Matcher observent chaque séquence. Les formes fixes restent fixes mais conservent un diagnostic d'admission Governor-owned. -Pour Candidate Generation, l'estimation demande jusqu'à douze threads CPU et -un slot I/O. Le callback de Queue compte comme un de ces threads ; il crée donc +Pour Candidate Generation, l'estimation demande jusqu'à soixante-quatre threads +CPU et un slot I/O. Le callback de Queue compte comme un de ces threads ; il crée donc au plus `cpu_threads - 1` threads enfants. Tous les enfants sont joints avant la fin de la séquence, la libération de réservation ou `lardon3d_task_sequence_break()`. @@ -192,7 +201,7 @@ la fin de la séquence, la libération de réservation ou ## Calcul et publication Visual Index Une séquence sélectionne le même préfixe durable d'au plus seize Feature Sets -qu'en mode sériel. Jusqu'à douze participants effectivement admis lisent les +qu'en mode sériel. Jusqu'à seize participants effectivement admis lisent les Feature Files immuables ; chaque reader, son descripteur de fichier et sa tranche de 256 descripteurs appartiennent à un seul participant. Aucun enfant n'utilise le handle Project DB partagé. @@ -259,17 +268,22 @@ réelle soutenue doit attendre un état hôte qui admet répétitivement Les sources sont les memberships durables du Visual Index, pagés en ordre croissant de `feature_set_id`. Les IDs peuvent être clairsemés. Une fenêtre -contient au plus `2 * cpu_threads` sources et jamais plus de 24. Chaque résultat +contient au plus `2 * cpu_threads` sources et jamais plus de 64 ni du lot admis. +Chaque résultat conserve au plus le top-K existant de 256 propositions ; l'estimation de Task -reste conservatrice à 256 Kio fixes et 64 Kio par item admis. +reste conservatrice à 256 Kio fixes et 8 Mio par item admis. Ce coût couvre la +pile enfant bornée de 4 Mio, le reader/Visual Index et le handle SQLite privé de +chaque participant. Chaque participant possède son propre handle `project.db` pour les lectures et le ferme avant de terminer. Le calcul parallèle charge le Feature Set source, exécute exactement la requête Visual Index existante et forme les paires canoniques en mémoire. Il ne publie rien. -La reprise normalise uniquement l'estimation Candidate v1 sérielle historique -de forme exacte vers la demande courante de douze participants. Cette +La reprise normalise uniquement les estimations Candidate historiques +complètes reconnues — CPU1/128 Kio fixes et CPU12/256 Kio fixes, toutes deux à +64 Kio par item — vers la demande courante CPU64/256 Kio fixes/8 Mio par item. +Cette estimation effective reste privée et n'est pas checkpointée avant admission ; le Governor peut toujours la réduire à son budget disponible. Le Task ID, le curseur typé, le lot 1–64 et les paramètres scientifiques restent inchangés. Les autres kinds @@ -284,6 +298,39 @@ fingerprints et identités scientifiques ne changent pas. Le nombre de threads ne peut donc modifier ni les identités persistées, ni leur ordre de publication, ni leur cardinalité. +## Préparation et publication Geometric Verifier + +La policy scientifique GV v3, les paramètres, le fingerprint, la seed, le +masque, la matrice et `cv::UsacParams::isParallel=false` restent FROZEN. Le +parallélisme courant est externe à l'estimator : une séquence sélectionne un +préfixe d'au plus seize Match Results indépendants et jusqu'à huit participants +utiles les préparent en parallèle. La fenêtre/participant a été validée sûre +jusqu'à seize, mais la demande production s'arrête à huit selon la preuve de +débit durable ci-dessous. + +Chaque préparation possède ses handles de fichiers Match/Feature et son objet +opaque borné ; le handle Project DB partagé reste sérialisé par son mutex +interne. Les enfants ne publient jamais. Après toutes les jointures, le callback +Queue propriétaire publie seul dans l'ordre des parents, avance uniquement le +préfixe contigu et checkpoint avant toute libération de réservation à +`sequence_break`. Un échec de création partielle stoppe les nouvelles prises de +travail, joint tous les enfants créés et détruit exactement une fois chaque +préparation déjà produite. + +La preuve représentative réelle porte sur 4 113 parents, dont 4 102 +applicables : 578 vérifiés et 3 524 rejetés. Les IDs canoniques, toutes les +colonnes GVR et le digest scientifique +`9401ef6168804b6f1d51f4cdf64cd6b33cbebd2934e5294c8feacc87f9c8ce86` +sont identiques à CPU1/2/4/8/12. Les débits complets, incluant préparation, +publication ordonnée, curseur et checkpoint, valent respectivement +60,7514/83,9556/104,7545/116,6329/119,7606 parents/s. CPU12 n'ajoute que +2,68 % sur CPU8, sous le deadband Governor de 5 % : CPU utile maximal 8, +fenêtre CPU sûre 16 et batch maximal 16. + +La preuve S21 complète historique (172 741 parents traversés, 172 275 +applicables, 24 065 vérifiés et 148 210 rejetés) reste valide avec son chemin +CPU1/batch8 original. Elle n'est pas présentée comme un rerun du nouveau chemin. + ## Échec, reprise et progression Une erreur de calcul à la source `S` interdit toute publication de `S` et de diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index 15ae726..a1a5de6 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -15,13 +15,11 @@ Task ↓ Estimate ↓ -Governor +Task Queue ↓ -Reservation +Resource Governor / Reservation ↓ -Scheduler - ↓ -Worker +callback worker admis ↓ Résultat atomique ↓ @@ -44,8 +42,9 @@ des fichiers admissibles et maintenance d'un manifeste cohérent. **Statut :** IMPLEMENTED ### Import Task -Premier type métier persistant. Il s'exécute par lots bornés dans le scheduler -générique, cible explicitement un ScanSet et peut être reconstruit puis repris. +Premier type métier persistant. Il s'exécute par lots bornés dans le runtime et +la Queue génériques, cible explicitement un ScanSet et peut être reconstruit +puis repris. **Statut :** IMPLEMENTED @@ -56,12 +55,40 @@ mémoire depuis `manifest.tsv` reste une façade legacy pour la TUI. **Statut :** IMPLEMENTED +### Profils optiques et calibrations + +Project DB v23 ajoute un overlay générique distinct pour profils de boîtiers, +objectifs électroniques ou manuels, configurations optiques, affectations aux +campagnes/Captures et calibrations explicitement compatibles. La migration +n'infère ni ne backfill aucune donnée S21/A6000 ; ces appareils restent des +preuves de validation, pas des identités produit. + +**Statut :** IMPLEMENTED — OVERLAY ADDITIF v23 + ### Image View Vues triées et filtrées du catalogue pour la TUI. Ne modifie pas le catalogue, le manifeste ou les images. **Statut :** IMPLEMENTED +### TUI observatoire / centre de contrôle + +Le thread principal possède ncurses, l'entrée et le rendu. Un modèle pur reçoit +des copies bornées de l'unique Queue et du Governor, coalescées autour d'une +seconde, et présente progression durable, ETA honnête, pipeline, ressources, +profils optiques et SSD. Les dimensions validées sont full ≥100×30, compact de +référence 72×20, minimum 60×15, puis le fallback `Terminal trop petit`. Les +couleurs s'accompagnent toujours de libellés textuels et `F10 SSD` reste visible +au minimum supporté. + +Ouvrir, fermer ou changer de projet détruit/joint l'unique Queue avant de +fermer Project DB, puis recrée une Queue vide et rebranche l'observation. Les +ABI historiques Task/Resource/AppState/layout restent inchangées ; les vues +riches utilisent des structures et fonctions additives décrites dans +[Runtime](runtime.md). + +**Statut :** CURRENT / VALIDATED OPERATIONAL + ### Feature Store Extraction ORB réelle par tâche persistante, Feature Sets logiques et assets binaires content-addressed lisibles par plages bornées. @@ -104,10 +131,22 @@ adaptatifs, réservations opaques et historique borné de métriques. **Statut :** IMPLEMENTED +### Contrôleur SSD externe optionnel + +Frontière physique UDisks2 pour la paire de labels `LARDON_SWAP` et +`LARDON_SCRATCH`, avec identité Drive+UUID stable, état borné, leases scratch +et drain sûr. Il ne formate, ne répare ni ne force jamais l'hôte et ne remplace +pas le Resource Governor. La TUI exécute ses actions dans un seul thread +joinable et le Governor enregistre l'état physique puis orchestre seul les +leases scratch de production. Les quatorze Task kinds courants n'en consomment +aucun ; capacité visible ne signifie donc pas usage. + +**Statut :** CURRENT / VALIDATED OPERATIONAL + ### Candidate Pair Sous-système de génération et persistance de paires d'images candidates pour -le matching géométrique. Répond uniquement à « quelles paires valent la -peine d'être试探ées ? » sans validation géométrique. +le matching géométrique. Répond uniquement à « quelles paires valent la peine +d'être explorées ? » sans validation géométrique. **Statut :** IMPLEMENTED @@ -165,7 +204,7 @@ frontière de séquence connue. ## Invariants fondamentaux - Aucun callback de tâche n'est lancé sans réservation active validée. -- Le scheduler ne décide jamais des ressources. +- La Queue ne décide jamais des ressources. - Le Resource Governor est l'unique propriétaire des budgets. - Les réservations sont libérées exactement une fois. - ncurses appartient exclusivement au thread principal. @@ -174,7 +213,7 @@ frontière de séquence connue. ## Limites actuelles -- File à worker unique avec FIFO strict. +- File à worker unique avec FIFO stable et bypass des seuls `WAIT` ressources. - Absence de DAG de dépendances. - Absence de priorités. - Absence de pools de workers multiples (CPU/GPU/IO). diff --git a/docs/architecture/persistence.md b/docs/architecture/persistence.md index 19e071d..a792634 100644 --- a/docs/architecture/persistence.md +++ b/docs/architecture/persistence.md @@ -2,7 +2,10 @@ ## Vision -Lardon3D doit stocker les métadonnées de reconstruction dans une base de données persistante légère, probablement SQLite, tandis que les données numériques massives restent dans des fichiers/binaires adaptés. +Lardon3D stocke les métadonnées de reconstruction dans Project DB SQLite, +tandis que les données numériques massives restent dans des fichiers/binaires +adaptés. Le schéma courant est v23 ; les sections v7 ci-dessous documentent la +fondation historique sans prétendre être la tête de migration. ## Principes fondamentaux @@ -37,6 +40,10 @@ Les éléments suivants sont des concepts de domaine, PAS des tables SQL imposé - track - observation - camera +- camera_body_profile +- lens_profile +- optical_configuration +- optical_calibration_profile - pose - point3d - reconstruction_layer @@ -108,7 +115,7 @@ par le `time_t` local avant conversion. Le format reste donc lisible entre plateformes uniquement pour les valeurs communes à leurs domaines `size_t` et `time_t`. -## Project Database v7 +## Project Database v7 — fondation historique SQLite contient l'état logique interrogable et les références aux fichiers ; les checkpoints et artefacts volumineux restent externes. L'enregistrement du @@ -200,8 +207,9 @@ Les records sont parcourus par task ID croissant. Un checkpoint conserve cet avertissement jusqu'au prochain checkpoint durable. Une tâche terminale n'appartient pas à la requête de reprise. -**NOT_YET_WIRED** — autosave complet, réconciliation des fichiers orphelins et -retry piloté par l'utilisateur pour les sources indisponibles. +**NOT_YET_WIRED** — réconciliation des fichiers orphelins et retry piloté par +l'utilisateur pour les sources indisponibles. Les checkpoints existants sont +kind-owned aux frontières métier ; aucun timer générique ne peut les remplacer. **NOT_YET_WIRED** — migration de la vue TUI en mémoire vers la pagination SQLite, scrub des assets et réconciliation globale des orphelins. @@ -212,4 +220,25 @@ transactionnellement à ses métadonnées SQLite après publication. **IMPLEMENTED** — Visual Index externe segmenté, memberships transactionnels et tâche `visual_index.update` récupérable. -**PLANNED** — migrations v7+ et reprise avec dépendances. +**IMPLEMENTED** — migrations additives et séquentielles jusqu'à Project DB +v23. Les versions v16 à v22 restent l'histoire scientifique et de persistance +gelée ; v23 ajoute uniquement l'overlay optique générique. + +Les neuf relations v23 séparent profils de boîtier et alias, profils d'objectif +et alias, configurations optiques, affectations de configuration aux groupes +de campagne et aux Captures, profils de calibration et sélection explicite par +Capture. Une configuration référence exactement un boîtier et un objectif ; un +objectif manuel sans EXIF est normal. La compatibilité d'une +calibration est exacte sur la configuration optique et ses dimensions/champs +scientifiques. Aucun profil S21, A6000 ou Meike n'est inséré ou déduit par la +migration : les tables nouvelles restent vides tant qu'un caller ne fournit +pas explicitement les données. + +La migration v22→v23 est une transaction additive. Elle ne réinterprète ni les +Captures, ni les Images, ni les résultats scientifiques historiques. Une copie +S21 et une copie A6000 ont atteint v23 avec intégrité et clés étrangères +valides, comptes scientifiques inchangés et tables optiques vides. Les détails +normatifs sont dans [Project Database](project_database.md). + +**NOT_YET_WIRED** — reprise ordonnée par dépendances/DAG et réconciliation +globale des artefacts orphelins. diff --git a/docs/architecture/precision_feature_pipeline.md b/docs/architecture/precision_feature_pipeline.md index 1c3ac69..2a7ee5d 100644 --- a/docs/architecture/precision_feature_pipeline.md +++ b/docs/architecture/precision_feature_pipeline.md @@ -56,9 +56,10 @@ validé. Après crash, la registry statique reconstruit le même `task_id`; l'im est recommencée, sans micro-checkpoint trompeur. Aucun Feature Set partiel ne devient READY. Le SHA-256 de l'Image Asset géré est vérifié avant décodage. -L'estimation SIFT demande jusqu'à douze threads CPU. Le réglage OpenCV du -démarrage est une baseline/plafond sûre ; pour chaque séquence, l'unique -callback Queue applique temporairement le compte immuable admis dans `1..12`, +L'estimation SIFT publie la capacité sûre de l'API OpenCV, `INT_MAX`; le +Governor la borne toujours au compute-pool réel. Le réglage OpenCV du démarrage +est une baseline sûre ; pour chaque séquence, l'unique callback Queue applique +temporairement le compte immuable admis dans `1..compute-pool`, le vérifie puis restaure la baseline sur toute sortie. Une mutation process-wide concurrente par plusieurs workers n'est pas supportée. La tâche réserve aussi un slot IO, aucun GPU et environ 1,06 Gio structurels : @@ -67,8 +68,8 @@ zéro. Le Resource Governor admet donc le fan-out OpenCV exact ; la Queue conserve un seul callback actif et ne superpose pas un second pool SIFT. Le nombre de threads reste opérationnel et absent du fingerprint SIFT/RootSIFT. -La reprise accepte la forme CPU12 courante et normalise uniquement l'ancienne -forme CPU1 complète et exacte dans la copie privée restaurée. Elle ne publie +La reprise accepte les formes historiques CPU12 et CPU1 complètes et exactes, +et les normalise vers la demande portable dans la copie privée restaurée. Elle ne publie aucun checkpoint d'estimation seule ; une forme voisine est rejetée. L'audit contrôlé OpenCV 5.0.0 à 1, 2, 4, 8 et 12 threads compare exactement le count, l'ordre et les valeurs binary32 des champs keypoint persistés, les @@ -88,8 +89,8 @@ après `imread`. La build OpenCV/TBB système n'est pas instrumentée par TSan et rapporte ses propres accès de teardown TLS/`cv::Mat`. Les tests TSan suppriment uniquement les frames des objets partagés `libopencv_core.so` et -`libopencv_features.so`; les frames Lardon3D et toutes les autres bibliothèques -restent contrôlées. +`libopencv_features.so`, ainsi que le runtime externe `libtbb.so`; les frames +Lardon3D et toutes les autres bibliothèques restent contrôlées. ## Consolidation intra-image @@ -153,6 +154,9 @@ maximum 8192, les totaux sont 15,52 GB (14,45 Gio) de descriptors et 16,25 GB images ne sont jamais chargées ensemble. Le Visual Index v1 reste exclusivement ORB-LSH. Candidate Pair Generator, -matching production, vérification géométrique, tracks et SfM sont **PLANNED**. +Matcher ORB/SIFT/RootSIFT, Geometric Verifier v3, Track Model/Builder et Sparse +SfM Gates C–G sont désormais **IMPLEMENTED / PASS-FROZEN à leurs frontières +respectives**. Le pipeline dense complet, mesh, texturing et viewer restent +futurs ; MVS-M1 ne fournit que sa frontière externe bornée FROZEN. ALIKED est **PLANNED / BLOCKED ON MODEL PROVENANCE + VALIDATED ONNX EXPORT** : aucun code ALIKED, ONNX ou Python production n'appartient à v1A. diff --git a/docs/architecture/project_database.md b/docs/architecture/project_database.md index 1eef01d..96a2a29 100644 --- a/docs/architecture/project_database.md +++ b/docs/architecture/project_database.md @@ -1,5 +1,76 @@ # Base de données projet Lardon3D +## Contexte optique générique — Project DB v23 + +**IMPLEMENTED / VALIDATED / REVIEWED.** La tête courante v23 est une migration +transactionnelle additive au-dessus de la fondation scientifique et de +persistance v22 **PASS / FROZEN**. Elle crée neuf tables sans aucune ligne : +`camera_body_profiles`, `camera_body_aliases`, `lens_profiles`, +`lens_profile_aliases`, `optical_configurations`, +`acquisition_campaign_group_optics`, `capture_optical_configurations`, +`optical_calibration_profiles` et `capture_calibration_selections`. + +Boîtier, objectif, configuration optique et calibration sont quatre identités +distinctes. Une configuration lie exactement un boîtier, un objectif et une +focale entière optionnelle en micromètres. Un objectif manuel sans EXIF est un +cas normal : son profil explicite ne requiert aucun alias metadata. Le fixture +Meike vérifie ce chemin générique ; il ne crée aucun branchement produit par +marque ou modèle. Les alias make/model, lorsqu'ils existent, sont des couples +exacts `BINARY`, jamais une recherche fuzzy, une correction de casse ou une +preuve d'identité implicite. + +Une campagne peut assigner une configuration différente à chaque groupe avant +sa matérialisation. Lorsque S3-E retourne le Capture, la même transaction +retient la correspondance groupe→Capture, copie l'affectation optique avec sa +provenance campagne et avance le curseur. Un rollback tardif annule les trois +mutations. Une affectation explicite post-import est possible seulement pour un +Capture encore non affecté ; l'absence reste `NOT_FOUND` et ne crée pas de +profil « inconnu ». + +Un profil de calibration référence une calibration sparse immuable existante et +une seule configuration exacte. Il ne copie, recalcule, interpole ni fabrique +aucun coefficient. Les listes compatibles filtrent uniquement cette identité +exacte. La sélection sur Capture est toujours explicite et exige la même +configuration des deux côtés ; plusieurs profils compatibles restent ambigus +jusqu'à cette sélection. Retry exact et naturel converge, un conflit durable +valide retourne `CONSTRAINT`, tandis qu'une ligne ou dépendance persistée +malformée retourne `CORRUPT` avant comparaison avec une demande alternative. + +La migration v22→v23 n'inspecte ni EXIF, chemin, basename, SHA-256, dimensions, +nom d'appareil, ni calibration historique. Elle ne réinterprète donc aucune +Capture, image ou calibration v16–v22. Le marqueur `schema_version=23` est le +point de publication durable ; un échec de création ou un update de marqueur +qui ne cible pas exactement la v22 rollbacke toute la migration. + +La validation a migré via l'API production des copies des projets réels S21 et +A6000 : version 23, `integrity_check` et clés étrangères propres, comptes et +lignes scientifiques inchangés, neuf tables optiques vides et SHA-256 des +sources inchangés. La suite normale 57/57, dix tests focalisés, cinq cibles +ASan/UBSan, les contrôles de headers C17/C++ et la revue indépendante après +correction sont acquis. Cette preuve porte sur l'overlay et sa compatibilité ; +elle ne crée aucune calibration scientifique pour ces campagnes. + +### Workflow TUI optique courant + +La TUI v23 appelle exclusivement les API publiques bornées ; elle ne modifie +pas directement SQLite. Elle peut inspecter un Capture, rechercher des alias +metadata par égalité exacte, parcourir par pages de 16 les boîtiers, objectifs, +configurations et calibrations compatibles, créer un nouveau profil ou une +nouvelle configuration immuable, puis affecter explicitement un groupe de +campagne ou un Capture lorsque le contrat ci-dessus l'autorise. Un objectif +Meike manuel sans électronique ni alias reste une donnée normale, et un lookup +sans correspondance demeure unresolved. + +La sélection d'une calibration reste explicite et exige l'exacte configuration +du Capture. Une absence, plusieurs candidats, une incompatibilité, `BUSY`, I/O +ou corruption sont présentés comme tels ; la TUI ne fabrique jamais de profil +« inconnu », de compatibilité ou d'identité. `R` permet un retry explicite après +un échec de bind/chargement. `[` revient à la première page et `]` charge la +suivante ; chaque libellé annonce seulement le compte de la page courante et +l'existence exacte d'une suite. Le modèle TUI emprunte la DB jusqu'à unbind et +doit être détaché avant la fermeture projet, conformément au +[runtime](runtime.md#observatoire-tui-actuel). + ## Snapshot d'exécution scientifique sélectionnée — Project DB v22 **PASS / FROZEN.** La migration additive v21→v22 ajoute @@ -76,7 +147,9 @@ avancement de `k` vers `k+1` après publication du résultat `k`, et valeur term Un éventuel `group_index = group_id - 1` reste strictement privé à l'exécuteur. Résultat et curseur sont atomiques avant le checkpoint Task générique. Les lectures valident les types, signes, bornes et relations dans les valeurs SQLite 64 bits avant toute conversion vers -les champs C étroits. Recommandation mesurée et override humain restent distincts. +les champs C étroits et exigent le dispatch générique exact +`photo_quality.triage/v1`. Recommandation mesurée et override humain restent +distincts. ## Capture / Asset Provenance v1 — Project DB v19 (historique FROZEN) @@ -189,7 +262,7 @@ mutation de campagne complète n'a été effectuée. Restent hors S3 : TUI, scrub d'assets et réconciliation des orphelins, ainsi que vidéo et reconstruction aval lorsqu'ils sont applicables. L'identité -opérationnelle persistante de campagne, son exécution par Task/Scheduler/Governor +opérationnelle persistante de campagne, son exécution par Task/Queue/Governor et sa reprise sont définies par Project DB v20 ci-dessous ; ils ne modifient pas le contrat S3. @@ -205,9 +278,12 @@ existante ; un échec de migration laisse la v19 complète et utilisable. `task_id`, qui est l'identité opérationnelle de la campagne. La création et les checkpoints enregistrent dans une même transaction le snapshot Task générique, sa référence de checkpoint et le record typé de campagne. Ce record conserve le -`scanset_id`, le curseur `next_group_id`, le nombre de groupes et une requête -immuable : un upsert ne peut faire progresser le curseur que si ScanSet, nombre -de groupes et octets de requête sont identiques. +`scanset_id`, le curseur historique `next_group_id`, le nombre de groupes et +une requête immuable. Pour cette table de campagne, malgré son nom historique, +le curseur est une position de prochain travail à base zéro dans `0..N` : sa +valeur est le nombre de groupes one-based déjà retenus. Un upsert ne peut le +faire progresser que si ScanSet, nombre de groupes et octets de requête sont +identiques. La requête v1 est un codec borné, déterministe, à magic/version explicites et champs entiers de largeur fixe little-endian. Elle sérialise les sources, leurs @@ -219,11 +295,15 @@ et ne confèrent que la basis `CALLER_EXPLICIT`, jamais une inférence `STRONG`. Les groupes de plan gardent leurs IDs un-based. Pour chaque groupe achevé, `acquisition_campaign_captures` conserve la relation unique `(task_id, group_id) → capture_id` et interdit qu'un même Capture corresponde à -deux groupes de la même tâche. Après le retour de S3-E, une transaction unique -retient cette relation et avance `next_group_id`, avant la progression générique -et le checkpoint. La reprise peut alors transmettre le `capture_id` retenu à -S3-E, sans réhoming ni inférence depuis un chemin, SHA, basename, timestamp, -métadonnée ou `image_id`. +deux groupes de la même tâche. L'état durable valide contient exactement le +préfixe de mappings `1..next_group_id`, aucun trou et aucun mapping en avance ; +chaque Capture existe dans le ScanSet de la campagne. Après le retour de S3-E, +une transaction unique retient la relation du groupe courant et avance ce +curseur, avant la progression générique et le checkpoint. La reprise peut alors +transmettre le `capture_id` retenu à S3-E, sans réhoming ni inférence depuis un +chemin, SHA, basename, timestamp, métadonnée ou `image_id`. Une storage class, +borne, dispatch kind/version ou relation durable malformée retourne `CORRUPT` +avant toute mutation et ne fournit jamais un faux `resume_capture_id`. Chaque séquence matérialise un seul groupe. Pause et annulation restent coopératives aux frontières de groupe ; après un groupe non terminal, @@ -291,9 +371,9 @@ exactes conservent leur sémantique. ## Gate F — décision historique Project DB v17 **PASS / FROZEN.** La migration historique v15→v16 et le -modèle de reconstruction Sparse SfM v16 restent inchangés. Gate F fait avancer -la tête de schéma à v17 par une migration strictement additive contenant une -seule table métier de tâche : `sparse_sfm_tasks`. +modèle de reconstruction Sparse SfM v16 restent inchangés. Gate F a fait +avancer la tête de schéma alors courante à v17 par une migration strictement +additive contenant une seule table métier de tâche : `sparse_sfm_tasks`. Cette table suit le modèle des autres Task Kinds durables : une ligne par `task_id`, clé primaire et clé étrangère vers `tasks(task_id)` avec suppression @@ -326,8 +406,10 @@ persistée exactement ; seule une composante de graphe non reconstruite est omise. Les comptes et métriques globaux décrivent exclusivement la géométrie effectivement persistée. Aucun placeholder ni table diagnostique n'est ajouté. -> Version courante : **v20**. La migration transactionnelle additive v19→v20 -> ajoute les records de campagne durable décrits ci-dessus sans modifier les +> Version publiée par cette section historique : **v20** ; la tête actuelle +> est v23 comme défini en ouverture du document. La migration transactionnelle +> additive v19→v20 ajoute les records de campagne durable décrits ci-dessus +> sans modifier les > tables ni identités Capture/Asset de v19. La migration transactionnelle > v18→v19 ajoute la fondation Capture/Asset Provenance sans modifier les tables > scientifiques historiques. La migration transactionnelle v17→v18 ajoute le @@ -924,23 +1006,27 @@ fermeture/réouverture et payload corrompu. ## Ouverture et migrations -Une DB vide reçoit la chaîne de schémas jusqu'à v15 dans une transaction +Une DB vide reçoit la chaîne de schémas jusqu'à v23 dans une transaction `BEGIN IMMEDIATE`. Une DB v1 reçoit transactionnellement les colonnes nullable `task_kind` et `task_kind_version`, puis les migrations v2→v3. Les anciennes lignes restent `NULL/NULL`, sans type inventé et sans perte des projets, tâches, checkpoints ou artefacts. Une interruption ou erreur provoque un rollback complet. Les DB v1 -à v14 sont migrées séquentiellement vers v15. +à v22 sont migrées séquentiellement vers v23. Une v10 publiée est validée comme telle avant que v10→v11 crée `matcher_tasks` ; son absence n'est donc pas une corruption. Une version future est refusée et une DB contenant des tables sans métadonnée de version est considérée corrompue. La fonction interne de migration applique uniquement la chaîne séquentielle connue jusqu'à -v15 ; une valeur hors de 1..15 est refusée. La failure injectée v12 rollbacke +v23 ; une valeur hors de 1..23 est refusée. Les failures injectées de chaque +étape rollbackent les objets et le marqueur de version de cette étape. Par +exemple, la failure v12 rollbacke la table, l'index et le changement de version, laissant une vraie v11 utilisable. La failure injectée v13 conserve une vraie v12 sans `geometric_verifier_tasks` ; un retry applique ensuite v12→v13. La failure injectée v14 conserve une vraie v13 sans `track_sets` ; un retry applique ensuite v13→v14. La failure injectée v15 conserve une vraie v14 sans `track_builder_tasks` ; un retry applique -ensuite v14→v15. +ensuite v14→v15. Le même contrat couvre v16→v22 ; en v23, aucune des neuf +tables optiques ni aucun marqueur v23 ne subsiste après la failure injectée, et +un retry exact converge depuis la v22 intacte. Migration v1→v2 exacte, exécutée entre `BEGIN IMMEDIATE` et `COMMIT` : @@ -1066,7 +1152,7 @@ l'attente. Il choisit le canonique codec/version valide dont les champs du résumé DB correspondent exactement. Ce choix est prioritaire et ignore une `.next` périmée ou corrompue. Si le canonique ne correspond pas, une `.next` codec/version valide dont le même résumé correspond est promue sous le verrou -puis utilisée. L'absence de `.next` reste normale pour un projet v22 legacy +puis utilisée. L'absence de `.next` reste normale pour un projet v22 ou antérieur contenant seulement `.chk`; toute autre absence, corruption, version future ou divergence classe seulement la tâche comme non récupérable. @@ -1089,9 +1175,17 @@ ouvert. ## Statut -**PASS / FROZEN** — SQLite système, schéma v20 et migrations séquentielles -v1→v2→v3→v4→v5→v6→v7→v8→v9→v10→v11→v12→v13→v14→v15→v16→v17→v18→v19→v20, identité projet, transactions -tâche+checkpoint, pagination de reprise et artefacts génériques. +**IMPLEMENTED / VALIDATED / REVIEWED** — SQLite système, tête de schéma v23 et +migrations séquentielles v1→…→v23, identité projet, transactions +tâche+checkpoint, pagination de reprise, artefacts génériques et overlay +optique additif. Les contrats scientifiques et de persistance v16–v22 conservent +leurs statuts historiques PASS / FROZEN. + +**IMPLEMENTED / VALIDATED / REVIEWED — Project DB v23** : profils boîtier et +objectif data-driven, alias exacts optionnels, configurations multiples par +campagne/Capture, profils de calibration exactement compatibles et sélection +explicite. La migration ne backfill aucune identité ; les projets v22 restent +récupérables et scientifiquement inchangés. **PASS / FROZEN** — Project DB v20 : migration additive v19→v20, record typé de campagne lié au Task ID, requête immuable bornée, diff --git a/docs/architecture/reconstruction_pipeline.md b/docs/architecture/reconstruction_pipeline.md index 0f5c58b..0c25579 100644 --- a/docs/architecture/reconstruction_pipeline.md +++ b/docs/architecture/reconstruction_pipeline.md @@ -38,7 +38,9 @@ vagues et relations géométriques entre acquisitions restent planifiées. | **Asset** | Le contenu physique est identifié par `asset_id`, SHA-256, taille et chemin géré. Plusieurs images logiques peuvent partager cet asset. | **Statut :** IMPLEMENTED v1 — catalogue SQLite paginé et assets -content-addressed. Les états Feature/Matching/Reconstruction restent planifiés. +content-addressed. Feature Sets, Match Results, GVR, Tracks et Sparse +Reconstruction sont désormais persistés par leurs relations spécialisées ; ils +ne deviennent pas pour autant un champ d'état mutable de l'image catalogue. --- @@ -69,7 +71,8 @@ descriptors. | **Proximité temporelle comme signal secondaire** | Lorsque les images portent un horodatage EXIF, la proximité temporelle sert de signal complémentaire au contenu visuel, mais ne remplace jamais l'analyse visuelle. | **Statut :** IMPLEMENTED v1 — LSH binaire déterministe, segments immuables, -updates incrémentales et query top-K bornée. Le générateur de paires reste planifié. +updates incrémentales et query top-K bornée. Le générateur de paires en aval est +également implémenté et conserve sa propre identité/persistance. --- @@ -405,7 +408,7 @@ Ces invariants s'appliquent à toutes les étapes du pipeline : points 3D existants ne jamais supprimés implicitement. Toute suppression est une action explicite et traçable. -4. **Le scheduler ne décide jamais des ressources.** +4. **La Queue/runtime ne décide jamais des ressources.** Seul le Resource Governor arbitre les budgets, les lots et les réservations. diff --git a/docs/architecture/resource_aware_pipeline.md b/docs/architecture/resource_aware_pipeline.md index 899aee2..a997f52 100644 --- a/docs/architecture/resource_aware_pipeline.md +++ b/docs/architecture/resource_aware_pipeline.md @@ -13,11 +13,12 @@ desktop. Les signaux d'admission combinent `MemAvailable`, charge CPU, PSI CPU, PSI mémoire, PSI I/O et deltas `pswpin`/`pswpout`. Un seuil dépassé empêche une nouvelle admission ; il ne rompt pas une réservation saine déjà active. -Le Governor maintient trois zones. GREEN emploie le lot adapté normal. La soft -floor RAM, un PSI au seuil ou un premier intervalle avec swap actif produit -YELLOW et interdit toute croissance. Deux observations de pression -consécutives, ou `MemAvailable` sous la hard floor, produisent RED et suspendent -toute admission. Le premier snapshot swap établit seulement la baseline. +Le Governor maintient trois zones. GREEN emploie le lot adapté normal. La zone +de prudence RAM entre 3 et 4 Gio, un PSI au seuil ou un premier intervalle avec +swap actif produit YELLOW et interdit toute croissance. Deux observations de +pression consécutives, ou `MemAvailable` sous la réserve dure de 3 Gio, +produisent RED et suspendent toute admission. Le premier snapshot swap établit +seulement la baseline. La récupération possède deux phases distinctes : trois observations saines font `RED → YELLOW`, puis trois nouvelles observations saines font @@ -43,8 +44,11 @@ observe maintenant le RSS/HWM courant dans un buffer borné, uniquement comme diagnostic du processus : il ne le confond ni avec la réservation Task ni avec un coût attribuable. Le modèle cible un hôte Linux natif non contraint ; cgroups, limites systemd/RLIMIT, multi-GPU, historique/monitoring RSS long terme, -redimensionnement d'admission depuis le RSS, scratch et stockage externe restent -différés. +redimensionnement d'admission depuis le RSS et consommation Task du scratch +restent différés. Le contrôleur SSD optionnel gère le cycle de vie physique ; +son état est enregistré auprès du Governor, qui est l'unique orchestrateur des +leases scratch de production. Aucun des quatorze kinds actuels ne les consomme, +et ni le registre, ni les leases, ni le swap ne créent un budget RAM. ## Feature Extraction @@ -53,9 +57,10 @@ publication Feature Store, métadonnées DB, checkpoint terminal et libération buffer. Le batch vaut donc une image et la granularité de reprise est une image. Le worker unique et la file bornée fournissent la backpressure actuelle. -Le démarrage configure une baseline et un plafond OpenCV sûrs avant la création -de Queue. L'unique callback lourd applique ensuite temporairement le compte CPU -immuable admis pour sa séquence, dans `1..12`, et restaure la baseline sur toute +Le démarrage configure une baseline OpenCV issue du compute-pool réellement +disponible avant la création de Queue. L'unique callback lourd applique ensuite +temporairement le compte CPU immuable admis pour sa séquence, dans +`1..compute-pool`, et restaure la baseline sur toute sortie, y compris après une mutation suivie d'un échec de vérification. La tâche réserve donc le nombre réellement appliqué au lieu d'annoncer artificiellement un thread pendant qu'une primitive interne en utilise davantage. Une mutation @@ -83,11 +88,15 @@ recalcule pas les descripteurs. ## Geometric Verification Project DB v12 stocke un résultat borné à 1024 octets de masque et neuf -binary64. `geometric_verifier.run` v1 traite un Match Result atomique, publie -par une transaction courte puis checkpoint son curseur par lots 1/2/4/8 avant +binary64. `geometric_verifier.run` v1 traite chaque Match Result comme unité +scientifique atomique, publie par une transaction courte puis checkpoint son +curseur par lots 1..16 avant `task_sequence_break()`. Sa ligne durable appartient à Project DB v13. Le job -réserve un CPU logique et 4 Mio, sans slot GPU. Admission, pression, lots et -slow-start restent exclusivement décidés par Runtime et Governor. +peut employer jusqu'à huit participants utiles et seize participants sûrs, par +lots au plus seize, avec 8 Mio par parent et sans slot GPU. Le propriétaire +publie le préfixe canonique dans l'ordre. Admission, pression, lots et +slow-start restent exclusivement décidés par Runtime et Governor ; l'USAC +scientifique interne conserve `isParallel=false`. ## GPU et files @@ -110,12 +119,13 @@ reste inchangée. ## Profil interactif 8845HS mesuré -- budget CPU Lardon3D : 12 threads logiques, 4 réservés au desktop ; -- réserve `MemAvailable` : un quart de la RAM, environ 3,8 Gio ; -- hard floor `MemAvailable` : un huitième, environ 1,9 Gio ; +- budget CPU Lardon3D observé : 12 threads logiques, 4 réservés au desktop ; +- réserve dure `MemAvailable` : 3 Gio ; +- zone de prudence `MemAvailable` : de 3 à 4 Gio ; - Feature workers : 1 ; batch : 1 image ; - Matcher workers : 1 ; lots adaptatifs 1, 2, 4 ou 8 Candidate Pairs ; -- Geometric Verifier workers : 1 ; lots adaptatifs 1, 2, 4 ou 8 Match Results ; +- Geometric Verifier : un callback propriétaire, jusqu'à 8 participants utiles, + lots adaptatifs jusqu'à 16 Match Results ; - profondeur de la Task Queue : 64 tâches légères, un seul callback actif ; - PSI CPU avg10 : nouvelle admission suspendue à 20 % ; - PSI mémoire avg10 : nouvelle admission suspendue à 1 % ; @@ -134,10 +144,13 @@ une distribution de latence estimator-only. ## Limites -Le profil maximal explicite et les pools multi-workers restent hors périmètre. +Les pools multi-workers restent hors périmètre. La capacité CPU portable est +désormais bornée par le compute-pool de l'hôte et la limite intrinsèque du kind, +jamais par un plafond global 12. SIFT/RootSIFT et Feature Extraction Vulkan restent hors de ce contrat. Swap, zram et disque externe ne sont jamais ajoutés au budget RAM. Aucun chemin -scratch/spill ni aucune action modifiant l'hôte n'appartient à Gate G core. +scratch/spill Task n'appartient à Gate G core. La commande SSD optionnelle est +une intégration opérationnelle additive, pas une admission scientifique. La validation B3 du modèle Sparse SfM v16 a utilisé des processus frais, un fixture synthétique de 100 000 landmarks et 500 000 observations, cinq passes diff --git a/docs/architecture/resource_boundary.md b/docs/architecture/resource_boundary.md index abfec5e..3605f96 100644 --- a/docs/architecture/resource_boundary.md +++ b/docs/architecture/resource_boundary.md @@ -5,10 +5,14 @@ **ACCEPTED** — architecture decision for the post-Gate C documentation freeze. Current Sparse SfM gates A through G are **PASS / FROZEN**. -Project Database: current schema **v22**; historical v16 remains frozen. +Project Database: current schema **v23**; the v22 foundation and historical +v16 science remain frozen. -This record is normative for the current architecture. It does not introduce -an implementation, a public API, a persistence format or a roadmap commitment. +This record is normative for the separation of responsibilities. Its original +Gate C/G decision introduced no implementation or syntax. Later explicitly +authorized additions, including the additive Project DB v23 optics overlay and +the bounded UDisks2 SSD controller, must remain on their own side of this +boundary and do not create a generic Resource System. ## Context @@ -24,7 +28,8 @@ resolver, runtime cache, resource handle or runtime dependency graph. ### 1. Persistent identity and metadata -Project Database v16 owns logical identities and relations. SQLite IDs identify +Project Database v23 owns current logical identities and relations; v16 remains +the historical Sparse SfM persistence boundary. SQLite IDs identify projects, ScanSets, images, assets, Feature Sets, results, tracks and Sparse SfM records. SHA-256 values identify published file contents. Metadata stores sizes, states, provenance and project-relative artifact paths. @@ -55,7 +60,10 @@ generic unload operation or generic dependency lifetime. The Resource Governor owns policy decisions for RAM, GPU, CPU and IO budgets. It consumes immutable task estimates, samples system pressure, chooses bounded lots and creates opaque transient reservations. A task must hold an active -reservation before executing its callback and releases it exactly once. +reservation before executing its callback and releases it exactly once. The +later additive SSD integration also makes it the sole production orchestrator +for scratch leases, without adding scratch to RAM admission or to the Task +estimate. The Governor does not own persistent IDs, resolve paths, validate file formats, load application objects, cache data or manage semantic dependencies. @@ -88,11 +96,13 @@ System by this decision. ## Resource Governor boundary -The Governor remains resource-type agnostic. It answers whether an operation -fits current budgets; it does not answer what an asset is or where it lives. -Tasks create estimates, request reservations, execute bounded work and release -reservations. The Governor owns only policy, budgets, pressure, lots and -reservations. +The Governor remains independent of scientific/artifact identity. It answers +whether an operation fits current budgets; it does not answer what an asset is +or where it lives. Tasks create estimates, request reservations, execute +bounded work and release reservations. The additive external-storage registry +copies physical capacity/state and arbitrates process-local scratch leases, but +does not resolve paths, own files, persist device identity or add SSD/swap to +RAM. ### Task demand declaration versus admission policy @@ -169,9 +179,12 @@ available_ram = max( ) ``` -The implementation retains its existing checked, saturating subtraction order. -The host reserve remains one quarter of physical RAM and the emergency floor -remains one eighth of physical RAM. +The implementation retains its checked, saturating subtraction order. On a +capable host, the default hard/admission reserve is approximately 3 GiB of +`MemAvailable`; the 3–4 GiB interval is a non-escalating caution band and does +not subtract 4 GiB from capacity. Smaller hosts use a deterministic fractional +reserve so at least bounded work remains possible. A custom policy can still +set explicit reserve/floor values. Possible overlap between `MemAvailable` and already-materialized memory from an active reservation is accepted. The deliberate bias is false `WAIT`, not overcommit. Gate G adds no RSS tracking, materialization state, allocation @@ -215,17 +228,43 @@ topologies are deferred. #### Scratch, persistence and science -Scratch and external-storage support is excluded from Gate G core. It adds no -scratch estimate fields, demand/availability API, manager, allocator, removable -media monitor, mount logic, capacity policy, spill contract, cleanup contract, -project scratch path or SSD detection. Sparse SfM has no scratch consumer, -spill algorithm or out-of-core path. Swap, zram and external SSD capacity never +Scratch demand remains excluded from Gate G core: no scratch field was added to +`Lardon3DResourceEstimate`, and Sparse SfM still has no scratch consumer, spill +algorithm or out-of-core path. Swap, zram and external SSD capacity never enlarge scientific RAM admission. -No automatic `swapon`, `swapoff`, mount, unmount, formatting, partitioning or -destructive cleanup is authorized. A future feature requires a real task -consumer, an explicit task-specific contract, user opt-in where appropriate and -a separate architecture review. No dormant generic API is introduced. +An explicitly authorized, bounded SSD controller owns physical discovery and +lifecycle through UDisks2/GDBus: exact labels, stable Drive+filesystem UUID +identity, fixed-capacity low-level scratch capabilities and safe drain. It is +not a scheduler, Governor, Task admission path, generic allocator or project +persistence layer. It never formats, partitions, repairs, powers off, forces +swap removal or performs destructive cleanup. No code outside that reviewed +controller may add ad-hoc `swapon`, `swapoff`, mount/unmount or shell control. + +The controller's validated bounded snapshot is registered with the Governor. +The Governor is the only production entry for scratch acquire/release and +fails closed during drain, error, absence, replacement or unknown authority. +This is process-local operational ownership, not a new scientific resource +dimension: no scratch field is added to `Lardon3DResourceEstimate`, and all +fourteen current Task kinds have zero scratch consumers. A future consumer +still requires an explicit Task-specific eligibility, lifetime and cleanup +contract. Registered scratch/swap totals remain telemetry and never enlarge +host-memory capacity. + +Validation is state-complete, not a friendly-enum shortcut. Pairing or any +control/allocation authority requires the currently detected Drive and both +UUID-bearing partitions, exact nonempty identities, known positive partition +extents and coherent activity/mount/drain/capability facts. Contradictory +`ABSENT` data is malformed; partial `DETECTED` is non-actionable. A disconnected +sticky hazard remains a non-allocating `ERROR`, and only the exact original +tuple after complete reconnection can regain drain authority. + +The source generation legally saturates at `UINT64_MAX`. Public same-generation +material changes remain stale and cannot regrant authority. The only exception +is private provenance for the exact serialized Governor wrapper operation: it +may reconcile its own saturated acquire/release and the address-backed lease +count, then permit checked unregister. This does not create a generic +same-generation update or weaken fail-closed telemetry. Gate G core requires no Project DB v18, resource-history, reservation, telemetry, policy or scratch table. Reservations and snapshots remain ephemeral; @@ -266,24 +305,40 @@ allocations and is not classified as a Lardon3D Gate G leak. The real-machine `orb-vulkan-backend` test, `git diff --check`, and the complete human diff review pass. No Gate G human decision or implementation blocker remains. +The later global-maintenance sanitizer run preserves that qualification rather +than overwriting it: its first full LSan pass reports the same exact external +signature in five OpenCL-loading tests, plus two nonreproduced 30-second timing +events. ASan/UBSan then pass 64/64 with leak detection disabled, and a +loader-free project-owned subset passes LSan 20/20. Fresh GCC TSan covers the +resource/controller/Queue/TUI concurrency boundary inside its 14/14 plus 220 +repeat matrix; Vulkan is deliberately outside TSan and is validated by the +fresh hardware suite 65/65. The independent final review subsequently passed +with zero blocking findings, so the enclosing lifecycle is now +`GLOBAL_MAINTENANCE_AUDIT=PASS/FROZEN`. This freezes the audited resource +boundary without turning scratch, swap or future dense work into RAM capacity. + ## Project DB boundary The historical Project DB v16 migration and Sparse SfM reconstruction model -remain unchanged and frozen. Gate F is authorized to advance the current schema -head to v17 only with the dedicated `sparse_sfm_tasks` typed-payload table; that -task persistence does not add a resource table, handle table, cache table, -dependency table or resource-policy field. +remain unchanged and frozen. Gate F advanced that historical head to v17 only +with `sparse_sfm_tasks`; later authorized migrations through v23 likewise add no +resource table, handle table, cache table, dependency table or Governor policy +field. The v23 optical tables describe camera/lens/calibration context, not +runtime resource residency. -No further schema expansion, bundle redesign, Track Model change, Track Builder -change or persistence API redesign is part of this resource-boundary scope. +This resource-boundary decision itself authorizes no future schema expansion, +bundle redesign, Track Model change, Track Builder change or persistence API +redesign. ## Options considered ### A. Governor evolution -Rejected for the current ticket. The Governor is already the correct owner of -budgets, estimates, pressure, lots and reservations. Extending it toward -identity or artifact ownership would violate the current boundary. +Rejected by the original no-new-subsystem decision when it meant scientific +identity or artifact ownership. The later explicitly authorized, additive +external-storage registry stays within the same boundary: it copies physical +state and arbitrates leases, but owns no file, path, persistent identity or +generic loaded resource. ### B. Internal artifact resolver @@ -315,8 +370,9 @@ resolver or Governor asset integration as part of the current architecture. - Existing persistent identities are sufficient. - Existing stores already validate their own artifacts. - Existing task and backend ownership is explicit and bounded. -- Existing Governor policy is sufficient for resource admission. -- No new public syntax or ABI is required. +- Existing Governor policy remains sufficient for RAM/CPU/GPU/I/O admission. +- No new persistent syntax or replacement ABI is required; later observation + and scratch APIs are additive C17 surfaces. - Gate B v16 remains untouched. - The decision is reversible if a concrete future requirement appears. @@ -337,7 +393,9 @@ resolver or Governor asset integration as part of the current architecture. - Generic dependency graph or cycle management. - Streaming or asynchronous resource loading. - CPU/GPU dual-residency manager. -- Project DB schema beyond the additive Gate F v17 typed-task migration. +- Project DB schema changes justified only as a generic Resource System. The + separately authorized historical v18–v22 and current v23 migrations remain + governed by their own persistence contracts. - Bundle redesign. - Governor integration with persistent asset identity. - BA, new orchestration, Task Runtime redesign or renderer redesign. @@ -351,20 +409,29 @@ contracts remain authoritative in their own domains. ## Persistence impact -The historical Project DB v16 migration remains unchanged. Gate F adds only the -v17 typed-task payload already described above. Existing IDs, hashes, metadata -and relative paths are not renamed, generalized or duplicated. +The historical Project DB v16 migration remains unchanged. Gate F added only +the v17 typed-task payload described above; the current v23 overlay is additive +and creates no backfill. Existing scientific IDs, hashes, metadata and relative +paths are not renamed, generalized or duplicated by resource management. ## Memory / resource impact -None beyond existing task estimates and Governor reservations. No new cache or -resident object ownership is introduced. Existing bounded readers and backend -buffers remain responsible for their own memory. +The original no-new-subsystem decision added none beyond existing task +estimates and Governor reservations. The later SSD controller owns only its +fixed-capacity snapshot/lease/action state; the Governor retains one bounded +copy and fixed wrapper-operation/lease accounting. Neither adds a cache, Task +residency or RAM budget. Existing bounded readers and backend buffers remain +responsible for their own memory. ## Concurrency impact -None. No new shared mutable state, worker, callback, lock or lifetime protocol -is introduced. Existing Governor mutex/condition ownership remains unchanged. +The original decision introduced no shared mutable state, worker, callback, +lock or lifetime protocol. The SSD controller owns its own mutex and fixed +lease table. The TUI binding owns at most one joinable bounded-operation thread +and the Governor owns its registry mutex state. Governor never holds its mutex +while entering the controller, and the controller never calls Governor. At +shutdown Queue/Task leases end before unregister, then controller ends before +Governor; no background polling loop or second scheduler exists. ## Security impact diff --git a/docs/architecture/resource_governor.md b/docs/architecture/resource_governor.md index 0ca62e9..1894315 100644 --- a/docs/architecture/resource_governor.md +++ b/docs/architecture/resource_governor.md @@ -13,18 +13,27 @@ Cette couture privée est gelée ; seule une séquence ultérieure peut recevoir un autre contrat. Les dimensions retenues, le corpus S21 complet, les suites portable/Vulkan, les sanitizers et l'audit XHIGH ferment le statut v2. -Sur le profil validé Ryzen 7 8845HS, le Governor lit le masque d'affinité permis -et la topologie package/core Linux. Il réserve des groupes de coeurs physiques -complets, frères SMT inclus, en préférant déterministiquement les plus grands -IDs package/core. Avec le masque unrestricted 0–15 et une réserve logique de -quatre, il dérive le pool lourd `0-5,8-13` et réserve `6,7,14,15` au desktop -Arch/Sway, à l'audio et à l'interaction ordinaire. Si le caller est déjà limité -à au plus `logical_total - reserve`, son masque permis devient directement le -pool de calcul sans seconde soustraction. Sans affinité/topologie exploitable, -le budget portable `logical_total - reserve` subsiste sans exclusion arbitraire -de frères SMT et l'affinité est diagnostiquée inactive. Cette politique privée -est **PASS / FROZEN** sur le profil validé ; aucun ID CPU n'est persisté ni ne -devient une identité scientifique. +**PORTABLE_HOST_POLICY_MAINTENANCE — IMPLEMENTED / VALIDATED / REVIEWED.** La +maintenance ultérieure retire les plafonds matériels globaux issus de l'hôte +de preuve, sans changer la science, les formats, les fingerprints ni la +durabilité v2. Son lifecycle global reste suivi par +[`GLOBAL_MAINTENANCE_AUDIT`](global_maintenance_audit.md). + +Le Governor lit le masque d'affinité permis et la topologie package/core Linux. +La politique par défaut demande quatre CPU logiques de réserve lorsque c'est +praticable et conserve au moins un CPU de calcul sur un petit hôte. Avec une +topologie complète, elle réserve des groupes de coeurs physiques complets, +frères SMT inclus, en choisissant le sous-ensemble déterministe dont le +dépassement de la cible logique est minimal. Les CPU déjà exclus par un masque +externe comptent dans la réserve hôte, donc la même capacité n'est jamais +soustraite deux fois. Sans affinité/topologie exploitable, le budget de compte +portable subsiste sans fabriquer de masque ni d'ID CPU. + +Sur le profil de preuve Ryzen 7 8845HS unrestricted 0–15, la cible logique de +quatre donne le pool lourd `0-5,8-13` et la réserve `6,7,14,15` pour Arch/Sway, +l'audio et l'interaction ordinaire. Ces IDs sont une observation historique de +cet hôte, pas une politique portable. Aucun ID CPU n'est persisté ni ne devient +une identité scientifique. Seul le worker lourd de l'unique Queue applique et relit son propre masque (`pid=0`) avant les callbacks. Le caller/main/TUI reste unrestricted et aucun @@ -52,15 +61,20 @@ Le nombre de CPUs du pool borne directement l'admission. CPU Matcher reste validé dans `1..12`, indépendamment du lot : `cpu_threads=12, batch_size=1` demeure un contrat valide. -La cible Compute Governor v2 conserve 3 GiB de `MemAvailable` et ne franchit -pas intentionnellement le plancher dur de 2 GiB sur cette classe d'hôte 16 GiB. +La politique par défaut conserve environ 3 GiB de `MemAvailable` comme réserve +dure d'admission sur un hôte capable. Entre 3 et 4 GiB, elle entre en prudence +YELLOW et bloque la croissance, mais ne soustrait jamais 4 GiB à la capacité et +ne transforme pas cette bande stable en RED. Un hôte de moins de 3 GiB dégrade +la réserve en fraction déterministe afin de garder une capacité bornée. Les entrées de pression actives sont `MemAvailable`, les PSI CPU/mémoire/I/O et les deltas swap-in/swap-out entre observations. L'occupation totale du swap est un état historique, pas à elle seule une activité récente. Ces objectifs v2 ne réécrivent pas rétroactivement les constantes Gate G core gelées ci-dessous. Les observations saines autorisent une croissance lente. Pour une dimension -CPU réellement réductible, la rampe est exactement `1 → 2 → 4 → 8 → 12`, -bornée par l'enveloppe et le compute-pool. Deux séquences établissent d'abord +CPU réellement réductible, la rampe double depuis 1 et inclut toujours le +maximum exact de la capacité lorsqu'il n'est pas une puissance de deux. Elle +est bornée par l'enveloppe et le compute-pool, sans plafond global 12. Deux +séquences établissent d'abord une référence de débit durable puis ouvrent un essai borné au palier supérieur. Les dimensions CPU/génériques conservent deux observations d'essai et un gain d'au moins 5 % avant une nouvelle croissance. Le lot ORB Vulkan normal exige @@ -136,7 +150,8 @@ lorsque cette petite capacité reste incertaine. Sur l'hôte validé, les preuve exactes sont 512 Mio de VRAM visible et 7 986 020 352 octets de GTT pour environ 16 Gio de RAM. Une classification UMA conservatrice d'un GPU à faible VRAM peut refuser inutilement une admission ; classer à tort cet iGPU comme mémoire libre -séparée pourrait contourner les cibles 3 Gio/2 Gio et est interdit. La capacité +séparée pourrait contourner la réserve dure de 3 Gio et la prudence 3–4 Gio, +ce qui est interdit. La capacité rapportée ne forme donc aucun second budget VRAM indépendant sur UMA. La politique canonique v2 est GPU-first lorsqu'un backend est validé, @@ -265,39 +280,42 @@ privée de sûreté/benchmark peut retenir deux slots, soit 1,25 Mio, seulement sous un contrat forcé depth 2. Device, pipeline, layouts et cache restent partagés; leurs allocations driver opaques ne reçoivent pas un coût inventé. -| Kind v1 | Estimation courante | Dimensions réellement consommées / constat Phase 1 | +| Kind v1 | Estimation courante | Dimension consommée et raison | | --- | --- | --- | -| `raw.develop` | MIXED; CPU 1; lot 1; hôte `2 Gio + contexte`, 0/item; I/O 1; GPU 0 | Fixe et atomique. Un garde Queue applique CPU1 au pool OpenCV process-wide puis restaure la valeur précédente. | -| `photo_quality.triage` | IMPORT; CPU 1; lot 1; hôte `contexte retenu + 20 Mio`, 0/item; I/O 1; GPU 0 | Un groupe par séquence. Un garde Queue applique/restaure CPU1 ; lot et contexte sont honnêtes. | -| `acquisition_campaign.run` | IMPORT; CPU 1; lot 1; hôte `contexte retenu + 256 Kio` + 64 Kio/item; I/O 1; GPU 0 | Sources, confirmations, requête et plan sont chargés avant admission. La reprise remplace seulement l'enveloppe opérationnelle historique sous-estimée par ce coût exact ; le snapshot durable reste inchangé. | -| `import.images` | IMPORT; CPU 1; lot 1..32; hôte 128 Kio + `NAME_MAX+64`/item; I/O 1; GPU 0 | Le callback consomme exactement le lot admis et réadmet entre lots. | -| `features.extract` | CPU; CPU 1..12; lot 1; hôte 64 Mio + 512 Mio/item; I/O 1; GPU 0 | Une image. Le callback applique/restaure exactement le CPU admis dans OpenCV, y compris si la vérification échoue après mutation. Sortie égale à 1/2/4/8/12. | -| `features.extract.sift` | CPU; CPU 1..12; lot 1; hôte 64 Mio + 1 Gio/item; I/O 1; GPU 0 | Même enforcement/rollback adaptatif. La forme durable courante reste CPU12 et la forme historique CPU1 exacte est normalisée en mémoire. | -| `features.extract.rootsift` | CPU; CPU 1..12; lot 1; hôte 64 Mio + 1 Gio/item; I/O 1; GPU 0 | Même exécution adaptative que SIFT ; aucune couture GPU validée. | -| `visual_index.update` | CPU; CPU 1..12; lot 1..16; hôte 8 Mio + 2 Mio/item; I/O 1; GPU 0 | CPU et lot sont lus du contrat; au plus `cpu_threads-1` enfants joints avant publication. | -| `candidate_pair.generate` | CPU; CPU 1..12; lot 1..64; hôte 256 Kio + 64 Kio/item; I/O 1; GPU 0 | CPU et lot sont consommés; fenêtre `min(2*CPU, 24)`. La forme sérielle historique exacte est normalisée en mémoire. | -| `matcher.run` | CPU: CPU 1..12, lot 1..12, hôte 0 + 10 Mio/item, I/O 1, GPU 0. ORB Vulkan normal: CPU 1, lot 1..8, même hôte/item, I/O 1, GPU 1 + 640 Kio, inflight 1. | ORB AUTO adapte seulement le lot sur huit observations pures par palier; CPU complet en fallback. Le benchmark privé conserve batch 12 et depth 2 (1,25 Mio), pas la politique normale. Vulkan explicite et contrôle synchrone restent depth 1; SIFT/RootSIFT et CPU explicite restent fixes. Helpers=0. | -| `geometric_verifier.run` | CPU; CPU 1; lot 1..8; hôte 4 Mio, 0/item; I/O 1; GPU 0 | Le lot est consommé séquentiellement; USAC conserve `isParallel=false`. | -| `track_builder.run` | CPU; CPU 1; lot 1; fixe `(4 Mio + arêtes * (48 + 2*160)) * facteur`, facteur 2 jusqu'à 400k arêtes puis 8; 0/item; I/O 1; GPU 0 | Rebuild atomique. La reprise valide le scope mais ne recalcule pas l'estimation avant admission. | -| `sparse_sfm.run` | CPU; CPU 1; lot 1; fixe `ceil_Mio(128 Mio + 64 Kio/image + 2 Kio/track + 512 octets/observation)`; 0/item; I/O 1; GPU 0 | Exécution atomique; BA à un thread. La forme est redérivée à la reprise sans réconciliation de l'estimation persistée. | -| `incremental_reconstruction.run` | CPU; CPU 1; lot 1; fixe `ceil_Mio(256 Mio + 128 Kio*(caméras base + images extension) + 4 Kio*(landmarks base + tracks extension) + 1 Kio*(observations base + extension))`; 0/item; I/O 1; GPU 0 | Exécution atomique; la forme est redérivée à la reprise sans réconciliation de l'estimation persistée. | +| `raw.develop` | MIXED; CPU 1; lot 1; hôte `2 Gio + contexte`, 0/item; I/O 1; GPU 0 | Un Capture atomique. Le garde applique/restaure CPU1 au pool OpenCV global. Les 2 Gio sont une allowance de travail opérationnelle, pas une limite de dataset. | +| `photo_quality.triage` | IMPORT; CPU 1; lot 1; hôte `contexte retenu + 20 Mio`, 0/item; I/O 1; GPU 0 | Un groupe par séquence ; garde OpenCV CPU1. Aucun scaling utile déterministe n'est acquis. | +| `acquisition_campaign.run` | JPEG: IMPORT ; RAW: MIXED. CPU 1; lot 1; hôte `contexte retenu + requête transitoire exacte + 256 Kio` + 64 Kio/item ; DEVELOP_RAW ajoute 2 Gio; I/O 1; GPU 0 | Un groupe S3-E par séquence, sans Task imbriqué. La création et la reprise dérivent la même estimation ; la forme historique exacte est normalisée seulement en mémoire. | +| `import.images` | IMPORT; CPU 1; lot 1..32; hôte 128 Kio + `NAME_MAX+64`/item; I/O 1; GPU 0 | Copie/hash I/O-bound. Le callback consomme le lot admis et réadmet entre lots. | +| `features.extract` | CPU; CPU 1..compute-pool; lot 1; hôte 64 Mio + 512 Mio/item; I/O 1; GPU 0 | La demande durable emploie le maximum `int` positif de l'API OpenCV ; le Governor la borne à l'hôte. Le garde applique/restaure exactement le CPU admis. | +| `features.extract.sift` | CPU; CPU 1..compute-pool; lot 1; hôte 64 Mio + 1 Gio/item; I/O 1; GPU 0 | Même contrat OpenCV. Les formes CPU12 et CPU1 historiques complètes sont acceptées et normalisées en mémoire. | +| `features.extract.rootsift` | CPU; CPU 1..compute-pool; lot 1; hôte 64 Mio + 1 Gio/item; I/O 1; GPU 0 | Même contrat que SIFT ; aucune couture GPU scientifiquement compatible n'est validée. | +| `visual_index.update` | CPU; CPU 1..16; lot 1..16; hôte 8 Mio + 2 Mio/item; I/O 1; GPU 0 | Le segment contient au plus 16 Feature Sets indépendants. Au plus `cpu_threads-1` enfants sont joints avant publication owner-only. | +| `candidate_pair.generate` | CPU; CPU 1..64; lot 1..64; hôte 256 Kio + 8 Mio/item; I/O 1; GPU 0 | Le batch de 64 est la borne algorithmique et de ressources. Fenêtre `min(2*CPU, 64, reste_du_lot)`, un handle DB privé par participant, piles enfants de 4 Mio facturées. | +| `matcher.run` | CPU: CPU 1..12, lot sûr 1..12, hôte 0 + 10 Mio/item, I/O 1, GPU 0. ORB Vulkan AUTO: CPU 1, lot utile 1..8, même hôte/item, GPU 1 + 640 Kio, inflight 1. | Le batch/participant 12 est une borne intrinsèque mesurée du Matcher. Batch 12 et depth 2 (1,25 Mio) restent sûrs pour preuves privées, mais insuffisamment utiles en AUTO. CPU complet est le fallback. | +| `geometric_verifier.run` | CPU; CPU utile 1..8, fenêtre sûre 16; lot 1..16; hôte 0 + 8 Mio/item; I/O 1; GPU 0 | Des parents indépendants sont préparés en parallèle, puis publiés/checkpointés en ordre par le propriétaire. L'USAC scientifique conserve `isParallel=false`. | +| `track_builder.run` | CPU; CPU 1; lot 1; fixe `(4 Mio + arêtes * (48 + 2*160)) * facteur`, facteur 2 jusqu'à 400k arêtes puis 8; 0/item; I/O 1; GPU 0 | Rebuild DSU atomique et publication owner-only ; aucune partition scientifiquement validée n'est acquise. | +| `sparse_sfm.run` | CPU; CPU 1; lot 1; fixe `ceil_Mio(128 Mio + 64 Kio/image + 2 Kio/track + 512 octets/observation)`; 0/item; I/O 1; GPU 0 | Exécution atomique FROZEN ; Gate D/E et BA restent à un thread. | +| `incremental_reconstruction.run` | CPU; CPU 1; lot 1; fixe `ceil_Mio(256 Mio + 128 Kio*(caméras base + images extension) + 4 Kio*(landmarks base + tracks extension) + 1 Kio*(observations base + extension))`; 0/item; I/O 1; GPU 0 | Recalcul atomique FROZEN depuis les entrées immuables, sans état solveur persistant. | -Ces écarts sont des constats d'implémentation Compute Governor v2. Ils ne -créent ni nouvelle limite scientifique, ni nouvelle identité, ni modification -du schéma Project DB. Une enveloppe privée de capacités peut corriger la -sélection/réconciliation sans modifier l'ABI C public du Task Kind Registry. +Ces bornes sont opérationnelles, externes-library, algorithmiques, mesurées ou +scientifiques selon la dernière colonne. Elles ne créent aucune limite de +cardinalité scientifique, identité ou migration Project DB. Les formes +historiques CPU1/CPU8/CPU12 citées sont des signatures exactes de reprise, pas +des plafonds portables de production. ## SIFT v1A -Une extraction SIFT demande jusqu'à douze threads CPU, un slot IO, aucun GPU, -pour une image. L'estimation structurelle conservatrice est environ 1,06 Gio +Une extraction SIFT demande jusqu'au compute-pool hôte, un slot IO, aucun GPU, +pour une image. Sa demande durable utilise `INT_MAX`, borne valide de l'API +OpenCV, mais ce nombre n'est jamais une admission matérielle. L'estimation +structurelle conservatrice est environ 1,06 Gio (décodage, pyramides, candidats et F32×128), lot 1, pic de record batch zéro. -Le callback applique exactement le contrat admis dans `1..12` au pool OpenCV +Le callback applique exactement le contrat positif admis au pool OpenCV process-wide sous l'unique propriétaire Queue, puis restaure la valeur précédente sur toutes les sorties. Les tests déterministes ORB, SIFT et RootSIFT couvrent 1/2/4/8/12 et obtiennent les mêmes keypoints, descripteurs et -métriques. Cette dimension opérationnelle ne change ni fingerprint ni Feature -Set. +métriques ; 12 reste une preuve de l'hôte courant, pas un maximum portable. +Cette dimension opérationnelle ne change ni fingerprint ni Feature Set. ## Responsabilité @@ -305,19 +323,20 @@ Le Resource Governor est l'unique propriétaire des budgets (RAM, GPU, CPU, IO). Il arbitre les ressources disponibles et calcule les lots adaptatifs pour chaque tâche. -Le profil interactif par défaut conserve un quart de la RAM détectée et un -quart des threads logiques pour le système hôte. Sur 16 Gio/16 threads, cela -donne environ 3,8 Gio et 4 threads de headroom. Une nouvelle admission attend -également lorsque PSI CPU `some avg10` atteint 20 %, ou PSI mémoire 1 %. Ces -signaux n'interrompent jamais le petit job déjà réservé. Ces valeurs décrivent -le profil Gate G core gelé; la cible opérationnelle v2 active est le couple -3 GiB/2 GiB et les signaux différentiels définis en tête de document. +Le profil interactif par défaut conserve quatre threads logiques lorsque +praticable et au moins un thread de calcul sur un petit hôte. Sur 16 threads, +cela donne 4 threads de headroom ; avec une topologie fiable, la sélection porte +sur des coeurs physiques complets et peut dépasser minimalement la cible +logique. Une nouvelle admission attend également lorsque PSI CPU `some avg10` +atteint 20 %, ou PSI mémoire 1 %. Ces signaux n'interrompent jamais le petit job +déjà réservé. -La soft floor vaut un quart et la hard floor un huitième de la RAM détectée. La -soft floor place le Governor au minimum en YELLOW ; la hard floor le place -immédiatement en RED. Le premier delta swap entre deux snapshots produit -YELLOW. Un second delta consécutif produit RED. Le premier snapshot ne constitue -qu'une baseline et n'est jamais interprété comme une activité récente. +La réserve dure/admission RAM vaut environ 3 GiB sur un hôte capable. La bande +3–4 GiB place le Governor au minimum en YELLOW et remet la croissance à 1 sans +soustraire 4 GiB de la capacité. La hard floor place immédiatement en RED. Le +premier snapshot swap constitue seulement une baseline ; un delta actif produit +YELLOW, puis RED s'il persiste. L'occupation totale du swap n'est pas un signal +d'activité récente. La récupération interdit `RED → GREEN` : trois observations saines produisent RED vers YELLOW, puis trois autres YELLOW vers GREEN. Le plafond reste 1 pendant @@ -326,8 +345,7 @@ le plafond : 1, 2, 4, 8, puis les paliers supérieurs utiles aux autres kinds. ## Contrat Gate G gelé -**PASS / FROZEN.** Les constantes existantes ci-dessus -restent inchangées. La RAM disponible conserve le modèle conservateur +**PASS / FROZEN.** Le contrat d'admission conserve le modèle conservateur `min(MemAvailable, RAM physique) - réserve hôte - réservations actives`, borné à zéro. Le double comptage conservateur possible d'une allocation déjà visible dans `MemAvailable` est accepté : un faux `WAIT` est préféré à un overcommit. @@ -351,6 +369,65 @@ scratch, historique RSS long terme, redimensionnement de réservation depuis le RSS ou monitoring live n'appartient à Gate G core. L'observation courante bornée RSS/HWM de Compute Governor v2 reste strictement diagnostique. +## Registre de stockage externe et leases scratch + +**EXTERNAL_STORAGE_OPERATIONAL_INTEGRATION — CURRENT / VALIDATED +OPERATIONAL.** Cette couture additive postérieure ne modifie pas Gate G core, +`Lardon3DResourceEstimate`, les budgets RAM/GPU/CPU/I/O ni une identité +scientifique. Elle permet au contrôleur physique SSD exact d'enregistrer sous +le mutex Governor une `Lardon3DResourceExternalStorage` bornée : génération +source, état +`ABSENT/DETECTED/AVAILABLE/IN_USE/DRAINING/SAFE/ERROR`, permission de nouvelle +allocation, total/libre scratch connus ou inconnus, swap connu ou inconnu, +nombre de leases, identité stable et raison. + +L'enregistrement est exclusif à un objet contrôleur emprunté. Les updates stale +ne peuvent pas restaurer une disponibilité ; une observation malformée ou +indéterminée devient `ERROR` conservateur et bloque les nouvelles allocations. +Un changement matériel incrémente la génération Governor et réveille les +waiters. L'état n'est ni persisté, ni relié à Project DB, ni interprété comme +capacité RAM. Les détails physiques et les capacités F10 restent la propriété +du contrôleur ; le panneau ressources lit l'usage enregistré auprès du +Governor. + +La conversion contrôleur→Governor est fail-closed par état. Toute paire ou +autorité exige détection courante du Drive et des deux partitions, identité +Drive+deux UUID exacte/non vide, tailles de partition connues et positives, +ainsi que mount, activité, leases, drain et capacités cohérents. `ABSENT` +interdit tout fait détecté/actif/monté/loué/capacitaire ; `DETECTED` incomplet +reste seulement observable. Un `ERROR` sticky peut conserver le tuple original +malgré la disparition, mais n'alloue rien ; il n'offre un drain qu'après preuve +du même tuple complètement reconnecté. Aucun état amical ni bit +`pairing_valid` ne remplace cette preuve physique. + +Les wrappers `lardon3d_resource_governor_acquire_scratch()` et +`lardon3d_resource_governor_release_scratch()` sont l'unique entrée de +production pour les leases. Ils exigent le contrôleur exactement enregistré, +son identité courante et l'autorité explicite d'allocation. `DRAINING`, +`ERROR`, absence, remplacement, état stale ou non-enregistré refusent une +acquisition ; une release exacte reste possible pendant le drain ou l'erreur. +Le Governor ne tient jamais son mutex pendant un appel contrôleur, et le +contrôleur ne rappelle jamais le Governor. La génération et l'état sont +revalidés avant publication afin que la transition ne crée ni inversion de +verrou ni lease non comptée. + +`UINT64_MAX` est une saturation source valide, pas un sentinel. L'API publique +continue de refuser une update matériellement différente au même watermark et +ne peut donc pas réaccorder une autorité stale. Seule la complétion du wrapper +exact, déjà sérialisé pour l'objet contrôleur et l'adresse de lease enregistrés, +peut réconcilier à `UINT64_MAX` sa propre acquisition/libération. Le compte +interne fondé sur les adresses reste l'autorité ; une erreur de complétion peut +retirer l'autorité mais ne peut jamais recopier un ancien compte physique. + +Le teardown production suit strictement : Queue détruite/jointe et chaque +lease Task rendu, worker SSD joint puis unregister vérifié, contrôleur détruit, +Governor détruit. Un unregister est refusé tant qu'une opération wrapper ou un +lease exact subsiste. Les quatorze Task kinds courants n'ont aucun consommateur +scratch : le compte normal est donc réellement zéro et la capacité disponible +n'autorise aucun usage implicite. Une future Task consommatrice devra définir +son propre contrat d'éligibilité et son ownership sans transformer scratch ou +swap en RAM. + ## API principale ### Création et destruction @@ -376,9 +453,19 @@ bornée RSS/HWM de Compute Governor v2 reste strictement diagnostique. - `lardon3d_resource_governor_wait_for_change()` - Attendre un changement - `lardon3d_resource_governor_pressure()` - Lire GREEN, YELLOW ou RED +### Stockage externe additif +- `lardon3d_resource_governor_register_external_storage()` / + `lardon3d_resource_governor_update_external_storage()` / + `lardon3d_resource_governor_unregister_external_storage()` / + `lardon3d_resource_governor_get_external_storage()` - Copier et observer + l'état physique borné d'un contrôleur exact +- `lardon3d_resource_governor_acquire_scratch()` / + `lardon3d_resource_governor_release_scratch()` - Posséder un lease scratch + de production sous l'orchestration du Governor + ## Invariants -1. Le scheduler ne décide jamais des ressources +1. La Queue/runtime ne décide jamais des ressources 2. Le Resource Governor est l'unique propriétaire des budgets 3. Les réservations sont obligatoires avant toute exécution 4. Les réservations sont libérées exactement une fois @@ -386,6 +473,9 @@ bornée RSS/HWM de Compute Governor v2 reste strictement diagnostique. 6. L'historique des métriques est strictement borné (8 entrées par classe) 7. Un contrat de séquence est immutable jusqu'à sa libération; seule la séquence suivante peut être adaptée +8. Swap, scratch et espace SSD n'augmentent jamais la capacité RAM +9. Un lease scratch de production passe par le Governor et reste attaché à + l'objet lease exact jusqu'à sa libération ## Cycle de vie @@ -424,35 +514,44 @@ bornée RSS/HWM de Compute Governor v2 reste strictement diagnostique. l'échantillon peut conserver taille/durée mais n'alimente jamais l'adaptation mémoire. - Pas de communication inter-classes de tâches -- `features.extract` réserve un lot de 1, demande jusqu'à douze threads CPU et +- `features.extract` réserve un lot de 1, demande la borne positive `int` + d'OpenCV et un slot I/O, avec 64 Mio fixes et 512 Mio par image. Cette estimation conservatrice couvre le chemin actuel sans prétendre mesurer les allocations internes d'OpenCV. - l'admission choisit 1..`min(12, compute_pool)` et le callback applique ce + L'admission choisit 1..`compute_pool` et le callback applique ce nombre immutable au pool OpenCV. `record_batch` couvre la validation source, le décodage, ORB, la publication et la finalisation DB ; `peak_memory_bytes == 0` signifie « mesure inconnue ». -- `visual_index.update` demande jusqu'à douze threads CPU, un slot I/O, 8 Mio +- `visual_index.update` demande jusqu'à seize threads CPU, un slot I/O, 8 Mio fixes et 2 Mio par Feature Set, par lots de 1 à 16. Le GPU vaut zéro. Le callback compte comme participant et crée au plus `cpu_threads - 1` enfants, tous joints avant publication et rupture de séquence. Chaque participant possède au plus un reader/FD Feature File ; les tranches de postings privées partitionnent le buffer borné du segment. `record_batch` compte uniquement les memberships commités et conserve la mémoire inconnue à zéro. -- `candidate_pair.generate` demande jusqu'à douze threads CPU, un slot I/O, - 256 Kio fixes et 64 Kio par Feature Set, par lots de 1 à 64. Le GPU vaut - zéro. La reconstruction reconnaît uniquement l'ancienne estimation exacte - (128 Kio fixes, 64 Kio par item, un thread CPU, un slot I/O, aucun GPU, lots - 1 à 64, classe CPU) et la normalise éphémèrement vers l'estimation courante - complète ; aucun checkpoint d'estimation seule n'est publié et une forme - voisine n'est jamais réinterprétée comme legacy. +- `candidate_pair.generate` demande jusqu'à soixante-quatre threads CPU, un + slot I/O, 256 Kio fixes et 8 Mio par Feature Set, par lots de 1 à 64. Le GPU vaut + zéro. La reconstruction reconnaît la forme immédiatement antérieure exacte + (256 Kio fixes, 64 Kio par item, CPU12) et la plus ancienne forme sérielle + exacte (128 Kio fixes, 64 Kio par item, CPU1), toutes deux à lot 1..64, + I/O1, GPU0, classe CPU. Elle les normalise éphémèrement vers la forme courante + 256 Kio + 8 Mio/item, CPU64 ; aucun checkpoint d'estimation seule n'est + publié et une forme voisine n'est jamais réinterprétée comme legacy. `record_batch` compte le nombre de paires générées par séquence et la durée réelle du lot ; `peak_memory_bytes == 0` signifie « mesure inconnue ». Chaque séquence interroge le Visual Index pour jusqu'à 64 memberships. Le calcul emploie des fenêtres internes d'au plus deux sources par thread admis - et 24 sources au total ; le propriétaire de Task persiste ensuite seul et en + et 64 sources au total ; le propriétaire de Task persiste ensuite seul et en ordre canonique. Cette estimation opérationnelle ne limite pas la taille scientifique du dataset. +- `geometric_verifier.run` réserve 8 Mio par Match Result admis, un slot I/O, + CPU 1..8 utile et lot 1..16. La fenêtre/participant est indépendamment sûre + jusqu'à 16, mais CPU12 n'a ajouté que 2,68 % sur CPU8 dans la preuve réelle + de 4113 parents, sous le deadband 5 %. Les enfants préparent des parents + indépendants et sont tous joints avant la publication owner-only, le curseur + contigu, le checkpoint et `sequence_break`. L'USAC interne garde + `isParallel=false`; CPU/lot ne modifient ni fingerprint ni GVR. - `matcher.run` demande jusqu'à douze threads CPU, un slot IO et 10 Mio par Candidate Pair admise, correspondant au working set contrôlé inférieur à environ 10 Mio par paire au @@ -554,3 +653,10 @@ sont gelées ; il ne reste aucune décision humaine Gate G. ## Statut **GATE G — PASS / FROZEN.** + +L'intégration opérationnelle SSD/Governor ci-dessus est +**CURRENT / VALIDATED OPERATIONAL**. Le jalon global qui la contient est +`GLOBAL_MAINTENANCE_AUDIT=PASS/FROZEN`. Les validations fraîches +portable/Vulkan, ASan/UBSan, LSan qualifié, TSan et ABI sont acquises ; l'unique +revue finale indépendante a conclu PASS sans finding bloquant. Le gel porte sur +la frontière auditée et ne crée aucun consommateur scratch ni budget RAM. diff --git a/docs/architecture/runtime.md b/docs/architecture/runtime.md index d60d74f..e590d23 100644 --- a/docs/architecture/runtime.md +++ b/docs/architecture/runtime.md @@ -3,8 +3,12 @@ ## Modèle d'exécution ### Threads -- Thread principal : TUI ncursesw (exclusif) -- Thread worker : exécution des tâches métier +- Thread principal : entrée, modèle de vue et rendu TUI ncursesw (exclusif) +- Thread worker Queue : exécution sérielle des tâches métier +- Participants internes : uniquement ceux du contrat Task admis, joints par le + callback propriétaire avant publication +- Opération SSD : au plus un thread joinable, uniquement pendant une opération + UDisks bornée ; il ne rend rien et ne devient ni Queue ni scheduler ### Synchronisation - Mutex pour les accès partagés @@ -16,7 +20,7 @@ ```text 1. Création (PENDING) 2. Soumission à la file -3. Sélection par le scheduler +3. Sélection FIFO/adaptative par la Queue 4. Réservation obligatoire 5. Exécution (RUNNING) - Pause/reprise coopérative @@ -35,7 +39,7 @@ ### Variables de condition - Coordination entre threads - Notification de changement d'état -- Attente passive (pas de polling) +- Attente passive ; timeout borné seulement pour réévaluer un `WAIT` ressources ### Atomicité - Opérations indivisibles @@ -44,9 +48,10 @@ ## Gestion des erreurs ### Rollback -- Retour à l'état précédent en cas d'échec -- Nettoyage complet des ressources -- Aucun état partiellement appliqué +- Rollback des transactions locales avant publication +- Nettoyage complet des ressources possédées par l'opération +- Une publication fichier réussie suivie d'un échec DB peut laisser un orphelin + valide ; aucune transaction distribuée fichier+SQLite n'est revendiquée ### Récupération - Reprise à la dernière frontière connue @@ -55,8 +60,9 @@ ## Limites actuelles -- Worker unique (pas de pools multiples) -- Pas de parallélisme inter-tâches +- Worker Queue unique (pas de pools inter-Tasks multiples) +- Pas de parallélisme inter-Tasks ; certains kinds possèdent des participants + internes bornés, comptés par leur contrat Governor - Reprise automatique limitée aux tâches indépendantes reconstructibles ## Reprise durable @@ -84,15 +90,20 @@ transaction catalogue restent hors mutex Task et hors mutex DB pendant l'I/O. resoumet automatiquement les tâches production valides. Il retourne après l'enqueue et n'attend jamais leur terminaison. -L'ordre d'initialisation production est : profil matériel, governor, queue et -worker, TUI, puis ouverture DB/projet et reprise synchrone. Une fermeture ne -peut commencer qu'après le retour de `project_open()`. Le worker peut consommer -pendant le scan ; chaque tâche exécutée est néanmoins réadmise normalement. +L'ordre d'initialisation production est : politique driver, profil matériel, +Governor, backend, Queue/worker, contrôleur SSD optionnel et binding +Governor, puis TUI. L'ouverture DB/projet et la reprise synchrone sont pilotées +ensuite depuis le thread principal. Une fermeture ne peut commencer qu'après le +retour de `project_open()`. Le worker peut consommer pendant le scan ; chaque +tâche exécutée est néanmoins réadmise normalement. -**NOT_YET_WIRED** — autosave générique des autres kinds et reprise ordonnée par -dépendances. +**NOT_YET_WIRED** — reprise ordonnée par dépendances/DAG. Les kinds de +production reconstructibles checkpointent déjà à leurs frontières métier ; +aucun timer autosave générique ne doit avancer devant leur publication durable. -**PLANNED** — reprise globale du scheduler via la Project Database. +**IMPLEMENTED** — reprise sélective des kinds reconstructibles via Project DB, +Task Kind Registry et Queue. Les dépendances/DAG restent différées ; il +n'existe pas de scheduler global distinct à restaurer. ## Accès Project Database @@ -100,8 +111,14 @@ dépendances. les opérations multi-tables sont transactionnelles et bornées. **IMPLEMENTED** — le cycle de vie projet ouvre/crée `project.db`, vérifie -l'identité et ferme la connexion. L'application arrête la task queue avant la -fermeture finale du projet. +l'identité et ferme la connexion. Ouvrir, fermer ou changer de projet est une +frontière exacte : l'observateur et la vue optique libèrent leurs borrows, puis +l'unique Queue est annulée, jointe et détruite, callbacks terminaux inclus, +avant la fermeture de Project DB. Une seule Queue vide est ensuite recréée et +les observateurs sont rebondés. Il n'existe jamais deux schedulers simultanés. +L'historique terminal et l'espace d'IDs Queue sont ainsi propres à la session ; +les mêmes Task IDs durables de deux projets restent indépendants et aucun +historique fourni n'est affiché lorsqu'aucun projet n'est chargé. **IMPLEMENTED** — la registry reconstruit explicitement callback/userdata hors mutex DB pour un kind connu ; elle ne soumet aucune tâche. @@ -119,11 +136,179 @@ bloquent pas l'ouverture. commitée. Un segment temporaire n'est jamais visible et un rejeu exclut les Feature Sets déjà membres. +## Durée de vie terminale et fermeture Queue + +Une Task terminale reste vivante jusqu'au retour complet de son callback +terminé. Queue la retire alors de la liste active et la détruit hors de son +mutex ; seule une histoire de 64 snapshots reste observable. Les appels déjà +enregistrés avant `task_queue_destroy()` sont attendus. Le propriétaire doit +empêcher tout nouvel appel dès le début de la destruction, règle nécessaire à +toute API C adressée par pointeur brut. + +Un callback terminé peut consulter les vues Queue tant que le propriétaire la +maintient vivante. Il ne peut pas détruire cette Queue, retirer son propre +record ni attendre une opération dépendante de son retour. + +## Observatoire TUI actuel + +**CURRENT / VALIDATED OPERATIONAL.** Ce statut décrit l'implémentation et ses +tests courants. L'audit global qui contient cette frontière est désormais +`PASS/FROZEN` après revue indépendante ; le statut TUI reste volontairement +opérationnel et n'interdit pas ses évolutions futures sous un ticket distinct. + +### Séparation modèle, observation et rendu + +Le modèle `tui_model` est pur et testable sans terminal. Le renderer reçoit +seulement des copies bornées et n'interroge ni Queue, ni Governor, ni Project +DB, ni contrôleur SSD. Toutes les fonctions ncurses, l'entrée clavier et le +rendu demeurent sur le thread principal. + +L'observateur runtime emprunte Queue et Governor et conserve une seule copie +cohérente. Les captures ordinaires sont coalescées pendant au moins une seconde +monotone ; un échec conserve la dernière vue bornée en la marquant stale. +Il observe au plus 129 Tasks : les 64 pending possibles, l'unique active et les +64 snapshots terminaux récents. L'ordre Queue place le travail vivant du plus +récent au plus ancien, puis l'histoire par terminaison décroissante ; une Task +active ne peut donc pas être masquée par un vieux préfixe historique. Il +n'existe ni scan DB par frame, ni lecture `/proc` volumineuse, ni historique +non borné. + +Les ABI historiques restent exactes : `Lardon3DTaskSnapshot`, +`Lardon3DResourceSnapshot`, `Lardon3DAppState` et +`lardon3d_layout_draw()` ne sont pas étendus en place. Les surfaces additives +`Lardon3DTaskObservation`, `lardon3d_task_queue_observe()`, +`Lardon3DResourceObservation`, `Lardon3DRuntimeSnapshot` et +`lardon3d_layout_draw_runtime()` portent les nouveaux champs. De même, +`lardon3d_tui_run()` reste le symbole historique ; l'application utilise +`lardon3d_tui_run_with_ssd_operation()` avec un owner SSD conservé hors de +`Lardon3DAppState`. + +### Progression et ETA + +Une Task typée publie `completed/total` seulement après son propre commit +métier durable. Quand ces compteurs sont connus, la TUI les affiche toujours et +en dérive le pourcentage sans utiliser le message ou le nom. Une Task marquée +`COMPLETED` avec un préfixe durable incomplet est une erreur d'intégrité +visible, jamais 100 %. Quand les comptes typés sont inconnus, le lifecycle peut +être terminal mais la progression scientifique reste indéterminée. Le +pourcentage générique non typé, lorsqu'il est utile, porte explicitement le +libellé runtime. + +Le débit est un EWMA borné. La première observation établit seulement le +préfixe de reprise et ne contribue pas au taux ; une reprise de RUNNING remet +également la fenêtre temporelle à zéro. Deux intervalles strictement positifs +sont nécessaires avant un débit et une ETA connus. Une absence de progrès, +une pression Governor, une régression ou une preuve insuffisante produit +respectivement `STALLED`, `THROTTLED`, reset ou `INDETERMINATE/CALCULATING`. +Seule une complétion cohérente vaut exactement 100 % et ETA zéro ; aucune fausse +précision n'est affichée. + +### Pipeline et ressources + +La synthèse utilise les étapes Acquisition, RAW, Quality, Features, Visual +Index, Candidate, Matcher, GV, Tracks, Sparse SfM et future Dense. Les états +sont `NOT_READY`, `READY`, `QUEUED`, `RUNNING`, `THROTTLED`, `BLOCKED`, +`COMPLETE`, `FAILED` et `NOT_APPLICABLE`. Dense reste explicitement +`NOT_APPLICABLE` tant qu'aucun Task kind de production n'existe ; une étape +future n'est jamais devinée active depuis un nom ou un message. + +Le panneau ressources expose CPU actif/admis/disponible et sa raison, GPU +présent/mémoire/busy/backend lorsqu'ils sont connus, RAM/MemAvailable/réserve, +swap total/utilisé et deltas actifs, lot/inflight/helpers/I/O, scratch et +pression Governor GREEN/YELLOW/RED. Le contrat installé de l'exacte Task active +est l'autorité pour CPU et lot. Un dernier diagnostic privé seulement indexé +par kind peut appartenir à une autre Task ou séquence : backend, inflight, +helpers, utilisation ou raison restent donc `UNKNOWN` sans association exacte +Task+séquence. La mémoire UMA est comptée une seule fois et ni swap ni scratch +ne sont ajoutés à la capacité RAM. + +### Dimensions, couleurs et clavier + +Le layout complet demande au moins 100×30. Le layout compact est validé à la +frontière 72×20 et reste supporté jusqu'au minimum 60×15. En dessous, le rendu +se réduit au message borné `Terminal trop petit`; un resize recalcule la classe +sans faire travailler un worker. Les rôles sémantiques sont healthy vert, +warning jaune, error rouge, GPU cyan, CPU bleu, SSD magenta, plus dim/bold. +Les libellés textuels demeurent l'autorité lorsqu'il n'y a pas de couleur ou +pas assez de paires terminal. + +Les écrans courants sont accueil, projets, import, viewer futur, tâches, +ressources, optique, SSD et aide. `F1..F7` naviguent respectivement vers aide, +projets, import, viewer, tâches, ressources et optique. Le segment littéral +`F10 SSD` est réservé au début du footer et reste visible à 60 colonnes dans +tous les modes pertinents. Les footers dérivent du même mode que le handler : + +- saisie active : Enter valide, Échap annule, F10 reste disponible ; +- import actif : `X` demande l'annulation et F10 reste disponible ; `q` et + Échap sont affichés comme désactivés ; +- mode idle : `q`, Échap/navigation et les commandes propres à l'écran sont + annoncés seulement lorsqu'ils sont réellement traités ; +- Tasks : flèches/`j`/`k`, `P` pause, `R` reprise, `C` annulation ; +- Optique : Tab change de panneau, flèches/`j`/`k` sélectionnent, `[` revient à + la première page et `]` charge la suivante ; `B/L/C/V/A/G/K/E` déclenchent + les opérations indiquées et `R` retente explicitement un bind/chargement. + +### Workflow optique + +La TUI consomme les API v23 décrites dans +[Project Database](project_database.md), sans SQL direct ni édition d'une ligne +immuable. Elle inspecte une affectation Capture, effectue seulement des lookup +metadata exacts, liste les profils de boîtier/objectif/configuration et accepte +un objectif manuel sans électronique ni alias — le Meike de test est un cas +normal, pas une branche produit. « Modifier » signifie créer un nouveau profil +ou une nouvelle configuration immuable, puis l'assigner explicitement à un +groupe de campagne encore éligible ou à un Capture non affecté. Les +calibrations listées doivent être compatibles avec l'exacte configuration et +la sélection reste explicite ; absence, ambiguïté, incompatibilité, BUSY, I/O +et corruption sont rendues sans profil fabriqué. Les pages ont 16 lignes, +rapportent un compte page-local et un indicateur « suite » exact. + +### SSD F10 et lifetime application + +La TUI affiche les huit états physiques `ABSENT`, `DETECTED`, `ENABLING`, +`ENABLED`, `IN_USE`, `DRAINING`, `SAFE_TO_UNPLUG` et `ERROR`, avec identité +stable, modèle/télémétrie lorsqu'ils sont connus, swap, scratch, mount, usage, +leases, drain et raison. `UNKNOWN` n'est jamais remplacé par zéro ou par une +supposition ; `SAFE_TO_UNPLUG` est mis en évidence comme endpoint sûr. F10 +choisit exclusivement l'une des capacités exactes +`can_enable`, `can_disable` ou `can_cancel_drain` publiée par le contrôleur ; un +état incomplet, une paire de remplacement ou un résultat malformé n'accorde +aucune autorité. L'opération synchrone UDisks s'exécute dans au plus un thread +joinable, tandis que le main continue de rendre et de poller sans blocage. + +La validation qui alimente ces capacités est fail-closed par état : toute +autorité exige Drive et deux partitions détectés, identités Drive+UUID exactes, +extents positifs connus et faits mount/activité/drain cohérents. `ABSENT` ne +peut transporter aucun fait actif, `DETECTED` partiel n'a aucune action et un +hazard `ERROR` déconnecté ne peut que retenir l'identité originale sans +allocation. Seule la reconnexion complète de ce tuple peut autoriser son drain. + +Après chaque observation ou résultat validé, l'adaptateur enregistre une copie +bornée de l'état physique auprès du Governor. Une copie malformée devient +`ERROR` et interdit les nouvelles allocations ; l'observation ressources lit +cet état Governor-owned, tandis que les détails/permissions F10 restent dans +le snapshot physique. La génération source peut saturer à `UINT64_MAX` : une +update publique égale ne réaccorde jamais une autorité stale ; seule la +complétion du wrapper exact déjà engagé réconcilie son lease adressé. À l'arrêt, +l'ordre est : destruction/join de la Queue et +libération de chaque lease Task, fermeture du projet/DB, join puis unregister +vérifié de l'adaptateur SSD, destruction du contrôleur, puis destruction du +Governor. Les tests utilisent un provider factice et n'exécutent aucune vraie +mutation SSD. + ## Invariants - ncurses appartient exclusivement au thread principal - Aucune tâche ne démarre sans réservation active - Les réservations sont libérées exactement une fois - Les buffers sont strictement bornés +- Le Resource Governor reste l'unique propriétaire de l'admission ; ni Queue, + ni contrôleur SSD ne constituent un second orchestrateur de ressources -## Statut : DOCUMENTATION DE L'IMPLÉMENTATION ACTUELLE +## Statut : CURRENT / VALIDATED OPERATIONAL + +La TUI/runtime et son raccordement SSD sont implémentés, testés et relus dans +leur tranche. Le statut global est +`GLOBAL_MAINTENANCE_AUDIT=PASS/FROZEN`. Les builds portables/Vulkan, +sanitizers, contrôles de concurrence et ABI frais sont acquis ; l'unique revue +finale indépendante a conclu PASS sans finding bloquant. diff --git a/docs/architecture/scheduler_resource_integration.md b/docs/architecture/scheduler_resource_integration.md index 845d046..43d3a52 100644 --- a/docs/architecture/scheduler_resource_integration.md +++ b/docs/architecture/scheduler_resource_integration.md @@ -1,13 +1,16 @@ -# Intégration Scheduler ↔ Resource Governor +# Intégration Task Queue ↔ Resource Governor ## Responsabilité -Documenter l'intégration architecturale entre le scheduler de tâches et le Resource Governor, incluant les frontières de responsabilités, le cycle de vie des réservations et le comportement en cas de pause. +Documenter l'intégration architecturale entre le runtime/Task Queue et le +Resource Governor, incluant les frontières de responsabilités, le cycle de vie +des réservations et le comportement en cas de pause. Le nom historique de ce +fichier ne désigne pas un scheduler distinct. ## Frontières de responsabilités -### Scheduler -- Ordonnancement FIFO +### Task Queue / runtime +- Ordonnancement FIFO stable avec bypass des seuls `WAIT` - Exécution des callbacks - Gestion des états de tâche - Sélection de la première tâche admissible @@ -17,14 +20,16 @@ Documenter l'intégration architecturale entre le scheduler de tâches et le Res - Calcul des lots adaptatifs - Réservations opaques - Historique de métriques +- Registre borné de l'état SSD physique et orchestration des leases scratch de + production, sans transformer cet espace en RAM ## Cycle d'exécution ```text 1. Task → Estimate (estimation des ressources) -2. Scheduler → Governor → decide() (décision d'admission) +2. Queue → Governor → decide() (décision d'admission) 3. Governor → Reservation (réservation opaque) -4. Scheduler → Worker (exécution) +4. Queue → Worker (exécution) 5. Worker → Governor → record_batch() (métriques) 6. Governor → release() (libération) ``` @@ -38,7 +43,7 @@ Documenter l'intégration architecturale entre le scheduler de tâches et le Res - `REJECT` : rejeter la tâche ### Comportement en cas de WAIT -- Le scheduler saute la tâche en tête de file +- La Queue saute la tâche en tête de file seulement lorsqu'elle est en `WAIT` - Il évalue la tâche suivante - Pas de blocage de la file - Si aucune tâche n'est admissible mais qu'un `WAIT` PENDING subsiste, le worker @@ -77,7 +82,7 @@ Documenter l'intégration architecturale entre le scheduler de tâches et le Res ## Invariants 1. Aucune tâche ne passe de PENDING à RUNNING sans réservation active -2. Le scheduler ne prend jamais de décision sur les ressources +2. La Queue ne prend jamais de décision sur les ressources 3. La libération des réservations s'effectue exactement une fois par cycle/séquence 4. Les atomicités sont garanties sous mutex @@ -86,6 +91,20 @@ Documenter l'intégration architecturale entre le scheduler de tâches et le Res - Worker unique (pas de pools multiples) - Pas de DAG de dépendances - Pas de priorités -- Pas de notification automatique de libération externe +- Aucun Task kind courant ne consomme le scratch externe ; toute future + éligibilité reste kind-owned et explicitement bornée + +Les changements matériels du registre SSD incrémentent la génération Governor +et réveillent ses waiters. Ils ne modifient pas un contrat Task déjà installé, +n'ajoutent pas le swap/scratch à la RAM disponible et ne créent aucune Task. +Une acquisition scratch de production passe par le wrapper Governor, puis sa +release exacte demeure autorisée pendant un drain. Le contrôleur physique ne +rappelle jamais le Governor et aucun mutex Governor n'est tenu pendant l'appel +au contrôleur. + +La fermeture Queue bloque l'ingress, attend le worker et tous les appels déjà +enregistrés, puis détruit exactement une fois. Une Task terminale n'est retirée +qu'après retour de son callback terminé ; Queue détruit alors Task/userdata hors +mutex et ne conserve qu'une histoire bornée de 64 snapshots. ## Statut : GATE G — PASS / FROZEN diff --git a/docs/architecture/sparse_sfm.md b/docs/architecture/sparse_sfm.md index 5f4bfbe..12e5ec2 100644 --- a/docs/architecture/sparse_sfm.md +++ b/docs/architecture/sparse_sfm.md @@ -725,7 +725,11 @@ translations are exhaustive: Every row is fingerprinted. Integer fields require no normalization beyond their fixed-width little-endian translation; every floating field uses the -canonical-zero rule above. +canonical-zero rule above. The four relative-pose/PnP iteration/inlier fields +retain this `u32` syntax for F0 compatibility, while an executable call through +the public Gate C OpenCV boundary additionally requires the `INT_MAX` bound +defined below. That operational validation neither rewrites nor normalizes +fingerprint bytes. The policy IDs above freeze the full named Gate D and Gate E v1 semantics, including canonical order and tie breaks, @@ -840,8 +844,8 @@ path. Swap, zram and external storage do not enlarge its RAM capacity. ### Gate F durable task payload -**FROZEN.** Gate F advances the current Project Database schema head from v16 -to v17 with one strictly additive `sparse_sfm_tasks` table. The historical v16 +**FROZEN.** Gate F advanced the then-current Project Database schema head from +v16 to v17 with one strictly additive `sparse_sfm_tasks` table. The historical v16 migration and its immutable reconstruction model remain unchanged. The new table follows the existing one-to-one typed-task pattern: its primary key is a foreign key to `tasks(task_id)` with cascade cleanup, and creation records the @@ -1134,6 +1138,26 @@ cheirality and inlier diagnostics. The tested v1 parameter set is frozen by the Gate C ground-truth and degeneracy evidence; future orchestration may choose other explicitly fingerprinted configurations. +#### Public OpenCV signed-boundary contract + +The C17 parameter fields for relative pose and calibrated PnP remain +`uint32_t`, but OpenCV accepts signed `int` iteration and inlier arguments. +Consequently, both `max_iterations` and `minimum_inliers` must be at most +`INT_MAX`; `max_iterations == 0` retains its existing invalid semantics, while +`minimum_inliers == 0` retains the existing PnP effective minimum of four. +Values above `INT_MAX` return `INVALID_ARGUMENT` before allocation, OpenCV, +RNG work or mutation of the caller-owned result/mask. Conversion to `int` +occurs only after that check. + +This is an operational language/library boundary, not a scientific-policy +change. The public field widths, F0 `u32` encoding, FROZEN defaults, thresholds, +seeds, fingerprints and every representable run remain byte-identical. The +maintenance regression rejects `INT_MAX+1` and `UINT32_MAX` for both primitives +without output mutation, while a degenerate no-solver fixture proves +`INT_MAX` itself remains representable without attempting that many +iterations. Focused validation passed 1/1 plus 20 repeats, targeted +ASan/UBSan 1/1, GCC/Clang C17/C++17 inclusion and the application link. + ### Gate C tested threshold set The pure API has no hidden defaults; callers provide all acceptance settings. diff --git a/docs/architecture/task_kind_registry.md b/docs/architecture/task_kind_registry.md index 66fc5e0..b181864 100644 --- a/docs/architecture/task_kind_registry.md +++ b/docs/architecture/task_kind_registry.md @@ -53,15 +53,15 @@ même fichier. Le détail chiffré des capacités est centralisé dans l'[audit | --- | --- | --- | | `raw.develop` | `raw_development_task.cpp`; `lardon3d_raw_development_task_reconstruct`; `run` | Aucune | | `photo_quality.triage` | `photo_quality_task.cpp`; `lardon3d_photo_quality_task_reconstruct`; `run` | Aucune | -| `acquisition_campaign.run` | `acquisition_campaign_task.cpp`; `lardon3d_acquisition_campaign_task_reconstruct`; `run` | Aucune | +| `acquisition_campaign.run` | `acquisition_campaign_task.cpp`; `lardon3d_acquisition_campaign_task_reconstruct`; `run` | Forme courante ou forme v22 exacte vérifiée contre la requête immuable → capacité courante en mémoire | | `import.images` | `import_task.c`; `lardon3d_image_import_reconstruct`; `run_image_import` | Aucune | -| `features.extract` | `feature_task.c`; `lardon3d_feature_extract_reconstruct`; `run` | Durable CPU12; admission/runtime OpenCV 1..12 | -| `features.extract.sift` | `sift_task.c`; `lardon3d_sift_extract_reconstruct`; `run` | CPU1 historique exact → durable CPU12; runtime 1..12 | -| `features.extract.rootsift` | `sift_task.c`; `lardon3d_sift_extract_reconstruct`; `run` | CPU1 historique exact → durable CPU12; runtime 1..12 | -| `visual_index.update` | `visual_index_task.c`; `lardon3d_visual_index_update_reconstruct`; `run` | CPU runtime 1..12 ; feedback par segment durable | -| `candidate_pair.generate` | `candidate_pair_task.c`; `lardon3d_candidate_pair_generate_reconstruct`; `run` | Forme sérielle historique exacte → CPU12 ; runtime CPU 1..12 et lot 1..64 | +| `features.extract` | `feature_task.c`; `lardon3d_feature_extract_reconstruct`; `run` | Formes CPU12/CPU1 historiques exactes → demande OpenCV portable ; runtime borné au compute-pool | +| `features.extract.sift` | `sift_task.c`; `lardon3d_sift_extract_reconstruct`; `run` | CPU12/CPU1 historiques exacts → demande OpenCV portable ; runtime borné au compute-pool | +| `features.extract.rootsift` | `sift_task.c`; `lardon3d_sift_extract_reconstruct`; `run` | Même réconciliation SIFT ; aucune voie GPU validée | +| `visual_index.update` | `visual_index_task.c`; `lardon3d_visual_index_update_reconstruct`; `run` | Formes CPU12/CPU1 historiques exactes → CPU/lot 1..16 | +| `candidate_pair.generate` | `candidate_pair_task.c`; `lardon3d_candidate_pair_generate_reconstruct`; `run` | Formes CPU12 et CPU1 historiques exactes → CPU/lot 1..64 | | `matcher.run` | `matcher_task.c`; `lardon3d_matcher_task_reconstruct`; `run` | Signatures CPU/Vulkan historiques exactes → formes courantes en mémoire | -| `geometric_verifier.run` | `geometric_verifier_task.c`; `lardon3d_geometric_verifier_task_reconstruct`; `run` | Aucune | +| `geometric_verifier.run` | `geometric_verifier_task.c`; `lardon3d_geometric_verifier_task_reconstruct`; `run` | Forme sérielle CPU1/batch8 exacte → CPU utile 1..8, lot 1..16 | | `track_builder.run` | `track_builder_task.cpp`; `lardon3d_track_builder_task_reconstruct`; `run` | Aucune | | `sparse_sfm.run` | `sparse_sfm_task.cpp`; `lardon3d_sparse_sfm_task_reconstruct`; `run` | Aucune | | `incremental_reconstruction.run` | `incremental_reconstruction_task.cpp`; `lardon3d_incremental_reconstruction_task_reconstruct`; `run` | Aucune | @@ -87,10 +87,11 @@ diagnostic ; ni l'enveloppe ni ce choix ne sont persistés. La politique CPU hôte reste privée au Governor : masque permis, groupes package/core/SMT, compute-pool et résultat d'application du worker Queue. Le -compute-pool borne l'admission de chaque kind, y compris une capacité durable -CPU12. Feature/SIFT/RootSIFT consomment le compte immutable 1..12 dans OpenCV ; -les kinds CPU1 restent fixes. Aucun ID CPU ou choix d'affinité n'entre dans le -descriptor, le checkpoint ou le Project DB. +compute-pool borne l'admission de chaque kind. Feature/SIFT/RootSIFT utilisent +le maximum `int` positif comme borne de l'API OpenCV, puis consomment le compte +immutable réellement admis ; les CPU12 durables ne sont plus que des signatures +historiques exactes. Les kinds CPU1 justifiés restent fixes. Aucun ID CPU ou +choix d'affinité n'entre dans le descriptor, le checkpoint ou le Project DB. Le feedback ne requalifie pas un succès de reprise en travail durable : les kinds Feature/SIFT/RootSIFT comptent un item seulement après extraction et @@ -99,8 +100,10 @@ incertaine compte zéro ; Visual Index compte pareillement zéro pour un segment `PUBLISHED_NOT_DURABLE`. L'état privé par kind/backend coordonne désormais une seule dimension d'essai. -Les CPU validés slow-startent `1/2/4/8/12`; après deux observations de baseline, -deux observations à au moins +5 % sont nécessaires pour accepter le palier. +Les CPU réductibles slow-startent par doubles successifs depuis 1, puis le +maximum exact de leur capacité, toujours bornés par le compute-pool. Après deux +observations de baseline, deux observations à au moins +5 % sont nécessaires +pour accepter le palier. Une fois CPU stabilisé, seuls les kinds dont le callback consomme réellement son lot peuvent ouvrir un essai de lot. `features.extract`, SIFT et RootSIFT enregistrent une observation atomique réussie partagée entre Tasks ; Visual @@ -127,7 +130,9 @@ userdata sans `AppState *` ancien. **IMPLEMENTED** — `project_open()` utilise la registry production immutable pour restaurer hors mutex DB et transférer chaque tâche acceptée à la queue. -**NOT_YET_WIRED** — autosave complet et réconciliation orpheline. +**NOT_YET_WIRED** — réconciliation orpheline et dépendances/DAG. Les kinds +reconstructibles checkpointent déjà à leurs frontières métier ; la Registry ne +possède pas un timer autosave et ne doit pas devancer leurs curseurs durables. **IMPLEMENTED** — `features.extract` version 1 reconstruit une extraction ORB depuis `image_id` et ses paramètres bornés. @@ -138,11 +143,11 @@ depuis `image_id` et ses paramètres bornés. **IMPLEMENTED** — `candidate_pair.generate`, version 1, recharge `visual_index_id + after_feature_set_id + top_k + minimum_evidence_count + scanset_filter + exclude_same_asset` depuis `candidate_pair_generate_tasks` -et reconstruit un contexte boundé. La restauration reconnaît uniquement le -snapshot opérationnel sériel historique v1 exact et remplace éphémèrement sa -demande d'un thread CPU par douze avant admission. Le checkpoint historique et -le curseur typé restent inchangés. Les snapshots Candidate courants et tous les -autres kinds restent inchangés. +et reconstruit un contexte boundé. La restauration reconnaît le snapshot +opérationnel sériel historique v1 exact et la forme CPU12 immédiatement +antérieure, puis les remplace éphémèrement par la demande courante CPU64 avec +8 Mio par item avant admission. Le checkpoint historique et le curseur typé +restent inchangés ; une forme voisine est rejetée. **PASS / FROZEN — Compute Governor v2.** `matcher.run`, version 1, recharge la configuration Matcher, l'identité Feature Set et le curseur @@ -227,18 +232,22 @@ une Task Matcher doit être reprise. La Registry continue donc à reconstruire uniquement la politique AUTO/fixe déduite de la signature durable, jamais un pipeline de benchmark. -**IMPLEMENTED** — `features.extract.sift` et `features.extract.rootsift` -acceptent leur forme CPU12 courante et normalisent uniquement leur forme CPU1 -historique exacte. ORB/SIFT/RootSIFT appliquent ensuite le compte CPU admis dans -`1..12`; les sorties testées à 1/2/4/8/12 restent égales. Cette compatibilité -opérationnelle n'altère ni fingerprint, Feature Set, checkpoint durable, ni -politique scientifique. +**IMPLEMENTED** — ORB, SIFT et RootSIFT acceptent leurs formes CPU12/CPU1 +historiques complètes et les normalisent vers la demande OpenCV portable +`INT_MAX`. Le Governor borne l'exécution au compute-pool ; les sorties testées +à 1/2/4/8/12 restent égales. Cette compatibilité opérationnelle n'altère ni +fingerprint, Feature Set, checkpoint durable, ni politique scientifique. **IMPLEMENTED** — `geometric_verifier.run`, version 1, recharge la configuration Fundamental immuable, en revalide le fingerprint et reprend `after_match_result_id`. Project DB v13 ajoute uniquement `geometric_verifier_tasks`, car le checkpoint générique v1 ne possède aucun payload propre au kind. +La forme historique série exacte (4 Mio fixes, CPU1, batch 1..8) est normalisée +en mémoire vers 8 Mio par item, CPU utile 8 et batch maximal 16. Cette évolution +ne touche ni fingerprint, GVR, ordre, curseur ni checkpoint historique ; une +forme voisine est refusée. + **IMPLEMENTED** — `track_builder.run`, version 1, reconstruit un scope explicite depuis son payload Project DB v15 et son asset little-endian validé. Le callback réutilise l'orchestration Gate C et le reconstructeur refuse toute corruption, diff --git a/docs/architecture/task_queue.md b/docs/architecture/task_queue.md index 1e0d75c..7ff105b 100644 --- a/docs/architecture/task_queue.md +++ b/docs/architecture/task_queue.md @@ -7,9 +7,10 @@ traitement. Il orchestre l'exécution séquentielle des tâches via un worker unique, gère la sélection de la prochaine tâche admissible et transmet les callbacks de résultat. -La file est le point central entre le scheduler (soumission) et les workers -(exécution). Elle ne décide jamais des ressources — elle applique uniquement -l'ordre FIFO et consulte le gouverneur via le scheduler. +La file est le point central entre les producteurs et le worker d'exécution. +Les responsabilités historiques de scheduler sont portées par cette Queue et +le runtime existants : aucun second scheduler n'existe. La Queue ne décide +jamais des ressources ; elle demande l'admission au Governor. ## Fichiers @@ -32,17 +33,19 @@ Le type public réel est l'opaque `Lardon3DTaskQueue`. | `lardon3d_task_queue_try_add_ex()` | Distingue succès, saturation, arrêt, collision d'ID et erreur | | `lardon3d_task_queue_pause()` / `resume()` | Contrôle une tâche par son ID stable | | `lardon3d_task_queue_cancel()` | Demande l'annulation par ID | -| `lardon3d_task_queue_remove()` | Retire une tâche terminale | +| `lardon3d_task_queue_remove()` | Retire un snapshot terminal retenu | | `lardon3d_task_queue_snapshot()` | Copie une vue bornée de la file | +| `lardon3d_task_queue_observe()` | Copie la vue additive typée/durable/admission | -## Comportement FIFO +## Comportement FIFO avec bypass stable des WAIT 1. `lardon3d_task_queue_add()` ajoute la tâche en fin de file. 2. Le sélecteur interne parcourt la file du début vers la fin. 3. La première tâche `PENDING` admissible (non terminale) est retournée. 4. Si aucune tâche n'est admissible, le worker attend un changement. -5. L'ordre de soumission est toujours respecté entre tâches de même priorité. +5. L'ordre de soumission est respecté entre tâches admissibles ; une tâche en + `WAIT` ressources peut être dépassée sans être réordonnée ou supprimée. Lorsqu'au moins une tâche PENDING reste en `WAIT` de ressources, cette attente est temporisée à 500 ms maximum. À l'expiration, le worker reprend le scan stable @@ -61,30 +64,47 @@ le blocage par la tête de file lorsqu'une tâche ne peut pas démarrer. 1. **Worker unique** : une seule tâche s'exécute à la fois. Pas de parallélisme interne à la file. -2. **FIFO strict** : l'ordre de soumission détermine l'ordre d'exécution. +2. **FIFO stable** : le scan commence à la tête et ne contourne que les + `WAIT` d'admission. Il n'existe ni priorité cachée ni réordonnancement. 3. **Réservation obligatoire** : aucune tâche n'est exécutée sans réservation validée par le gouverneur. -4. **Callback unique** : chaque tâche reçoit exactement un callback (succès, - échec ou annulation). +4. **Notification terminale unique** : le callback terminé de la Task retourne + avant la destruction de son userdata. Les callbacks métier peuvent avoir + plusieurs séquences admises. 5. **Annulation sûre** : annuler une tâche en cours la met en état `CANCELLED` sans interrompre brutalement le worker. -6. **Nettoyage complet** : `task_queue_destroy()` libère toutes les tâches - restantes, y compris celles en cours d'exécution. +6. **Retraite prompte et bornée** : après retour du callback terminé, la vraie + Task est détruite hors mutex et seule une copie de snapshot demeure. Les 64 + terminaisons les plus récentes sont retenues ; les plus anciennes sont + évincées sans conserver userdata ni Task. +7. **Fermeture coordonnée** : `destroy()` ferme d'abord l'ingress, attend le + worker et tout appel enregistré avant la fermeture, puis libère mutex et + mémoire exactement une fois. Le propriétaire doit interdire tout nouvel + appel API dès que la destruction commence. +8. **IDs non réutilisés** : la génération est monotone. Après consommation ou + génération de `UINT64_MAX`, l'épuisement est permanent pour la vie de la + Queue ; un ID restauré plus petit ou une éviction d'historique ne la réarme + jamais. +9. **Observation complète bornée** : la Queue de production accepte au plus 64 + pending, possède au plus une active et retient 64 terminaux. Une capacité + additive de 129 observe donc tout l'état courant. Les Tasks vivantes sont + copiées d'abord en ordre de soumission décroissant, puis l'histoire en ordre + de terminaison décroissant ; l'actif ne peut pas être caché par l'histoire. ## Interactions - **task** : chaque entrée de la file est un `Lardon3DTask` avec son état et sa progression. -- **scheduler** : la file applique la soumission FIFO et orchestre - l'exécution via le worker. -- **resource_governor** : la file consulte le gouverneur (via le scheduler) - avant d'exécuter chaque tâche. +- **runtime / producteurs** : soumettent les Tasks ; la Queue applique FIFO et + orchestre l'exécution via le worker. +- **resource_governor** : la file consulte directement le gouverneur avant + d'exécuter chaque tâche. - **hardware_profile / resource_snapshot** : informations matériel utilisées par le gouverneur pour les réservations. ## Statut -**GATE G — PASS / FROZEN** — file FIFO avec worker unique, +**GATE G — PASS / FROZEN** — file FIFO stable avec worker unique, sélection adaptative, réévaluation autonome des `WAIT`, pause, annulation coopérative et accueil des tâches restaurées avec ID préassigné. @@ -93,11 +113,23 @@ La reprise projet utilise `try_add_ex()` et ne bloque jamais `project_open()`. `PENDING` en DB et seront réévaluées lors d'une ouverture ultérieure. Ce n'est pas un second scheduler. +Ouvrir, fermer ou changer de projet détruit/joint cette Queue avant fermeture +de Project DB, puis recrée une unique Queue vide. L'histoire et son namespace +d'IDs ne traversent donc jamais une frontière projet ; ce reset ne modifie pas +les Task IDs durables conservés dans chaque DB. + +Les callbacks terminés sont appelés sans le mutex Queue. Tant que le +propriétaire maintient la Queue vivante, ils peuvent employer les lectures +`get`, `get_at`, `count` et `snapshot`. Ils ne doivent pas appeler +synchroniquement `remove()` sur leur propre record, `destroy()` sur la même +Queue, ni une opération dont l'achèvement dépend de leur propre retour. + ## Limites -- Worker unique : pas de parallélisme interne. +- Worker unique : pas de parallélisme inter-Tasks. Un kind peut posséder un + parallélisme interne borné et chargé dans sa réservation. - Pas de DAG ni de dépendances inter-tâches. -- Pas de priorités (FIFO strict). +- Pas de priorités (FIFO stable avec bypass des seuls `WAIT`). - La reprise ne possède pas encore de DAG ni de déclenchement différé lorsque une place se libère pendant la session courante. - Pas de pool de workers CPU/IO/GPU. diff --git a/docs/architecture/task_system.md b/docs/architecture/task_system.md index 13e8014..d8e070d 100644 --- a/docs/architecture/task_system.md +++ b/docs/architecture/task_system.md @@ -41,6 +41,21 @@ une opération de réadmission, pas un état supplémentaire. | `lardon3d_task_sequence_break()` | Libère puis renouvelle la réservation | | `lardon3d_task_snapshot()` | Copie l'état d'observation runtime | +### Observation additive et ABI + +`Lardon3DTaskSnapshot` conserve exactement son layout historique. La structure +additive `Lardon3DTaskObservation` répète ce préfixe dans un autre type et ajoute +kind/version, compteurs durables, nombre de séquences et copie du contrat +d'exécution installé. `lardon3d_task_observation()` initialise une sortie +caller-owned et cohérente sous le mutex Task ; aucun pointeur interne ne sort. + +`lardon3d_task_set_durable_progress()` ne peut être appelé par un owner typé +qu'après publication de son préfixe métier. Ces compteurs ne sont ni déduits du +pourcentage générique, ni d'un message, ni du nom de Task. Un +`set_progress()` ultérieur les efface afin qu'une observation ne conserve pas +une valeur exacte devenue stale. Cette couture sert la TUI mais ne change ni le +codec checkpoint, ni l'identité scientifique, ni l'estimation immutable. + ### API durable | Fonction | Description | @@ -80,8 +95,9 @@ compteur de séquences. Les timestamps et l'estimation complète ne sont pas dupliqués par la DB et ne participent donc pas à ce test. Un canonique valide dont ce résumé correspond est prioritaire ; une `.next` absente, périmée ou corrompue est alors ignorée. Sinon, une `.next` valide qui correspond au même -résumé est promue sous le même verrou puis reprise. Un ancien projet v22 qui ne -possède que `.chk` reste ainsi récupérable. Toute autre absence, version non +résumé est promue sous le même verrou puis reprise. Un ancien projet v22 ou +antérieur qui ne possède que `.chk` reste ainsi récupérable. Toute autre +absence, version non supportée, corruption ou divergence interdit la reprise de cette tâche sans affecter les autres entrées de l'inventaire. @@ -98,8 +114,8 @@ affecter les autres entrées de l'inventaire. `lardon3d_task_checkpoint()`. Aucun autre thread ne force son arrêt. 4. **Progression bornée** : la progression ne peut jamais dépasser la valeur maximale définie par l'estimation. -5. **Reprise réadmise** : une tâche restaurée non terminale repasse par la file, - le scheduler et le Resource Governor avec une nouvelle réservation. +5. **Reprise réadmise** : une tâche restaurée non terminale repasse par la + Queue/runtime et le Resource Governor avec une nouvelle réservation. 6. **Snapshot court** : seuls les champs durables sont copiés sous le mutex ; la sérialisation et les I/O ont lieu après déverrouillage. @@ -178,8 +194,10 @@ n'avancent pas la rampe. L'A/B forcé ABBA a mesuré seulement +2,077617 % à depth 2 (54,661652238 contre 55,797311953 paires/s), sous le deadband 5 %, avec digest identique, quatre séquences de fallback local par exécution et zéro panne/discard. Depth 2 est donc -**REJECTED_WITH_MEASURED_REASON** pour AUTO normal. Compute Governor v2 reste -en cours jusqu'aux réconciliations restantes. +**REJECTED_WITH_MEASURED_REASON** pour AUTO normal. Cette décision Matcher est +gelée ; l'intégration TUI, la validation exécutable et la revue finale +indépendante sont acquises. L'audit global qui contient cette frontière est +désormais `GLOBAL_MAINTENANCE_AUDIT=PASS/FROZEN`. Pour Matcher Vulkan, le lease privé de capacité matérialise le contrat de la séquence seulement après son admission. Il alloue un ou deux payloads de @@ -190,8 +208,9 @@ complète; elle ne laisse ni réservation suivante sous-facturée, ni résultat Vulkan partiel. Cette boucle est maintenant `observe → choose → execute → measure → adapt next`. -Les CPU réductibles progressent uniquement par `1/2/4/8/12`, bornés par le -compute-pool ; CPU et lot ne sont jamais essayés ensemble. Les observations +Les CPU réductibles progressent par paliers de puissances de deux, puis vers la +capacité exacte du kind ou du compute-pool lorsque ce dernier palier diffère ; +CPU et lot ne sont jamais essayés ensemble. Les observations hôte privées comprennent utilisation du pool, mémoire/PSI/swap actif, GPU busy et RSS observé, sans confondre RSS et réservation. Le dernier diagnostic peut être tiré par numéro de série ou formaté dans un buffer borné ; le runtime ne @@ -266,7 +285,9 @@ lors de sa soumission explicite à la file. **IMPLEMENTED** — resoumission automatique sélective des tâches production à l'ouverture du projet. -**NOT_YET_WIRED** — autosave générique et dépendances entre tâches. +**NOT_YET_WIRED** — dépendances/DAG entre Tasks. Il n'existe volontairement pas +de timer autosave générique : chaque kind persiste d'abord son curseur métier à +sa frontière atomique, puis publie son checkpoint générique. **IMPLEMENTED** — `visual_index.update` traite jusqu'à seize Feature Sets par séquence, checkpoint après commit de segment et repasse par le Governor. @@ -280,7 +301,7 @@ Governor via `sequence_break`. La reprise est idempotente avec le curseur **PASS / FROZEN — Compute Governor v2.** `matcher.run` traite une Candidate Pair atomique à la fois, par lots opérationnels bornés jusqu'à 12. Le code courant consomme honnêtement CPU, lot et GPU du contrat choisi. Feature, -SIFT et RootSIFT appliquent l'admission OpenCV `1..12`; RAW et Photo Quality +SIFT et RootSIFT appliquent l'admission OpenCV `1..compute-pool`; RAW et Photo Quality restent CPU1. Pour ORB normal, le Governor choisit GPU-first ou CPU complet pour la prochaine séquence, sans mutation pendant son exécution. Ces dimensions ne changent ni identité scientifique ni publication. Le callback persiste le curseur @@ -297,11 +318,13 @@ handles encore privés. Les buffers du second slot ne sont mappés que pendant une séquence depth 2 admise et sont libérés avant la rupture suivante. Helpers reste 0 et le contrôle synchrone force depth 1. -**IMPLEMENTED** — `geometric_verifier.run` v1 traite un Match Result atomique à -la fois, par lots adaptatifs de 1, 2, 4 ou 8. Project DB v13 conserve sa -configuration scientifique et `after_match_result_id`. Chaque résultat est -publié avant le curseur ; pause, annulation, checkpoint et rupture de séquence -restent coopératifs aux frontières des parents et des lots. +**IMPLEMENTED** — `geometric_verifier.run` v1 traite des Match Results +indépendants sous un callback propriétaire, avec jusqu'à 16 participants sûrs, +8 utiles et 16 parents par lot. Project DB v13 conserve sa configuration +scientifique et `after_match_result_id`. L'USAC interne reste +`isParallel=false`; le propriétaire publie le préfixe canonique ordonné avant +le curseur. Pause, annulation, checkpoint et rupture de séquence restent +coopératifs aux frontières des parents et des lots. **IMPLEMENTED** — `track_builder.run` v1 est une tâche durable de rebuild complet. Le scope GVR est persistant et immuable ; la reprise rejoue depuis le @@ -340,12 +363,18 @@ avant l'appel hors mutex. `join()` attend la fin du callback ; le userdata reste donc valide pendant celui-ci et son destructeur n'est appelé qu'ensuite par la destruction de la tâche. +L'observation TUI ne change pas cette ownership : elle ne retient ni Task ni +userdata et ne pilote aucune transition. Le contrat installé de la Task active +est l'unique source exacte pour CPU/lot ; un diagnostic Governor par kind ne +peut pas être attribué à cette Task sans association Task+séquence. + Une tâche reconstruite mais refusée avant transfert à la queue est abandonnée localement : son userdata est détruit, sans callback terminal ni écriture durable d'une fausse annulation. Une annulation explicitement demandée conserve le contrat de notification terminale. -La Project Database v7 peut enregistrer transactionnellement un résumé +Depuis Project Database v7, la base peut enregistrer transactionnellement un +résumé `Lardon3DTaskDurableSnapshot` et la référence de son checkpoint. Elle ne stocke ni estimation sérialisée complète, ni callback, ni réservation, et ne remplace pas la validation du fichier checkpoint avant `task_restore()`. @@ -357,9 +386,17 @@ snapshots dont le codec/version et le résumé DB ont été validés, mais ne pe appeler `task_restore()` que via un descriptor connu ; aucun pointeur n'est persistant. +## Project DB courant + +Le schéma Project DB courant est v23. Cette évolution additive ne modifie ni le +codec checkpoint v1, ni les règles de reprise ci-dessus ; elle ajoute le modèle +optique générique décrit dans [Project Database](project_database.md). + ## Limites -- Aucune priorité interne : l'ordre est uniquement FIFO. +- Aucune priorité interne : l'ordre est FIFO stable avec bypass des seuls + `WAIT` ressources. - Pas encore de DAG pour ordonner des reprises interdépendantes. - Aucune dépendance inter-tâches (pas de DAG). -- Pas encore de références d'artefacts métier validés. +- Pas encore de références génériques d'artefacts métier validés dans le codec + checkpoint ; les payloads typés conservent leurs propres références durables. diff --git a/docs/architecture/visual_index.md b/docs/architecture/visual_index.md index fbd4acf..a11df68 100644 --- a/docs/architecture/visual_index.md +++ b/docs/architecture/visual_index.md @@ -161,7 +161,7 @@ valide. La reprise recommence au dernier curseur commité et l'unicité des memberships rend le rejeu idempotent. **IMPLEMENTED — parallélisme interne borné.** La Queue exécute toujours un seul -callback. L'estimation demande jusqu'à douze threads CPU, un slot I/O, GPU zéro, +callback. L'estimation demande jusqu'à seize threads CPU, un slot I/O, GPU zéro, 8 Mio fixes et 2 Mio par Feature Set, lot 1..16. Le callback compte comme un participant et crée au plus `cpu_threads - 1` enfants. Chaque enfant lit exclusivement des Feature Files immuables et écrit une tranche privée ; il ne diff --git a/docs/architecture/vulkan_matcher.md b/docs/architecture/vulkan_matcher.md index 0db89a9..3a95654 100644 --- a/docs/architecture/vulkan_matcher.md +++ b/docs/architecture/vulkan_matcher.md @@ -141,9 +141,12 @@ sortie, soit 8,125 Mio de payload lazy en plus des 640 Kio ORB. Sur 24 160 requêtes SIFT et 24 161 requêtes RootSIFT contrôlées, y compris des descriptors produits par OpenCV SIFT et les frontières `nextafter` autour -de Lowe 0,7, aucune divergence Lowe n'a été observée. Ce résultat de corpus -n'est pas une garantie universelle. FP32 ne reproduit pas les distances bit à -bit et un corpus d'égalités adversariales démontre +de Lowe 0,7, aucune divergence Lowe n'a été observée. La comparaison exacte +compte néanmoins une divergence d'index top-2 et 20 251 distances aux bits +différents pour SIFT, puis une divergence d'index et 20 824 distances aux bits +différents pour RootSIFT. Ce résultat de corpus au niveau de la décision Lowe +n'est donc ni une identité bit à bit ni une garantie universelle. FP32 ne +reproduit pas les distances bit à bit et un corpus d'égalités adversariales démontre une divergence d'indices reproductible : OpenCV choisit `(0, 1)` et FP32 Vulkan `(7, 14)`. FP64 choisit `(0, 128)` et ne restaure donc pas le contrat OpenCV. Le recalcul CPU des distances des deux candidats ne peut corriger une diff --git a/docs/concepts/geometric_constraints.md b/docs/concepts/geometric_constraints.md index 2f62aae..9dc994e 100644 --- a/docs/concepts/geometric_constraints.md +++ b/docs/concepts/geometric_constraints.md @@ -8,7 +8,14 @@ Ces contraintes exploitent la géométrie projective des caméras, la structure ## Statut -**PLANNED** — Concepts mathématiques fondamentaux, pas encore implémentés comme module distinct. Utilisés implicitement dans les futurs algorithmes de matching et reconstruction. +**MIXED / EXPLICIT BOUNDARIES.** Il n'existe pas de module générique unique +« Geometric Constraints ». La matrice fondamentale et son support sont +implémentés/PASS-FROZEN dans Geometric Verifier v3. Essential, pose relative, +cheirality, parallaxe, triangulation et reprojection calibrées sont +implémentées/PASS-FROZEN dans Sparse SfM Gates C–G. La compétition explicite +Fundamental/Essential/Homography, les contraintes d'occlusion complètes et les +relations entre ScanSets restent futures. Ces distinctions remplacent +l'ancienne formule où tout le matching et la reconstruction étaient futurs. ## Place dans le pipeline @@ -128,7 +135,7 @@ typedef struct { ### 6. Triangle Quality Constraints -La qualité des triangles de triangulation影响 la précision de la reconstruction : +La qualité des triangles de triangulation influence la précision de la reconstruction : | Métrique | Seuil recommandé | Description | |----------|------------------|-------------| @@ -149,9 +156,12 @@ La qualité des triangles de triangulation影响 la précision de la reconstruct ## Contraintes de conception -- Les seuils géométriques (parallaxe minimale, erreur de reprojection) sont calibrables mais immuables pendant un traitement. +- Les seuils géométriques courants sont explicites, fingerprintés lorsqu'ils + appartiennent à l'identité, et immuables pendant un traitement ; les valeurs + FROZEN ne sont pas « calibrables » implicitement. - L'estimation de F ou E utilise RANSAC avec un nombre d'itérations borné. -- Les homographies sont détectées automatiquement mais ne remplacent pas E pour les scènes non planes. +- La compétition/détection Homography explicite reste future et ne doit pas + être attribuée au Geometric Verifier v3 actuel. - Les contraintes de visibilité sont recalculées à chaque ajout de caméra. - Les résultats de filtrage géométrique sont auditables (log des rejets avec raison). - En cas d'incertitude, les contraintes sont conservatistes (rejeter plutôt qu'accepter). diff --git a/docs/concepts/matching_and_tracks.md b/docs/concepts/matching_and_tracks.md index cc816ff..a7673d5 100644 --- a/docs/concepts/matching_and_tracks.md +++ b/docs/concepts/matching_and_tracks.md @@ -6,13 +6,15 @@ > [tracks.md](../architecture/tracks.md). Les différences notables : > le Track Model v1 ne contient aucune coordonnée 3D, aucun statut > (ACTIVE/OPTIMIZED/REJECTED), aucune matrice de co-visibilité et aucun -> plafond de longueur arbitraire. La triangulation, le Sparse SfM et le -> Bundle Adjustment sont des étapes ultérieures séparées. +> plafond de longueur arbitraire. Le Matcher, le Geometric Verifier et le +> Track Builder réels sont désormais implémentés et documentés par leurs +> contrats d'architecture ; triangulation, Sparse SfM et Bundle Adjustment +> demeurent des étapes séparées de ce concept historique. > Frontière v1A : les groupes de support ORB/SIFT sont uniquement des preuves > locales intra-image. Ils ne comparent pas les espaces Hamming et L2, ne sont -> pas des matches multivues et ne créent aucun track. Le futur matcher choisira -> explicitement SIFT ou RootSIFT après génération des paires. +> pas des matches multivues et ne créent aucun track. Le Matcher production +> choisit explicitement le Feature kind retenu après génération des paires. ## Définition @@ -22,8 +24,10 @@ Le matching transforme les features individuelles en relations inter-images. Les ## Statut -**PLANNED** — Étape critique du pipeline, pas encore implémentée. Visual Index -v1 fournit désormais les candidats, leur score et leurs preuves distinctes. +**ARCHIVE HISTORIQUE — SUPERSEDED.** Candidate Pair, Matching v1, Geometric +Verification v3 et Track Model/Builder v1 sont implémentés et gelés dans leurs +documents d'architecture. Les structures et politiques conceptuelles ci-dessous +ne redéfinissent pas ces contrats courants. ## Place dans le pipeline @@ -44,10 +48,10 @@ Reconstruction Layers (triangulation) Le matching est le pont entre les caractéristiques 2D des images et la structure 3D de la scène. Le score Visual Index est un signal de retrieval. Il ne constitue ni un match -descriptor-descriptor final, ni une preuve épipolaire. La chaîne reste : -candidate Visual Index → futur matching → vérification géométrique → tracks. +descriptor-descriptor final, ni une preuve épipolaire. La chaîne courante reste : +candidate Visual Index → matching → vérification géométrique → tracks. -La génération future de paires candidates combinera `image_id`, appartenance +La génération production de paires candidates combine `image_id`, appartenance au ScanSet, résultats du Visual Index et provenance. Une proximité temporelle pourra servir de signal secondaire ; la proximité dans un dossier et le nom de fichier ne constituent jamais l'identité principale. diff --git a/docs/concepts/reconstruction_layers.md b/docs/concepts/reconstruction_layers.md index af55d37..5582e65 100644 --- a/docs/concepts/reconstruction_layers.md +++ b/docs/concepts/reconstruction_layers.md @@ -8,7 +8,11 @@ L'idée centrale est de décomposer la reconstruction en couches de complexité ## Statut -**PLANNED** — Concept architectural pour la décomposition du pipeline de reconstruction. Pas encore implémenté. +**ARCHIVE / PLANNED POUR CETTE ABSTRACTION GÉNÉRIQUE.** Le type de snapshot et +les cinq layers proposés ci-dessous ne sont pas une API implémentée. Les jalons +réels Track, Sparse SfM, BA, Phase H et MVS-M1 ont depuis été acquis par leurs +contrats canoniques distincts ; ils ne valident ni ce modèle générique de +layers, ni Dense complet, mesh, texturing ou viewer. ## Place dans le pipeline @@ -124,7 +128,7 @@ Les snapshots sont persistés sur disque et consultables par le viewer. | **Matching & Tracks** | Fournissent l'entrée de la couche 0. | | **Geometric Constraints** | Utilisées dans toutes les couches pour filtrer et valider. | | **Resource Governor** | Le gouverneur alloue les budgets pour chaque couche et contrôle la taille des lots. | -| **Task** | Chaque couche est une tâche candidate. Les couches sont séquencées par le scheduler. | +| **Task** | Chaque couche est une tâche candidate. Les couches sont séquencées par le runtime/Queue. | | **Hardware Profile** | Les couches 2-4 peuvent exploiter le GPU si disponible. | | **Image Catalog** | Les couches 2-4 lisent les images originales pour le dense matching et texturing. | | **Viewer** | Le viewer affiche les snapshots des couches validées. | diff --git a/docs/concepts/scan_sets.md b/docs/concepts/scan_sets.md index 0e50c45..e8b1373 100644 --- a/docs/concepts/scan_sets.md +++ b/docs/concepts/scan_sets.md @@ -34,8 +34,9 @@ Project courante proposée au pipeline scientifique ultérieur. La sélection ne modifie ni l'image, ni son asset, ni les résultats existants. - Une dérivation asset bornée parent→enfant, versionnée par fingerprint, rend - possible une future image de travail RAW ou frame vidéo sans prétendre que - RAW ou vidéo sont déjà importés. + possible l'image de travail DERIVED de `raw.develop` sans confondre SOURCE et + dérivé. La frame vidéo reste future et ne doit pas être présentée comme + importée. Un contenu identique n'implique pas une identité logique unique. Dans un même ScanSet, le couple `(scanset_id, asset_id)` est unique : réimporter le même @@ -135,6 +136,9 @@ Track Model / Track Builder v1 peuvent consommer le catalogue et ses assets. Les primitives géométriques Sparse SfM Gate C et le noyau incrémental synchrone en mémoire Gate D sont **IMPLEMENTED / PASS**. -**PLANNED** — BA, orchestration Sparse SfM, MVS et relations géométriques -entre ScanSets. La vérification/scrub des assets et la réconciliation globale -restent également NOT_YET_WIRED. +**IMPLEMENTED / PASS / FROZEN aux frontières acquises** — BA finale Gate E, +orchestration Sparse SfM Gate F, admission Gate G et Phase H v1. MVS-M1 fournit +la frontière externe bornée OpenMVS, mais pas une publication dense durable ni +le pipeline dense complet. Les relations géométriques entre ScanSets, +vérification/scrub des assets et réconciliation globale restent +`NOT_YET_WIRED` ; ce futur n'annule pas les gates déjà acquises. diff --git a/docs/concepts/visual_index.md b/docs/concepts/visual_index.md index c72c29c..3e26fc8 100644 --- a/docs/concepts/visual_index.md +++ b/docs/concepts/visual_index.md @@ -85,7 +85,7 @@ Image → [Feature 1, Feature 2, ..., Feature N] | **Image Catalog** | Le catalogue fournit les métadonnées nécessaires à l'indexation (dimensions, modèle caméra). | | **Image View** | Les vues peuvent filtrer les images avant indexation (par zone, par qualité). | | **Resource Governor** | Le gouverneur alloue la mémoire pour la structure d'index et contrôle la taille des lots d'indexation. | -| **Task** | L'indexation visuelle est une tâche candidate pour le scheduler. Elle est CPU-intensive mais parallélisable. | +| **Task** | L'indexation visuelle est une tâche candidate pour le runtime/Queue. Elle est CPU-intensive mais parallélisable. | | **Hardware Profile** | Le profil matériel détermine le type d'index optimal (KD-Tree pour peu de features, LSH pour beaucoup). | ## Contraintes de conception diff --git a/docs/development/build.md b/docs/development/build.md index 66627a2..7cfe867 100644 --- a/docs/development/build.md +++ b/docs/development/build.md @@ -5,10 +5,16 @@ - **OS** : Linux (testé sur distributions récentes) - **Compilateur** : Clang (recommandé) ou GCC - **Système de build** : Meson + Ninja -- **Dépendances** : ncursesw (ncurses avec support Unicode) -- **Langage** : C17 +- **Dépendances principales** : ncursesw, SQLite, OpenSSL, GIO/GLib, OpenCV, + LibRaw, libexif, libpng, libdeflate, Ceres ; Vulkan reste optionnel +- **Langages** : API publiques C17 et implémentation mixte C17/C++17 -## Installation des dépendances +## Bootstrap des outils + +Les commandes ci-dessous installent seulement le compilateur, Meson/Ninja, +`pkg-config` et ncurses. Les bibliothèques listées plus haut doivent aussi être +disponibles dans les versions acceptées par `meson.build`; Meson reste la source +de vérité et refuse explicitement une dépendance absente ou incompatible. ```sh # Debian / Ubuntu @@ -24,7 +30,11 @@ sudo pacman -S clang meson ninja ncurses pkgconf ## Build standard ```sh -CC=clang meson setup build --wipe +# Première configuration +CC=clang meson setup build + +# Arbre existant +meson setup --reconfigure build meson compile -C build -j8 ``` @@ -49,8 +59,69 @@ meson test -C build --print-errorlogs # Vérification du style (whitespace) git diff --check + +# Vérification autonome d'un header C public modifié +cc -x c -std=c17 -fsyntax-only -Iinclude \ + -include lardon3d/
.h /dev/null ``` +### Preuve fraîche de maintenance globale — 1er septembre 2026 + +Le [registre canonique](../architecture/global_maintenance_audit.md) conserve +le détail et les qualifications. Les résultats reproductibles acquis sont : + +| Configuration fraîche | Compilateurs/options | Build | Suite | +| --- | --- | ---: | ---: | +| portable | Clang/Clang++ 22.1.8, C17/C++17, `-Dvulkan_orb=disabled` | 931/931 | 64/64 sériel | +| Vulkan | Clang/Clang++ 22.1.8, C17/C++17, `-Dvulkan_orb=enabled` | 939/939 | 65/65 sériel | +| ASan/UBSan portable | Clang/Clang++ 22.1.8, `address,undefined` | graphe complet | 64/64 avec LSan désactivé après attribution externe | +| TSan portable | GCC/G++ 16.2.1, Vulkan désactivé | cibles concurrentes | 14/14 + 220 répétitions | + +La suite Vulkan comprend `orb-vulkan-backend` sur la Radeon 780M RADV PHOENIX +réelle. La cible de feasibility SIFT/RootSIFT, non enregistrée dans la suite, +a été compilée/exécutée séparément : zéro divergence de décision Lowe mais des +divergences d'index et de bits de distance, donc aucune promotion en backend +production. Les probes stricts GCC/Clang C17+C++17 passent 76/76 sur les +19 headers publics modifiés ou nouveaux, ainsi que le fixture ABI, le lien +application et `git diff --check`; `scan3d/` reste intact. + +L'unique revue finale indépendante GPT-5.6 SOL/ULTRA a conclu PASS sans finding +bloquant. Elle a indépendamment rejoué le build portable, la suite complète +64/64, une matrice focalisée 15/15, les 76/76 probes de headers, l'ABI, les +négatifs de seams production, le SHA-256 du manifest GV retenu et le diff-check. +Le statut canonique est donc `GLOBAL_MAINTENANCE_AUDIT=PASS/FROZEN` ; les +qualifications sanitizer ci-dessous restent néanmoins partie de la preuve. + +Une validation post-freeze a ensuite attribué le délai intermittent de +`test-feature-task` à la capture de la télémétrie hôte réelle par ses Governors +synthétiques. Le fixture utilise maintenant un `ResourceSnapshot` complet, +privé, par Governor et compilé pour cette seule cible ; production continue de +lire la télémétrie réelle et n'exporte aucun seam. Après correction du second +Governor relevé en revue, Feature passe 100/100, la matrice ordonnée 4/4 et les +suites finales portable/Vulkan 64/64 et 65/65 ; ASan/UBSan ciblé avec +`detect_leaks=0` et TSan passent. Le registre canonique conserve la régression +charge 5 `WAIT`/charge 0 `START` et la qualification exacte. Un timeout `task` +isolé dans une suite normale mixte après reconstruction large reste +non reproductible : le ciblé immédiat et sa matrice de revue 100/100 passent, +sans modification de Task ni de son timeout. + +La première suite LSan complète est volontairement conservée comme non-PASS : +57 OK, 6 FAIL et 1 timeout. Cinq échecs partagent exactement la fuite externe +OpenCL de 3 808 octets/68 allocations ; les deux anomalies de 30 s n'ont aucun +diagnostic sanitizer. Le délai Feature, absent du suivi initial, a ensuite été +reproduit et corrigé comme décrit ci-dessus ; le délai Task reste non +reproductible. La suite entière passe 64/64 avec ASan/UBSan actifs et +`detect_leaks=0`, tandis qu'un sous-ensemble prouvé sans loader OpenCV/OpenCL +passe 20/20 avec LSan actif. Il est donc incorrect de résumer cette preuve par +« LSan 64/64 ». + +Le log Clang complet a aussi été audité. La conversion publique Sparse SfM +`uint32_t → int` était matérielle et a été corrigée avec validation ciblée ; les +autres émissions sont soit des conversions baseline déjà bornées, soit des +tests/benchmarks, soit des headers OpenCV/Ceres externes. Les emplacements et +justifications exacts restent centralisés dans le registre afin de ne pas +dupliquer une seconde liste normative ici. + ## Build ASan/UBSan (debug mémoire) À exécuter pour tout ticket touchant la mémoire, les durées de vie ou les diff --git a/docs/development/concurrency.md b/docs/development/concurrency.md index 7793d08..98d808b 100644 --- a/docs/development/concurrency.md +++ b/docs/development/concurrency.md @@ -19,6 +19,9 @@ Worker thread ├── Exécution des tâches ├── Calculs métier └── Écritures de résultats + +SSD operation thread (0 ou 1, joinable) +└── Un poll ou contrôle UDisks synchrone borné, sans ncurses ni Task ``` ## Règles fondamentales @@ -48,7 +51,7 @@ pthread_mutex_unlock(&queue->mutex); ### 3. Variables de condition pour la synchronisation ```c -// Producteur (scheduler) +// Producteur (caller de la Task Queue) pthread_mutex_lock(&queue->mutex); queue->ready = true; pthread_cond_signal(&queue->cond); @@ -89,7 +92,7 @@ void worker_callback(task_t *task, void *userdata) { | `pthread_cond_t` | Synchronisation producteur/consommateur | | `pthread_create()` | Création des workers | | `pthread_join()` | Attente de fin des workers | -| `pthread_cancel()` | Annulation d'un worker (dernier recours) | +| `pthread_cancel()` | Non utilisé pour interrompre une Task ; annulation coopérative | ## Invariants de concurrence @@ -101,7 +104,7 @@ void worker_callback(task_t *task, void *userdata) { le même ordre pour éviter les deadlocks. 3. **Annulation coopérative** : les workers vérifient périodiquement un - drapeau d'annulation. Pas d'interruption brutale sauf dernier recours. + drapeau d'annulation. Une Task n'est pas interrompue brutalement. 4. **Réservation atomique** : la réservation du gouverneur est atomique. Deux threads ne peuvent pas obtenir la même réservation. @@ -110,6 +113,46 @@ void worker_callback(task_t *task, void *userdata) { invoqué sans réservation active. Cet invariant est maintenu même en présence d'erreurs. +6. **Retraite après callback** : la notification terminale finit avant la + destruction du userdata. Queue détruit la Task hors de son mutex et ne + conserve ensuite qu'un snapshot borné. + +7. **Fermeture d'ingress** : le propriétaire empêche les nouveaux appels Queue + avant `destroy()`. La fermeture interne attend le worker et chaque appel + enregistré avant le close ; elle ne peut rendre sûr un appel démarré après + la libération d'un pointeur C brut. + +8. **Parallélisme scientifique propriétaire** : lorsqu'un kind emploie des + participants internes, le callback Queue demeure l'unique propriétaire. Le + nombre de participants et leur mémoire sont admis par le Governor ; seul le + propriétaire publie le préfixe durable ordonné et joint tous les enfants. + +9. **Lease SSD par objet** : un lease scratch appartient à l'adresse exacte de + l'objet fourni par le caller. Tous ses champs sont lus/écrits sous le mutex + du contrôleur. Le caller lui garantit un accès exclusif et ne le copie, ne le + déplace ni ne le présente simultanément à deux contrôleurs. En production, + acquire/release passent par les wrappers Governor ; le Governor relâche son + mutex avant l'appel contrôleur, et le contrôleur ne rappelle jamais le + Governor. À la saturation légale `generation == UINT64_MAX`, seule la fin + du wrapper exact déjà sérialisé peut réconcilier sa propre opération et le + compte fondé sur les adresses ; une update publique au même watermark ne + peut pas rendre une autorité stale. + +10. **Owner SSD unique** : la TUI/main demande et poll l'opération ; au plus un + thread joinable exécute une opération bornée et ne touche jamais ncurses. + Le destroy le joint avant unregister. Une observation malformée enregistre + `ERROR` et ne confère aucune autorité de contrôle ou de lease. + +11. **Frontière projet** : les vues libèrent leurs borrows, puis la Queue est + annulée/jointe/détruite avant Project DB. Une Queue vide est créée ensuite. + Aucun callback terminal ne peut donc déréférencer une DB déjà fermée et + l'histoire d'un projet ne fuit pas dans le suivant. + +12. **Ordre d'arrêt global** : Queue et leases Task, puis fermeture projet, + join/unregister du binding SSD, contrôleur SSD, et enfin Governor. Un + unregister encore bloqué par un lease est un échec observable, jamais un + pointeur abandonné. + ## Anti-patterns ### Deadlock @@ -149,7 +192,7 @@ pthread_mutex_unlock(&task->mutex); task_destroy(task); task_callback(task); // task est libéré -// ✅ Correct +// ✅ Correct : le callback est entièrement revenu avant la destruction task_callback(task); task_destroy(task); ``` @@ -171,10 +214,33 @@ meson test -C build-tsan --print-errorlogs ``` TSan détecte automatiquement : -- les race conditions -- les deadlocks potentiels -- les signaux perdus -- les verrous non libérés + +- les accès concurrents conflictuels instrumentés ; +- certaines utilisations incohérentes des primitives de synchronisation. + +Il ne prouve pas l'absence de deadlock, de signal perdu ou de bug dans une +bibliothèque non instrumentée. Les invariants de lifetime et d'ordre de locks +restent donc soumis aux tests déterministes et à la revue. + +### Preuve TSan globale courante + +La matrice fraîche emploie GCC/G++ 16.2.1 et désactive explicitement Vulkan. +Elle passe 14/14 cibles couvrant Task, Project, Queue, Governor, registre/leases +SSD, contrôleur SSD, observateur/TUI async, Candidate, Visual Index, Feature, +Matcher et GV, puis 220/220 répétitions déterministes : **234/234** au total. + +La seule liste de suppressions est `tests/tsan-opencv.supp`, limitée aux objets +partagés externes non instrumentés `libopencv_features.so`, +`libopencv_core.so` et `libtbb.so`. Elle ne masque aucune frame Lardon3D. Les +warnings GCC `-Wmaybe-uninitialized` des contrôles OpenCV Feature/SIFT sont +classés non matériels : le callback fournit une Task non nulle et le helper +initialise la structure avant toute autre sortie d'échec. Les warnings OpenCV +du build GV appartiennent aux headers externes. + +Cette preuve TSan ne vaut pas validation de concurrence Vulkan. Le backend +ORB Vulkan réel est couvert séparément par le build Clang Vulkan-on 939/939, +la suite 65/65 et ses tests de backend/handle/publication ; cette séparation +doit rester explicite dans tout rapport. ## Checklist de concurrence @@ -184,6 +250,6 @@ Avant de livrer un ticket touchant la concurrence : - [ ] Les mutex sont toujours libérés (même en cas d'erreur) - [ ] Les variables de condition sont vérifiées dans une boucle `while` - [ ] Aucun appel ncurses depuis un worker -- [ ] L'annulation est coopérative (pas de `pthread_cancel` sauf dernier recours) +- [ ] L'annulation des Tasks est coopérative (pas de `pthread_cancel`) - [ ] TSan ne signale aucune erreur - [ ] Le build ASan ne signale aucune fuite mémoire liée aux threads diff --git a/docs/performance/target_hardware.md b/docs/performance/target_hardware.md index 0536ce8..efd06d3 100644 --- a/docs/performance/target_hardware.md +++ b/docs/performance/target_hardware.md @@ -1,9 +1,13 @@ -# Profil de performance de la machine cible +# Profil de performance de l'hôte de validation Ce document décrit une politique de performance mesurée. Il ne modifie aucun contrat de correction du Matcher, du Match Store ou du Match Result. -## Cible principale actuelle +## Hôte principal de validation + +Ce profil est une preuve de performance et de stabilité pour la politique +portable. Il ne constitue ni une identité produit, ni une exigence matérielle, +ni un plafond CPU/GPU applicable aux autres hôtes. - AMD Ryzen 7 8845HS, Zen 4, 8 cœurs et 16 threads SMT ; - Radeon 780M à mémoire système partagée ; @@ -22,18 +26,22 @@ Le sysfs amdgpu de cet hôte expose 512 Mio dans `mem_info_vram_total` et aperture/GTT à l'échelle de la RAM suffit à classer ce GPU shared/UMA sans hardcoder son device ID. Le Governor débite alors les ressources GPU exactement une fois de `MemAvailable` et n'utilise jamais cet aperture comme mémoire libre -séparée pour contourner les objectifs 3 Gio/2 Gio. +séparée pour contourner la réserve dure de 3 Gio et la zone de prudence jusqu'à +4 Gio. Le runtime actuel de Lardon3D possède un worker lourd. Le profil interactif -établit au démarrage une baseline/plafond OpenCV de 12 threads et réserve quatre -threads logiques au desktop. Pour chaque séquence, l'unique callback Queue -applique temporairement le compte CPU immuable admis dans `1..12`, le vérifie et +établit au démarrage une baseline OpenCV depuis le compute-pool réellement +disponible. Sur cet hôte précis, le Governor réserve quatre threads logiques au +desktop et en admet douze ; douze est une observation matérielle, pas un plafond +produit. Pour chaque séquence, l'unique callback Queue applique temporairement +le compte CPU immuable admis dans `1..compute-pool`, le vérifie et restaure la baseline sur toute sortie. `cv::setNumThreads()` reste une configuration process-wide : sa mutation concurrente par plusieurs workers n'est pas supportée. Un futur pool multi-worker devrait donc changer ce modèle explicitement, pas multiplier silencieusement ces mutations globales. -Le worker Queue applique à lui-même le compute-pool `0-5,8-13`, tandis que le +Sur cette machine mesurée, le worker Queue applique à lui-même le compute-pool +`0-5,8-13`, tandis que le creator/main reste unrestricted `0-15`. Certains helpers de cache disque Mesa observés avaient réélargi leur affinité après l'initialisation lazy. Comme un pidfd ne stabilise pas le TID numérique pour `sched_setaffinity(tid)`, Lardon3D @@ -96,9 +104,10 @@ environ 0,10, 0,96, 14,7 et 59,6 ms pour BFMatcher CPU lors de la campagne production. L'initialisation lazy mesurée vaut environ 129–136 ms. Le seuil `feature_count_a × feature_count_b >= 768²` évite le GPU pour les petits travaux. -La mémoire directement contrôlée vaut 640 Kio par slot; rolling AUTO peut -réserver un ou deux slots, soit au plus 1,25 Mio, débités une fois de la RAM sur -la 780M UMA. Le backend mappe exactement cette capacité pendant la séquence et +La mémoire directement contrôlée vaut 640 Kio par slot. Rolling AUTO normal +réserve exactement un slot ; seule la couture privée de sûreté/benchmark peut +réserver deux slots, soit au plus 1,25 Mio, débités une fois de la RAM sur la +780M UMA. Le backend mappe exactement cette capacité pendant la séquence et rend le second slot avant une admission depth 1 suivante. Les tests de parité couvrent exactement le top-2 jusqu'à 8192, le Match File complet et le fallback CPU. Ces nombres décrivent la machine mesurée et ne sont pas un contrat portable @@ -131,14 +140,23 @@ des proxies objectifs, pas une mesure subjective de fluidité. Le coût court et le faible working set rendent Vulkan `NOT_JUSTIFIED` pour ce verifier sur la Radeon 780M. Le GPU reste utilisé uniquement par le Matcher ORB existant. -Le profil production réserve un CPU logique, 4 Mio et un worker, avec lots 1/2/4/8. Un run de la -vraie Task sur 1000 parents, suivi de ses chemins de reprise/configuration, a traversé environ +La validation scientifique initiale réservait un CPU logique, 4 Mio et un +worker, avec lots 1/2/4/8. Un run de la vraie Task sur 1000 parents, suivi de +ses chemins de reprise/configuration, a traversé environ 2001 parents réutilisés en 5,870 s (environ 341/s). Ce chiffre inclut DB, checkpoints et fixture ; il ne remplace pas la latence estimator-only Gate A. Le processus de test a culminé à 25 964 Kio RSS. `MemAvailable` a varié de 10 702 988 à 10 692 916 Kio, sans swap ; PSI avg10 final était 0,34 % CPU et 0 % mémoire/I/O. Les latences médiane/p95 par parent et le RSS début/fin n'étaient pas mesurables avec ce harness et ne sont donc pas extrapolés. +La maintenance ultérieure a conservé les octets GVR et l'USAC interne sériel, +mais parallélisé les parents indépendants sous un callback propriétaire. Sur le +fixture réel de 4113 parents, CPU1/2/4/8/12 donnent respectivement +60,7514/83,9556/104,7545/116,6329/119,7606 parents/s, avec digest littéral +identique. La capacité sûre est 16 participants et 16 parents par lot ; la +politique utile s'arrête à CPU8, car CPU12 n'ajoute que 2,68 %. Ces chiffres +restent une preuve locale, pas une identité ni un optimum portable. + ## Feasibility Vulkan SIFT / RootSIFT Sur le même RADV PHOENIX, `shaderFloat64`, les timestamps compute, un subgroup @@ -151,8 +169,10 @@ et 1,11×. Les quatre paires asymétriques testées perdent face au CPU. FP64 atteint environ 271 ms à 8192². La campagne numérique trouve une divergence top-2 sur des sommes égales -adversariales pour FP32 comme FP64, des distances et Match Files différents, -mais aucune divergence Lowe sur le corpus testé. Le backend devrait donc porter +adversariales pour FP32 comme FP64. Sur 24 160 requêtes SIFT et 24 161 requêtes +RootSIFT, elle compte respectivement une divergence d'index, 20 251 divergences +de bits de distance et zéro divergence Lowe, puis une divergence d'index, +20 824 divergences de bits et zéro divergence Lowe. Le backend devrait donc porter une identité scientifique propre et ne pourrait pas employer le fallback CPU transparent d'ORB. Cette complexité n'est pas justifiée par le profil de performance : SIFT et RootSIFT Vulkan sont rejetés pour Lardon3D v1 sur cette diff --git a/docs/roadmap/roadmap.md b/docs/roadmap/roadmap.md index 2c29b07..3eeb414 100644 --- a/docs/roadmap/roadmap.md +++ b/docs/roadmap/roadmap.md @@ -23,10 +23,10 @@ en RAM ou termine dans une seule vie de processus. [Project DB](../architecture/project_database.md). - MVS-M1, frontière OpenMVS v2.4.0, identité dense et export COLMAP/PLY borné : [pipeline de reconstruction](../architecture/reconstruction_pipeline.md). -- Task Runtime, checkpoints atomiques, Queue, Scheduler et Resource Governor : +- Task Runtime, checkpoints atomiques, Queue et Resource Governor : [Task](../architecture/task_system.md), [Queue](../architecture/task_queue.md) et [Governor](../architecture/resource_governor.md). -- Project DB v19 et S1–S3 Capture / Acquisition Ingestion : provenance +- Project DB v22 gelé, overlay optique additif v23 courant, et S1–S3 Capture / Acquisition Ingestion : provenance Capture/Asset, import capture-safe, publication dérivée, développement RAW, siblings multi-source, évidence S3-D, orchestration S3-E et campagne bornée : [Project DB et ingestion](../architecture/project_database.md). @@ -177,15 +177,17 @@ réduction à 1024 pixels et une réservation incluant le contexte retenu plus 2 de buffers d'analyse par groupe ; ces bornes ne sont pas des limites scientifiques de campagne ou de dataset. Elle doit conserver une voie honnête pour les RAW sans proxy plutôt que de -présumer que chaque RAW a un JPEG sibling. La revue TUI détaillée, la guidance -de capture et les keyframes vidéo restent des intégrations ultérieures qui -réutiliseront ce même chemin de qualité, sans second pipeline. +présumer que chaque RAW a un JPEG sibling. La revue interactive détaillée des +recommandations Photo Quality, la guidance de capture et les keyframes vidéo +restent des intégrations ultérieures qui réutiliseront ce même chemin de +qualité, sans second pipeline. Cette limite ne concerne pas l'observatoire +runtime TUI courant. -## NEXT REAL-DATA MILESTONE — SELECTED SCIENTIFIC EXECUTION ON ENGINE BAY INPUTS — IMPLEMENTATION / VALIDATION IN PROGRESS +## SELECTED SCIENTIFIC EXECUTION ON ENGINE BAY INPUTS — PASS / FROZEN L'intégration réelle opt-in porte uniquement sur les acquisitions sélectionnées des campagnes Engine Bay et s'arrête à la frontière pré-SfM validable. Pour -chaque campagne, un exécutable éphémère crée un projet temporaire Project DB v22 +chaque campagne, l'exécutable d'évidence a créé un projet temporaire Project DB v22 distinct : Visual Index v1 est borné à 4096 images, tandis que l'ensemble A6000+S21 en compte 4497. Cette séparation est opérationnelle ; elle ne redéfinit ni Capture, ni Asset, ni `image_id`, ni l'identité scientifique des acquisitions. @@ -202,20 +204,23 @@ qualité → candidats du même ScanSet → matching ORB → GV gelée -→ tracks +→ arrêt durable avant Tracks ``` Il rouvre ensuite le Project DB afin de rapporter l'évidence durable produite. -Il n'introduit ni coordinateur réutilisable, ni DAG, ni sidecar, ni scheduler, -ni version Project DB v23 : Task, Queue, Scheduler et Resource Governor existants -conservent leurs responsabilités. +Il n'introduit ni coordinateur réutilisable, ni DAG, ni sidecar, ni scheduler +distinct : Task, Queue et Resource Governor existants conservent leurs +responsabilités. L'overlay optique Project DB v23, ajouté ultérieurement, ne +réinterprète pas cette preuve v22 ; les copies des deux projets migrent avec les +comptes scientifiques inchangés et les nouvelles tables optiques vides. Le Sparse SfM réel final reste `BLOCKED_BY_KNOWN_CALIBRATION_DATA` pour ces campagnes ; aucune pseudo-calibration, interpolation de métadonnées ou inférence d'identité ne le contourne. Dense, mesh et publication aval ne font pas partie de -ce jalon pré-SfM. Ce milestone est une intégration réelle, pas une nouvelle série -de micro-gates S3, et son statut ne revendique encore ni implémentation achevée, -ni validation réussie, ni PASS/FROZEN. +ce jalon pré-SfM. Ce milestone est une intégration réelle, pas une nouvelle +série de micro-gates S3. Son statut acquis ne rend pas disponibles les données +de calibration physique manquantes et n'autorise pas à devancer la maintenance +globale. ## INTERNAL PARALLELISM + COMPUTE RESOURCES v1 — PASS / FROZEN @@ -249,7 +254,8 @@ choix CPU/Vulkan explicites restent des overrides de debug, benchmark et reproductibilité. Les dimensions retenues et toutes les validations v2 sont closes. -CPU12 est validé. Le Governor dérive désormais le pool lourd depuis le masque +Le pool CPU12 est validé comme preuve de cet hôte, pas comme plafond produit. +Le Governor dérive désormais le pool lourd depuis le masque permis et les groupes package/core/SMT. Sur l'hôte unrestricted courant, il obtient `0-5,8-13` et réserve `6,7,14,15`; un caller déjà précontraint ne subit pas une seconde réserve. Le worker Queue seul applique/vérifie son propre @@ -261,12 +267,15 @@ valeur explicite est préservée mais refusée. Cette politique non scientifique supprime les helpers de cache Mesa observés qui élargissaient leur masque. Les threads runtime restants héritent le compute-pool ; il n'existe plus de sweep, latch ou retry auxiliaire et le diagnostic expose la politique réelle. -Creator/main/TUI reste unrestricted. Le fallback au budget portable ne crée -aucune exclusion +Creator/main/TUI reste unrestricted. Les CPU déjà exclus de l'affinité du +processus comptent dans la réserve hôte ; le fallback count-only ne fabrique +aucun masque. Le fallback au budget portable ne crée aucune exclusion inventée. Cette couture est **PASS / FROZEN** sur le profil validé; ces IDs ne -sont pas une politique portable. La cible RAM conserve -3 GiB de `MemAvailable` et ne franchit pas intentionnellement le plancher dur de -2 GiB. Les PSI CPU/mémoire/I/O et les deltas swap-in/swap-out sont des signaux +sont pas une politique portable. La cible RAM conserve une réserve dure de +3 GiB de `MemAvailable`; la zone 3–4 GiB est une prudence qui ne soustrait pas +4 GiB à toute capacité. Les petits hôtes dégradent le budget en conservant au +moins une unité de calcul. Les PSI CPU/mémoire/I/O et les deltas +swap-in/swap-out sont des signaux actifs ; l'occupation totale du swap reste historique. Admission CPU et lot sont indépendantes. Sur la 780M, Hardware Profile classe conservativement comme UMA le petit aperture VRAM amdgpu de 512 Mio accompagné d'environ 7,99 Go de @@ -277,7 +286,7 @@ L'audit Phase 1 couvre les 14 kinds de production et sépare leurs dimensions fixes des dimensions réellement adaptables. Il confirme que tous passent par l'unique Governor, même les formes fixes, et que le contrat reste immutable pendant une séquence. Cette tranche ferme l'enveloppe privée, la sélection AUTO, -les diagnostics bornés, l'enforcement OpenCV adaptatif 1..12, la politique +les diagnostics bornés, l'enforcement OpenCV borné au compute-pool, la politique d'affinité privée et la réconciliation du contexte retenu des campagnes nouvelles ou restaurées. La création/reprise AUTO ne touche plus Vulkan sur le main ; le premier begin appartient au worker contraint. Inflight ORB normal est @@ -314,8 +323,9 @@ et GPU busy DRM, avec `unknown` sur absence ou parse non strict. Le backend Vulkan fournit des compteurs cumulatifs bornés de submit/complétion/fence/ readback/GPU/starvation/panne/discard ; Matcher agrège en plus CPU et publication par séquence. Le diagnostic est tirable par numéro de série, sans log ncurses ni -histoire persistée. Les CPU réductibles progressent `1/2/4/8/12` après deux -observations de baseline et deux gains d'au moins 5 % ; CPU et lot ne changent +histoire persistée. Les CPU réductibles progressent par puissances de deux vers +la capacité exacte du kind/compute-pool après deux observations de baseline et +deux gains d'au moins 5 % ; CPU et lot ne changent jamais dans le même essai. Sous pression, une admission adaptative encore permise réserve immédiatement CPU1 et lot minimum. Feature/SIFT/RootSIFT ne comptent un item qu'après extraction et publication durable propre ; READY, @@ -425,8 +435,9 @@ complet. Le digest Matcher reste La seconde reprise crée zéro ligne ; une Task interrompue sur une copie dédiée reprend le même ID et converge vers les mêmes lignes exactes. Feature, Candidate et Matcher ne sont pas rejoués. Track Builder et Sparse SfM ne sont -pas exécutés. `REAL_S21_GV_V3=PASS/FROZEN` ; la prochaine tranche S21 commence -donc après GV, sans prétendre que Tracks est acquis. +pas exécutés. `REAL_S21_GV_V3=PASS/FROZEN` ; la prochaine tranche scientifique +S21 commencera donc à Tracks maintenant que la gate de maintenance globale +ci-dessous est fermée. Tracks n'est pas acquis. Les gates de fermeture sont acquis : @@ -477,34 +488,99 @@ fondation scientifique pré-SfM courante correction progression/Resource Governor, audit GPU) → COMPUTE GOVERNOR v2 / ORB VULKAN ASYNC EXECUTION (PASS / FROZEN) → REAL S21 GV v3 (PASS / FROZEN; arrêt avant Tracks) +→ GLOBAL MAINTENANCE AUDIT + (PASS / FROZEN; validation et revue finale indépendante acquises) → poursuite pré-SfM réelle de S21 à partir de Tracks → acquisition dédiée de calibration → Sparse SfM réel → Dense / MVS ``` +## GLOBAL MAINTENANCE AUDIT — PASS / FROZEN + +Le jalon scientifique acquis reste arrêté après GV. Avant Tracks, Sparse SfM +ou Dense/MVS, la gate de maintenance globale a réconcilié l'architecture, les +ressources, la persistance et l'observation TUI. Son état et ses preuves +consolidées sont consignés dans le +[registre canonique de maintenance](../architecture/global_maintenance_audit.md). + +Les résultats déjà réglés sont : + +- Project DB v23 ajoute neuf relations optiques sans backfill ni inférence ; + les boîtiers, objectifs manuels/électroniques, configurations et calibrations + restent distincts, et la sélection de calibration exige une compatibilité + exacte et explicite ; +- le Governor réserve d'abord l'hôte, sans plafond CPU global 12 : réserve de + quatre CPU logiques sur hôte capable, groupes cœur/SMT complets lorsque la + topologie est fiable, au moins une unité de calcul sur petit hôte, réserve + RAM dure 3 GiB et prudence 3–4 GiB, PSI/swap actif et UMA comptée une fois ; +- Candidate porte une capacité sûre 64, Visual Index 16, ORB/SIFT/RootSIFT sont + bornés par le compute-pool, Matcher conserve sa borne intrinsèque sûre 12 et + utile 8, et GV conserve son USAC interne sériel mais admet 16 participants + sûrs/8 utiles et 16 parents par lot ; +- le contrôleur SSD UDisks2 optionnel est une frontière physique revue, avec + identité Drive+labels+UUID, leases, drain sûr et latch de danger pour action + indéterminée. Son état est enregistré auprès du Governor, seul orchestrateur + des leases de production ; il n'est ni scheduler ni second Governor ; +- la TUI est un observatoire/centre de contrôle validé opérationnellement : + ncurses main-thread, modèle pur, observation coalescée et bornée, progression + durable/ETA, pipeline et ressources honnêtes, profils optiques, layouts + 100×30/72×20/60×15, repli texte/couleur et F10 SSD asynchrone toujours + visible ; +- la frontière de session détruit/joint la Queue avant Project DB, puis recrée + une seule Queue ; l'arrêt global libère les leases Task avant unregister SSD, + contrôleur et Governor. + +Les validations finales exécutables sont acquises : build Clang portable +931/931 + suite 64/64, build Clang Vulkan 939/939 + suite 65/65 sur Radeon +réelle, ASan/UBSan 64/64 avec la limitation LSan OpenCL externe explicitement +qualifiée, LSan loader-free 20/20, TSan 14/14 + 220 répétitions, headers publics +76/76 sur 19 headers modifiés/nouveaux et contrôles ABI/diff/`scan3d`. +L'unique revue finale indépendante GPT-5.6 SOL/ULTRA a conclu PASS sans finding +bloquant après avoir indépendamment rejoué le build portable, la suite 64/64, +15/15 tests focalisés, les 76/76 probes de headers, l'ABI, les négatifs de seams +production, le SHA du manifest GV retenu et le diff-check. La gate de +maintenance est donc fermée ; la poursuite réelle depuis Tracks devient la +prochaine tranche séparée, sans avoir été exécutée par cette synchronisation. + ## NEAR TERM ### Scratch SSD externe et swap optionnel -Matériel envisagé : SSD externe d'environ 500 Go dans un boîtier USB-C 10 Gb/s. -La capacité planifiée est : détecter un disque externe adapté, le présenter dans -la configuration TUI/projet et demander si ce projet doit l'utiliser. +Le contrôleur physique actuel découvre par UDisks2 une paire exacte de labels +`LARDON_SWAP`/`LARDON_SCRATCH`, exige des UUID stables et la même identité +Drive, et ignore les renommages de nœud `/dev`. Modèle, série et vitesse USB +restent de la télémétrie optionnelle, jamais une identité produit. Les états +bornés sont `ABSENT`, `DETECTED`, `ENABLING`, `ENABLED`, `IN_USE`, `DRAINING`, +`SAFE_TO_UNPLUG` et `ERROR`. -Usages possibles : workspace/scratch, intermédiaires dense/mesh/texturing et, -sur activation explicite, swap de sécurité. Le contrat devra être possédé par -l'exécution Task, le Resource Governor et la politique temporaire du projet ; -il ne doit pas devenir un gestionnaire de stockage ad hoc. +Les usages futurs possibles sont workspace/scratch et intermédiaires +dense/mesh/texturing. Leur consommation devra être explicitement possédée par +les Tasks et passer par les wrappers de lease du Resource Governor ; le +contrôleur physique et le registre actuel n'inventent aucune éligibilité Task. +Les quatorze kinds courants ne consomment aucun scratch. Le swap de sécurité, +lui, reste une fonction du drain physique explicite et jamais un budget de +travail. -- le scratch déplace les gros intermédiaires hors de la RAM et du disque système ; -- il peut permettre des jobs bornés plus grands ; +- les leases scratch ont une ownership explicite ; `DRAINING` refuse les + nouveaux leases et attend leur libération exacte ; +- le swap n'est arrêté que si son usage est absorbable tout en conservant la + réserve hôte de 3 GiB, sans PSI élevé ni swap-in/out actif ; - SSD/swap ne sont jamais de la RAM ni une extension du budget scientifique ; - latence et débit USB restent distincts de la mémoire ; -- l'utilisation reste optionnelle et Governor-controlled ; -- retrait, déconnexion, ownership, nettoyage et publication atomique exigent - des sémantiques explicites ; -- aucun montage, formatage, `swapon` ou nettoyage destructif automatique n'est - actuellement implémenté ou autorisé. +- l'utilisation reste optionnelle ; le Governor demeure l'unique orchestrateur + de ressources et seul owner des leases scratch de production ; +- une action UDisks potentiellement appliquée mais non vérifiable verrouille le + tuple physique original ; un remplacement n'obtient aucune autorité ; +- aucun formatage, partitionnement, fsck, réparation, arrêt forcé ou suppression + n'est permis. La production n'appelle pas `statvfs`; espace total/libre reste + `UNKNOWN` quand UDisks ne le fournit pas. + +Le contrôleur, son registre Governor, le worker joinable unique et sa +présentation/actions F10 sont **CURRENT / VALIDATED OPERATIONAL**. Les tests +emploient un provider factice et ne montent, n'arrêtent ni ne modifient un vrai +disque. Ce statut ne crée aucun consommateur scratch ; c'est la validation +consolidée et la revue indépendante ci-dessus qui ont fermé la gate globale. ### Publication durable dense / mesh @@ -517,12 +593,16 @@ dense → mesh → refinement → texturing → consolidation → export Le scratch devient particulièrement pertinent à cette frontière. -### Workflow TUI-first +### Workflow TUI-first courant -La TUI exposera état projet, découverte de campagne, confirmation des candidats, -progression, pause/reprise/annulation disponible, Governor, choix du scratch et -étapes aval. ncurses reste au thread principal. Aucun projet GUI général n'est -introduit. +La TUI expose actuellement état projet, Tasks et contrôles disponibles, +progression durable/ETA, Governor/ressources, profils optiques et SSD F10. +ncurses reste au thread principal ; les opérations SSD bornées utilisent un +seul thread joinable sans devenir un scheduler. La découverte/édition optique +ne devine aucune identité : objectif manuel sans EXIF, profils/configurations +immuables, affectations campagne/Capture et sélection de calibration exacte +restent explicites. Le viewer général, la capture guidance, la consommation +dense du scratch et les workflows scientifiques aval restent futurs. ### Sources mixtes et multi-ScanSet @@ -795,6 +875,8 @@ CURRENT NEXT → COMPUTE GOVERNOR v2 / ORB VULKAN ASYNC EXECUTION (PASS / FROZEN; AUTO GPU-first ORB et preuve S21 Matcher complète) → REAL S21 GV v3 (PASS / FROZEN; Tracks/Sparse SfM non exécutés) + → GLOBAL MAINTENANCE AUDIT + (PASS / FROZEN; gate fermée avant la tranche Tracks séparée) → poursuite pré-SfM réelle de S21 à partir de Tracks → acquisition physique dédiée de calibration → calibration connue validée → Sparse SfM réel multi-campagne @@ -830,7 +912,7 @@ un second runtime, Governor ou système de persistance. 1. stabilité et intégrité scientifique avant débit ; 2. résultats atomiques et durables avant parallélisme ; 3. lots bornés et reprise avant taille de campagne ; -4. un Task Runtime, un Scheduler et un Resource Governor ; +4. un Task Runtime/Queue et un Resource Governor, sans second scheduler ; 5. scratch optionnel sans élargissement implicite des budgets RAM ; 6. TUI de contrôle avant visualisation riche ; 7. documentation canonique alignée sur le code validé. diff --git a/include/lardon3d/acquisition_campaign_task.h b/include/lardon3d/acquisition_campaign_task.h index 419962a..d18ce5f 100644 --- a/include/lardon3d/acquisition_campaign_task.h +++ b/include/lardon3d/acquisition_campaign_task.h @@ -33,6 +33,7 @@ enum { 128u) typedef struct { + /* Arrays are caller-owned and borrowed only for encode/create/enqueue. */ const Lardon3DAcquisitionCampaignSource *sources; size_t source_count; const Lardon3DAcquisitionCampaignConfirmation *confirmations; @@ -40,23 +41,43 @@ typedef struct { Lardon3DAcquisitionIngestOptions ingest_options; } Lardon3DAcquisitionCampaignTaskRequest; +/* Create one durable, initially unqueued campaign Task for an existing + * ScanSet. request arrays are copied into the bounded deterministic codec and + * are not retained. task_id is required and receives zero on failure. The + * caller owns the returned Task and must destroy it or transfer it to a Queue. + * Exact durable checkpoint retry is idempotent; changed request bytes conflict. */ Lardon3DTask *lardon3d_project_create_acquisition_campaign_task( Lardon3DAppState *state, uint64_t scanset_id, const Lardon3DAcquisitionCampaignTaskRequest *request, uint64_t *task_id); +/* Create and transfer a durable campaign Task to state's Queue. Inputs follow + * the create contract; success transfers Task ownership to the Queue. task_id + * is required and is initialized to zero before validation. If transfer + * fails after durable creation, false is returned while task_id keeps only that + * recoverable durable identity; the transient Task object is destroyed. */ bool lardon3d_project_enqueue_acquisition_campaign( Lardon3DAppState *state, uint64_t scanset_id, const Lardon3DAcquisitionCampaignTaskRequest *request, uint64_t *task_id); -/* Request payload is treated as immutable durable input: task execution and - * checkpoint/recovery flow from the encoded blob, not from caller memory after - * enqueue. */ +/* Rebuild bounded callback context from the immutable durable request. + * snapshot, the Lardon3DTaskReconstructionContext passed through context, and + * binding are required and caller-owned. On success binding owns newly + * allocated userdata through userdata_destroy. Group count/cursor and the + * exact generic kind/version are validated; no Capture identity is guessed. */ bool lardon3d_acquisition_campaign_task_reconstruct( const Lardon3DTaskDurableSnapshot *snapshot, void *context, Lardon3DTaskKindBinding *binding); -/* Exposed for deterministic codec validation without executing ingestion. */ +/* Encode a deterministic v1 payload without retaining input storage. output + * may be NULL only with capacity==0 for a size probe. size is required: after a + * valid request it receives the exact required byte count even when capacity is + * insufficient and false is returned; invalid input or an encoding exception + * sets it to zero. On success that same value is the written byte count. */ bool lardon3d_acquisition_campaign_request_encode( const Lardon3DAcquisitionCampaignTaskRequest *request, unsigned char *output, size_t capacity, size_t *size); +/* Strictly decode one bounded immutable payload into caller-owned arrays. + * Their capacities must cover the encoded counts. On success request borrows + * those arrays for the caller-controlled lifetime; malformed/truncated input, + * trailing bytes, invalid enums/counts, or insufficient capacity fail. */ bool lardon3d_acquisition_campaign_request_decode( const unsigned char *input, size_t size, Lardon3DAcquisitionCampaignSource *sources, size_t source_capacity, diff --git a/include/lardon3d/app_state.h b/include/lardon3d/app_state.h index 1ecc6b1..068ee5d 100644 --- a/include/lardon3d/app_state.h +++ b/include/lardon3d/app_state.h @@ -21,7 +21,9 @@ typedef enum { LARDON3D_SCREEN_VIEWER, LARDON3D_SCREEN_HELP, LARDON3D_SCREEN_TASKS, - LARDON3D_SCREEN_RESOURCES + LARDON3D_SCREEN_RESOURCES, + LARDON3D_SCREEN_OPTICS, + LARDON3D_SCREEN_SSD } Lardon3DScreen; typedef struct Lardon3DAppState { diff --git a/include/lardon3d/candidate_pair_gen.h b/include/lardon3d/candidate_pair_gen.h index 6b3a862..6c00c34 100644 --- a/include/lardon3d/candidate_pair_gen.h +++ b/include/lardon3d/candidate_pair_gen.h @@ -27,9 +27,15 @@ Lardon3DVisualIndexResult lardon3d_candidate_pair_generate_batch( Lardon3DCandidatePairGenStats *total_stats, uint64_t *last_feature_set_id); +/* Produce the frozen Candidate-generation v1 SHA-256 fingerprint in caller-owned + * 32-byte storage. Integer inputs are encoded at fixed width, little-endian; + * the filter is one u32 and exclude_same_asset is exactly one 0/1 byte. This + * preserves acquired little-endian fingerprints while making identity portable. + * query_options may be NULL for the historical ID-only form; fingerprint must + * be non-NULL. */ void lardon3d_candidate_pair_generation_fingerprint( uint64_t visual_index_id, uint64_t source_feature_set_id, const Lardon3DVisualIndexQueryOptions *query_options, unsigned char fingerprint[32]); -#endif \ No newline at end of file +#endif diff --git a/include/lardon3d/geometric_verifier_task.h b/include/lardon3d/geometric_verifier_task.h index da7c0ac..529e8ba 100644 --- a/include/lardon3d/geometric_verifier_task.h +++ b/include/lardon3d/geometric_verifier_task.h @@ -13,23 +13,64 @@ enum { LARDON3D_GEOMETRIC_VERIFIER_TASK_KIND_VERSION = 1, LARDON3D_GEOMETRIC_VERIFIER_TASK_MINIMUM_BATCH = 1, - LARDON3D_GEOMETRIC_VERIFIER_TASK_MAXIMUM_BATCH = 8, + /* Sixteen is the independently safe preparation/participant window, not a + * scientific dataset limit. Eight is the largest width with a material gain + * at the durable Task boundary; Governor admits only within that lower + * operational range. */ + LARDON3D_GEOMETRIC_VERIFIER_TASK_MAXIMUM_BATCH = 16, + LARDON3D_GEOMETRIC_VERIFIER_TASK_MAXIMUM_SAFE_CPU_THREADS = 16, + LARDON3D_GEOMETRIC_VERIFIER_TASK_VALIDATED_USEFUL_CPU_THREADS = 8, }; typedef struct { Lardon3DGeometricVerifierParameters verifier; } Lardon3DGeometricVerifierTaskConfiguration; +/* Creates and durably pre-creates one recoverable GV Task. `state` retains + * ownership of Project DB/Governor; the returned Task owns its private + * execution context and must be destroyed or transferred to Queue. A non-NULL + * `task_id` is initialized to zero and retains the durable ID only after Task + * creation succeeds. Each GVR identity remains the exact parent, verifier kind + * and version, and parameter fingerprint; operational CPU/batch choices do not + * alter its scientific payload. */ Lardon3DTask *lardon3d_project_create_geometric_verifier_task( Lardon3DAppState *state, const Lardon3DGeometricVerifierTaskConfiguration *configuration, uint64_t *task_id); +/* Transfers the created Task to Queue on success. If durable creation succeeds + * but transfer fails, the returned false result retains its nonzero `task_id` + * so recovery can find the pending durable work; no GVR identity is guessed. */ bool lardon3d_project_enqueue_geometric_verifier_task( Lardon3DAppState *state, const Lardon3DGeometricVerifierTaskConfiguration *configuration, uint64_t *task_id); +/* Registry reconstruction borrows snapshot/runtime inputs and returns one + * binding whose userdata is owned by the restored Task on success. Exact + * historical resource envelopes may be normalized in memory by Registry; + * durable scientific fingerprints and cursors remain unchanged. */ bool lardon3d_geometric_verifier_task_reconstruct( const Lardon3DTaskDurableSnapshot *snapshot, void *context, Lardon3DTaskKindBinding *binding); +#ifdef LARDON3D_GEOMETRIC_VERIFIER_TASK_TESTING +/* Test-only observation of real callback contracts; one bit per admitted CPU + * width. It changes neither Governor selection nor production execution. */ +void lardon3d_geometric_verifier_task_test_reset_cpu_contracts(void); +unsigned int lardon3d_geometric_verifier_task_test_cpu_contracts(void); +/* Deterministic test acknowledgement immediately before sequence_break. Arm + * before enqueue, wait for the callback, change Governor policy, then release; + * no production reservation or scheduling rule is bypassed. */ +void lardon3d_geometric_verifier_task_test_arm_sequence_barrier(void); +bool lardon3d_geometric_verifier_task_test_wait_sequence_barrier(void); +void lardon3d_geometric_verifier_task_test_release_sequence_barrier(void); +/* Test-only acknowledgement after every preparation participant is joined and + * the complete batch has passed status/ownership preflight, but before the + * callback owner publishes its first row. An external control request made + * while this barrier is held must be observed only after this already-engaged + * batch is published and checkpointed. */ +void lardon3d_geometric_verifier_task_test_arm_prepublication_barrier(void); +bool lardon3d_geometric_verifier_task_test_wait_prepublication_barrier(void); +void lardon3d_geometric_verifier_task_test_release_prepublication_barrier(void); +#endif + #endif diff --git a/include/lardon3d/layout.h b/include/lardon3d/layout.h index ff5f241..a17953e 100644 --- a/include/lardon3d/layout.h +++ b/include/lardon3d/layout.h @@ -3,9 +3,19 @@ #include #include -#include -#include +#include +#include +#include +#include +#ifdef __cplusplus +extern "C" { +#endif + +/* Frozen legacy renderer ABI. It accepts only the historical bounded Task and + * resource views; the implementation adapts them without reading beyond any + * caller object compiled against the original declarations. Main thread only. + */ void lardon3d_layout_draw( const Lardon3DAppState *state, const char *input_text, @@ -19,4 +29,29 @@ void lardon3d_layout_draw( int cols ); +/* Main-thread-only extended renderer. `state` and `runtime` are required; + * import/SSD/optics/palette views are nullable. Every supplied view is a + * borrowed, bounded caller-owned copy for this call; rendering performs no + * Queue, Governor, Project DB, controller, or scientific mutation. + * interaction_mode is the actual input owner and is the sole source of footer + * capabilities. */ +void lardon3d_layout_draw_runtime( + const Lardon3DAppState *state, + const char *input_text, + const char *input_label, + const Lardon3DImportTaskSnapshot *import_snapshot, + const Lardon3DRuntimeSnapshot *runtime, + const Lardon3DTuiSsdAsyncSnapshot *ssd_operation, + const Lardon3DTuiOpticsSnapshot *optics, + size_t selected_task, + const Lardon3DTuiPalette *palette, + Lardon3DTuiInteractionMode interaction_mode, + int rows, + int cols +); + +#ifdef __cplusplus +} +#endif + #endif diff --git a/include/lardon3d/optical_profiles.h b/include/lardon3d/optical_profiles.h new file mode 100644 index 0000000..c7b23f1 --- /dev/null +++ b/include/lardon3d/optical_profiles.h @@ -0,0 +1,254 @@ +#ifndef LARDON3D_OPTICAL_PROFILES_H +#define LARDON3D_OPTICAL_PROFILES_H + +#include +#include +#include + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +enum { + LARDON3D_OPTICAL_TEXT_CAPACITY = 128, + LARDON3D_OPTICAL_PROVENANCE_CAPACITY = 256, + LARDON3D_OPTICAL_PAGE_MAX = 128, +}; + +typedef enum { + /* No electronic identity is required. A manual lens legitimately has no + * metadata alias and is selected only through an explicit profile/config. */ + LARDON3D_OPTICAL_LENS_MANUAL = 1, + LARDON3D_OPTICAL_LENS_ELECTRONIC = 2, + LARDON3D_OPTICAL_LENS_INTEGRATED = 3, +} Lardon3DOpticalLensInterface; + +typedef enum { + LARDON3D_OPTICAL_FOCAL_RANGE_UNKNOWN = 1, + LARDON3D_OPTICAL_FOCAL_RANGE_PRIME = 2, + LARDON3D_OPTICAL_FOCAL_RANGE_ZOOM = 3, +} Lardon3DOpticalFocalRangeKind; + +typedef enum { + LARDON3D_OPTICAL_ASSIGNMENT_CAMPAIGN = 1, + LARDON3D_OPTICAL_ASSIGNMENT_CALLER_EXPLICIT = 2, +} Lardon3DOpticalAssignmentProvenance; + +typedef enum { + /* The profile applies only to its exact optical_configuration_id. It never + * authorizes interpolation or borrowing across a body, lens, or focal setup. */ + LARDON3D_OPTICAL_CALIBRATION_EXACT_CONFIGURATION = 1, +} Lardon3DOpticalCalibrationApplicability; + +typedef struct { + uint64_t camera_body_profile_id; + char manufacturer[LARDON3D_OPTICAL_TEXT_CAPACITY]; + char model[LARDON3D_OPTICAL_TEXT_CAPACITY]; + char name[LARDON3D_OPTICAL_TEXT_CAPACITY]; +} Lardon3DOpticalCameraBodyProfile; + +typedef struct { + uint64_t alias_id; + uint64_t camera_body_profile_id; + char metadata_make[LARDON3D_OPTICAL_TEXT_CAPACITY]; + char metadata_model[LARDON3D_OPTICAL_TEXT_CAPACITY]; +} Lardon3DOpticalCameraBodyAlias; + +typedef struct { + uint64_t lens_profile_id; + char manufacturer[LARDON3D_OPTICAL_TEXT_CAPACITY]; + char model[LARDON3D_OPTICAL_TEXT_CAPACITY]; + char name[LARDON3D_OPTICAL_TEXT_CAPACITY]; + Lardon3DOpticalLensInterface interface_kind; + Lardon3DOpticalFocalRangeKind focal_range_kind; + /* Micrometres make profile identity locale-independent and exact. Unknown + * range uses 0/0; a prime uses equal positive values; a zoom uses min enum { - LARDON3D_PROJECT_DB_SCHEMA_VERSION = 22, + /* v23 is an additive optical-context layer. Historical v22 rows and sparse + * calibration identities remain untouched; migration never guesses profiles + * or assignments for existing Captures. */ + LARDON3D_PROJECT_DB_SCHEMA_VERSION = 23, LARDON3D_PROJECT_DB_ID_CAPACITY = 65, LARDON3D_PROJECT_DB_KIND_CAPACITY = 65, LARDON3D_PROJECT_DB_PATH_CAPACITY = 4096, @@ -104,6 +107,8 @@ typedef struct { typedef struct { uint64_t task_id; uint64_t scanset_id; + /* Zero-based next-work position in 0..group_count. Its numeric value is the + * number of one-based campaign groups whose Capture mapping is durable. */ uint32_t next_group_id; uint32_t group_count; const unsigned char *request; @@ -691,24 +696,57 @@ Lardon3DProjectDbResult lardon3d_project_db_list_candidate_pairs( Lardon3DProjectDbResult lardon3d_project_db_load_image_import(Lardon3DProjectDb *database, uint64_t task_id, Lardon3DProjectDbImageImport *parameters); +/* Atomically record generic Task/checkpoint state and one immutable typed + * campaign request for an existing ScanSet. All pointers are required except + * checkpoint; request bytes are borrowed only for the call. task_kind/version + * must exactly identify acquisition_campaign.run/v1. Exact replay is + * idempotent, while a changed request or generic kind conflicts. The cursor is + * the zero-based next-work position and never a Capture or scientific image + * identity. */ Lardon3DProjectDbResult lardon3d_project_db_record_acquisition_campaign_task( Lardon3DProjectDb *database, const Lardon3DTaskDurableSnapshot *snapshot, const char *task_kind, uint32_t task_kind_version, const Lardon3DProjectDbCheckpoint *checkpoint, const Lardon3DProjectDbAcquisitionCampaignTask *parameters, int64_t updated_at); +/* Load and validate the typed campaign row and its exact generic kind/version. + * With request==NULL and request_capacity==0, this is a size probe: metadata + * and request_size are returned while parameters->request remains NULL. + * Otherwise request is required caller-owned storage borrowed into parameters + * on success. Insufficient capacity returns CONSTRAINT without truncation; + * malformed SQLite types, signs, bounds, cursor relation, dispatch identity, or + * a retained mapping set other than the exact one-based prefix 1..next_group_id + * return CORRUPT. Every retained Capture must still exist in this campaign's + * ScanSet; validation is bounded by the frozen 4096-group maximum. */ Lardon3DProjectDbResult lardon3d_project_db_load_acquisition_campaign_task( Lardon3DProjectDb *database, uint64_t task_id, unsigned char *request, size_t request_capacity, Lardon3DProjectDbAcquisitionCampaignTask *parameters); +/* Retain the explicit one-based group_id -> capture_id mapping and advance the + * zero-based next-work position atomically. next_group_id must equal group_id; + * exact retry is idempotent, conflicting identity/order is rejected. Before + * mutation, the generic kind/version, SQLite storage classes and complete + * retained prefix are validated signed-wide; malformed durable state returns + * CORRUPT, while a valid caller conflict returns CONSTRAINT. The Capture must + * already exist in the campaign ScanSet. In v23, any explicit group optical + * assignment is copied to Capture provenance in this same transaction before + * cursor publication; an unassigned group remains honestly unresolved. Generic + * Task progress may advance only after this call succeeds. */ Lardon3DProjectDbResult lardon3d_project_db_retain_acquisition_campaign_capture( Lardon3DProjectDb *database, uint64_t task_id, uint32_t group_id, uint64_t capture_id, uint32_t next_group_id); +/* Load one explicit retained mapping into caller-owned output. task_id and + * group_id must be positive; persisted SQLite types, bounds, Capture identity, + * plan range, zero-based cursor, exact durable prefix, Capture ScanSet, and + * generic campaign kind/version are validated before narrowing. A mapping ahead + * of the cursor or across ScanSets is CORRUPT and never yields a resume ID. No + * identity is inferred from request bytes or metadata. */ Lardon3DProjectDbResult lardon3d_project_db_load_acquisition_campaign_capture( Lardon3DProjectDb *database, uint64_t task_id, uint32_t group_id, Lardon3DProjectDbAcquisitionCampaignCapture *capture); /* Atomically record generic Task state and the immutable typed request for an * existing ScanSet. All pointers are required except checkpoint; request bytes - * are borrowed only for the call. The request identifies campaign groups - * operationally and never creates or infers Capture/Asset/image identities. */ + * are borrowed only for the call. task_kind/version must exactly identify + * photo_quality.triage/v1. The request identifies campaign groups operationally + * and never creates or infers Capture/Asset/image identities. */ Lardon3DProjectDbResult lardon3d_project_db_record_photo_quality_task( Lardon3DProjectDb *database, const Lardon3DTaskDurableSnapshot *snapshot, const char *task_kind, uint32_t task_kind_version, @@ -716,7 +754,10 @@ Lardon3DProjectDbResult lardon3d_project_db_record_photo_quality_task( const Lardon3DProjectDbPhotoQualityTask *parameters, int64_t updated_at); /* On success, parameters receives validated one-based cursor/count fields and * borrows caller-owned request storage containing the immutable request bytes. - * Insufficient capacity is reported as corrupt durable state, never truncated. */ + * The generic kind/version and existing ScanSet identity must match exactly. + * For otherwise valid arguments, any non-OK result zeroes parameters and leaves + * request storage untouched. A present typed row with a missing parent is + * CORRUPT, not NOT_FOUND; insufficient capacity is also CORRUPT, never truncated. */ Lardon3DProjectDbResult lardon3d_project_db_load_photo_quality_task( Lardon3DProjectDb *database, uint64_t task_id, unsigned char *request, size_t request_capacity, Lardon3DProjectDbPhotoQualityTask *parameters); @@ -784,6 +825,9 @@ Lardon3DProjectDbResult lardon3d_project_db_create_selected_execution( Lardon3DProjectDbResult lardon3d_project_db_load_selected_execution( Lardon3DProjectDb *database, uint64_t execution_id, Lardon3DProjectDbSelectedExecution *execution); +/* Load one immutable bridge item into caller-owned output. Required and + * nullable identifiers must use SQLite INTEGER/NULL storage exactly; numeric + * TEXT/REAL values are corruption even if SQLite could coerce them. */ Lardon3DProjectDbResult lardon3d_project_db_load_selected_execution_item( Lardon3DProjectDb *database, uint64_t execution_id, uint32_t item_index, Lardon3DProjectDbSelectedExecutionItem *item); diff --git a/include/lardon3d/resource_governor.h b/include/lardon3d/resource_governor.h index 9ee53f9..58e09b3 100644 --- a/include/lardon3d/resource_governor.h +++ b/include/lardon3d/resource_governor.h @@ -8,18 +8,29 @@ #include #include +#include enum { LARDON3D_RESOURCE_REASON_CAPACITY = 256, + LARDON3D_RESOURCE_EXTERNAL_IDENTITY_CAPACITY = 256, }; typedef struct Lardon3DResourceGovernor Lardon3DResourceGovernor; typedef struct Lardon3DResourceReservation Lardon3DResourceReservation; typedef struct { + /* Host RAM below this MemAvailable floor is never assigned to new work. + * The default keeps approximately 3 GiB on capable hosts; smaller hosts + * use a deterministic fractional reserve. This is an operational desktop + * safety budget, never a scientific dataset-size limit. */ uint64_t system_memory_reserve_bytes; + /* Custom policies may set a lower emergency threshold for immediate RED + * pressure. The default equals the normal reserve; its separate 4 GiB + * caution band is private Governor policy and does not reduce capacity. */ uint64_t emergency_memory_floor_bytes; uint64_t gpu_memory_reserve_bytes; + /* Requested host reserve in logical CPUs. Complete topology may reserve a + * minimally larger whole-core group, while at least one compute CPU remains. */ unsigned int system_cpu_reserve; double maximum_cpu_load_ratio; double maximum_cpu_pressure_avg10; @@ -120,6 +131,40 @@ typedef struct { size_t active_reservations; } Lardon3DResourceAvailability; +/* External storage is physical operational capacity, never Task/scientific + * identity and never an extension of host RAM. `generation` is the monotonic + * source/controller generation; the Governor has a separate aggregate change + * generation. Unknown byte metrics have their *_known flag clear and value + * zero. Strings are always NUL-terminated bounded copies; AVAILABLE, IN_USE, + * DRAINING and SAFE require a nonempty exact stable identity. Controller + * generation UINT64_MAX is legal saturation, not an invalid sentinel. */ +typedef enum { + LARDON3D_RESOURCE_EXTERNAL_STORAGE_ABSENT = 0, + LARDON3D_RESOURCE_EXTERNAL_STORAGE_DETECTED, + LARDON3D_RESOURCE_EXTERNAL_STORAGE_AVAILABLE, + LARDON3D_RESOURCE_EXTERNAL_STORAGE_IN_USE, + LARDON3D_RESOURCE_EXTERNAL_STORAGE_DRAINING, + LARDON3D_RESOURCE_EXTERNAL_STORAGE_SAFE, + LARDON3D_RESOURCE_EXTERNAL_STORAGE_ERROR, +} Lardon3DResourceExternalStorageStatus; + +typedef struct { + uint64_t generation; + Lardon3DResourceExternalStorageStatus status; + bool new_scratch_allocations_allowed; + bool scratch_total_known; + bool scratch_free_known; + uint64_t scratch_total_bytes; + uint64_t scratch_free_bytes; + bool swap_total_known; + bool swap_used_known; + uint64_t swap_total_bytes; + uint64_t swap_used_bytes; + size_t active_scratch_leases; + char stable_identity[LARDON3D_RESOURCE_EXTERNAL_IDENTITY_CAPACITY]; + char reason[LARDON3D_RESOURCE_REASON_CAPACITY]; +} Lardon3DResourceExternalStorage; + bool lardon3d_resource_policy_default( const Lardon3DHardwareProfile *profile, Lardon3DResourcePolicy *policy @@ -128,6 +173,9 @@ Lardon3DResourceGovernor *lardon3d_resource_governor_create( const Lardon3DHardwareProfile *profile, const Lardon3DResourcePolicy *policy ); +/* No call may race with destruction. A registered external controller must be + * unregistered and every Governor scratch lease released first; the bound TUI + * adapter performs this ordering during normal application shutdown. */ void lardon3d_resource_governor_destroy( Lardon3DResourceGovernor *governor ); @@ -135,6 +183,13 @@ bool lardon3d_resource_governor_set_policy( Lardon3DResourceGovernor *governor, const Lardon3DResourcePolicy *policy ); +/* Copies the currently active operational policy under the Governor mutex. + * The caller owns the output. Observation cannot mutate admission, reserve + * resources, or turn swap/scratch into RAM capacity. */ +bool lardon3d_resource_governor_get_policy( + Lardon3DResourceGovernor *governor, + Lardon3DResourcePolicy *policy +); bool lardon3d_resource_governor_decide( Lardon3DResourceGovernor *governor, const Lardon3DResourceSnapshot *snapshot, @@ -185,6 +240,77 @@ bool lardon3d_resource_governor_availability( const Lardon3DResourceSnapshot *snapshot, Lardon3DResourceAvailability *availability ); + +/* Validates and converts one controller-owned bounded snapshot into the + * Governor's physical-storage vocabulary. `storage` is initialized to zero on + * every failure. Pairing, allocation or control authority requires current + * detection of both partitions, exact Drive/UUID identity and positive known + * partition extents. Contradictory ABSENT facts are rejected; a disconnected + * sticky hazard remains representable only as non-authoritative ERROR. Missing + * optional telemetry remains unknown. This pure conversion performs no + * controller call and acquires no lock. */ +bool lardon3d_resource_external_storage_from_ssd_snapshot( + const Lardon3DSsdSnapshot *snapshot, + Lardon3DResourceExternalStorage *storage +); + +/* Registers one exact borrowed controller object and copies its initial state + * under the Governor mutex. The controller must outlive the registration. + * Registration is exclusive and fails if a controller is already registered; + * update exact-retries are idempotent. A valid newer source generation may + * change state, while stale or materially different equal-generation public + * data cannot restore availability. A conservative ERROR may replace + * same/older evidence to fail closed. The exact serialized scratch wrapper may + * reconcile its own completion at a saturated UINT64_MAX watermark; this + * exception is private provenance and is unavailable to update(). Material + * changes wake Governor generation waiters; source-generation-only refreshes + * do not. + * Unregister is rejected during a wrapper operation or while the last exact + * snapshot reports any active lease. Outputs from get are caller-owned and + * zeroed on failure/unregistered state. */ +bool lardon3d_resource_governor_register_external_storage( + Lardon3DResourceGovernor *governor, + Lardon3DSsdController *controller, + const Lardon3DResourceExternalStorage *storage +); +bool lardon3d_resource_governor_update_external_storage( + Lardon3DResourceGovernor *governor, + Lardon3DSsdController *controller, + const Lardon3DResourceExternalStorage *storage +); +bool lardon3d_resource_governor_unregister_external_storage( + Lardon3DResourceGovernor *governor, + Lardon3DSsdController *controller +); +bool lardon3d_resource_governor_get_external_storage( + Lardon3DResourceGovernor *governor, + Lardon3DResourceExternalStorage *storage +); + +/* Production scratch ownership crosses the physical-controller boundary only + * through these wrappers. A lease object remains caller-owned, exclusive, and + * unmoved exactly as required by the low-level controller. Acquire requires + * the exact registered controller and current allocation authority; draining, + * ERROR, absent, stale, or unregistered state fails closed. Release remains + * available for an exact wrapper-acquired lease during drain/ERROR. The + * Governor never holds its mutex while entering the controller, and the + * controller never calls back into the Governor. Registration/controller/ + * Governor must outlive every successful lease; unregister is rejected while + * one remains. */ +bool lardon3d_resource_governor_acquire_scratch( + Lardon3DResourceGovernor *governor, + Lardon3DSsdController *controller, + Lardon3DSsdScratchLease *lease +); +bool lardon3d_resource_governor_release_scratch( + Lardon3DResourceGovernor *governor, + Lardon3DSsdController *controller, + Lardon3DSsdScratchLease *lease +); + +const char *lardon3d_resource_external_storage_status_name( + Lardon3DResourceExternalStorageStatus status +); uint64_t lardon3d_resource_governor_generation( Lardon3DResourceGovernor *governor ); diff --git a/include/lardon3d/resource_snapshot.h b/include/lardon3d/resource_snapshot.h index 5c4fed2..c3808ec 100644 --- a/include/lardon3d/resource_snapshot.h +++ b/include/lardon3d/resource_snapshot.h @@ -33,6 +33,16 @@ typedef struct { uint64_t swap_pages_out; } Lardon3DResourceSnapshot; +/* Additive host observation. The legacy snapshot remains ABI-exact and is + * embedded by value; SwapTotal is telemetry only and never enlarges the + * Governor RAM budget. A successful capture makes swap_total_known true, + * including on a host whose exact total is zero. */ +typedef struct { + Lardon3DResourceSnapshot snapshot; + bool swap_total_known; + uint64_t swap_total_bytes; +} Lardon3DResourceObservation; + bool lardon3d_resource_snapshot_capture( const Lardon3DHardwareProfile *profile, Lardon3DResourceSnapshot *snapshot, @@ -40,4 +50,18 @@ bool lardon3d_resource_snapshot_capture( size_t error_message_size ); +/* Captures the same bounded /proc and GPU observation as the legacy entry + * point plus exact SwapTotal telemetry. `profile` and `observation` are + * required; a non-NULL observation is zeroed before validation and remains + * caller-owned. `error_message` may be NULL; when supplied with positive size + * it is always NUL-terminated. No retained pointer or reservation is + * created, and UMA availability remains the legacy snapshot's one host-RAM + * quantity rather than an additional budget. */ +bool lardon3d_resource_observation_capture( + const Lardon3DHardwareProfile *profile, + Lardon3DResourceObservation *observation, + char *error_message, + size_t error_message_size +); + #endif diff --git a/include/lardon3d/runtime_observer.h b/include/lardon3d/runtime_observer.h new file mode 100644 index 0000000..be8e118 --- /dev/null +++ b/include/lardon3d/runtime_observer.h @@ -0,0 +1,72 @@ +#ifndef LARDON3D_RUNTIME_OBSERVER_H +#define LARDON3D_RUNTIME_OBSERVER_H + +#include +#include +#include + +#include +#include +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +typedef struct Lardon3DRuntimeObserver Lardon3DRuntimeObserver; + +typedef struct { + uint64_t generation; + uint64_t captured_monotonic_ns; + bool stale; + char status[LARDON3D_TUI_TEXT_CAPACITY]; + + Lardon3DTaskObservation tasks[LARDON3D_TUI_TASK_CAPACITY]; + size_t task_count; + Lardon3DTaskQueueSummary task_summary; + bool active_task_known; + size_t active_task_index; + Lardon3DTuiProgressView active_progress; + Lardon3DTuiStageView stages[LARDON3D_TUI_STAGE_COUNT]; + + Lardon3DTuiResourceView resources; + bool ssd_controller_available; + Lardon3DSsdSnapshot ssd; +} Lardon3DRuntimeSnapshot; + +/* Creates a passive observer over borrowed runtime owners. Queue and Governor + * must outlive it; Hardware Profile is required only for this call and is + * copied by value. Observation owns no Task/reservation and never changes + * admission. Governor-registered SSD usage is copied here under the Governor + * mutex; physical D-Bus identity/action observation still belongs to the + * TUI's sole bounded SSD worker and is merged only into the separate physical + * view. */ +Lardon3DRuntimeObserver *lardon3d_runtime_observer_create( + const Lardon3DHardwareProfile *profile, + Lardon3DTaskQueue *queue, + Lardon3DResourceGovernor *governor +); + +void lardon3d_runtime_observer_destroy(Lardon3DRuntimeObserver *observer); + +/* Copies a caller-owned coherent view. Ordinary calls are coalesced for at + * least one monotonic second, bounding Queue/Governor and /proc work. The SSD + * worker independently applies the same minimum telemetry cadence. `force` is + * reserved for explicit user refresh/testing, never per-frame use. + * A failed refresh preserves the last bounded view, marks it stale, and + * returns false; no internal pointer escapes. Thread-safe only through the + * borrowed owners—call this object from its single TUI/main owner. */ +bool lardon3d_runtime_observer_refresh( + Lardon3DRuntimeObserver *observer, + bool project_loaded, + bool force, + Lardon3DRuntimeSnapshot *snapshot +); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/include/lardon3d/runtime_session.h b/include/lardon3d/runtime_session.h new file mode 100644 index 0000000..6b95bf7 --- /dev/null +++ b/include/lardon3d/runtime_session.h @@ -0,0 +1,30 @@ +#ifndef LARDON3D_RUNTIME_SESSION_H +#define LARDON3D_RUNTIME_SESSION_H + +#include +#include + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* Establishes the interactive Project/Queue lifetime boundary. The existing + * sole Queue is cancelled, joined, and destroyed (including finished + * callbacks and retained history) before the Project DB is closed; only then + * is one empty Queue created with a fresh ID namespace. `state`, its Governor, + * and a positive capacity are required. On allocation failure the Project is + * still safely closed and state->task_queue is NULL. Main-thread owner only; + * callers must release Queue/DB observers before entry and rebind them after. + */ +bool lardon3d_runtime_project_boundary( + Lardon3DAppState *state, + size_t queue_capacity +); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/include/lardon3d/sparse_sfm_geometry.h b/include/lardon3d/sparse_sfm_geometry.h index 842d60e..7d0ddc7 100644 --- a/include/lardon3d/sparse_sfm_geometry.h +++ b/include/lardon3d/sparse_sfm_geometry.h @@ -53,6 +53,14 @@ typedef struct { typedef struct { double robust_threshold_px; double confidence; + /* + * OpenCV's robust-estimator ABI accepts signed iteration/inlier counts. + * max_iterations must be in [1, INT_MAX] and minimum_inliers in + * [0, INT_MAX]. A value outside those bounds returns INVALID_ARGUMENT + * before allocation, OpenCV execution, or mutation of the caller-owned + * result and inlier mask. The signed bound is operational only; it does not + * change the FROZEN defaults or scientific identity of representable runs. + */ uint32_t max_iterations; uint32_t minimum_inliers; double minimum_inlier_ratio; @@ -73,6 +81,14 @@ typedef struct { typedef struct { double reprojection_threshold_px; double confidence; + /* + * OpenCV's robust-estimator ABI accepts signed iteration/inlier counts. + * max_iterations must be in [1, INT_MAX] and minimum_inliers in + * [0, INT_MAX]. A value outside those bounds returns INVALID_ARGUMENT + * before allocation, OpenCV execution, or mutation of the caller-owned + * result and inlier mask. minimum_inliers == 0 retains the established PnP + * effective minimum of four correspondences. + */ uint32_t max_iterations; uint32_t minimum_inliers; double minimum_inlier_ratio; diff --git a/include/lardon3d/ssd_controller.h b/include/lardon3d/ssd_controller.h new file mode 100644 index 0000000..52c1f3a --- /dev/null +++ b/include/lardon3d/ssd_controller.h @@ -0,0 +1,245 @@ +#ifndef LARDON3D_SSD_CONTROLLER_H +#define LARDON3D_SSD_CONTROLLER_H + +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +enum { + LARDON3D_SSD_TEXT_CAPACITY = 128, + LARDON3D_SSD_IDENTITY_CAPACITY = 256, + LARDON3D_SSD_PATH_CAPACITY = 256, + LARDON3D_SSD_REASON_CAPACITY = 256, + LARDON3D_SSD_MAX_SCRATCH_LEASES = 64, +}; + +#define LARDON3D_SSD_SWAP_LABEL "LARDON_SWAP" +#define LARDON3D_SSD_SCRATCH_LABEL "LARDON_SCRATCH" +#define LARDON3D_SSD_SCRATCH_MOUNT_PATH "/mnt/lardon-scratch" + +typedef struct Lardon3DSsdController Lardon3DSsdController; + +typedef enum { + LARDON3D_SSD_ABSENT = 0, + LARDON3D_SSD_DETECTED, + LARDON3D_SSD_ENABLING, + LARDON3D_SSD_ENABLED, + LARDON3D_SSD_IN_USE, + LARDON3D_SSD_DRAINING, + LARDON3D_SSD_SAFE_TO_UNPLUG, + LARDON3D_SSD_ERROR, +} Lardon3DSsdState; + +typedef enum { + /* The requested transition was observed and verified. */ + LARDON3D_SSD_CONTROL_OK = 0, + /* No unsafe action was attempted; retry after the snapshot blocker clears. */ + LARDON3D_SSD_CONTROL_PENDING, + /* Provider/identity/operation failure; the snapshot retains truthful state. */ + LARDON3D_SSD_CONTROL_ERROR, +} Lardon3DSsdControlResult; + +/* A scratch lease is a caller-owned, process-local capability. Its opaque + * words are never persistent identity and must not be inspected or copied to + * represent additional use. The exact object address is part of ownership: + * after acquire, that object must remain alive and unmoved until its successful + * release. The caller must provide exclusive access to that object for this + * whole lifetime and must never present the same storage concurrently to + * different controllers, whose distinct mutexes cannot serialize caller + * memory. Calls using separate lease objects are thread-safe. Acquire writes a + * fresh token; release zeros the same object. A copied, constructed, stale, + * foreign, or already released object is rejected without changing the + * controller's exact bounded lease count. */ +typedef struct { + uintptr_t opaque_controller; + uint64_t opaque_lease_id; +} Lardon3DSsdScratchLease; + +/* The snapshot is a bounded caller-owned copy. Every string is NUL-terminated. + * `generation` is monotonic and legally saturates at UINT64_MAX; serialized + * controller operations may therefore change material state at that terminal + * watermark. Arbitrary equal-generation copies are not control authority. + * Unknown optional telemetry has its *_known flag clear and its text, when + * present, set to "UNKNOWN"; zero is therefore never guessed as knowledge. + * Device nodes are current observations only. Stable product identity is the + * paired UDisks Drive identity plus the two filesystem UUIDs. If an owned + * device disappears or is replaced, that tuple remains reported while paths + * become UNKNOWN; polling alone cannot clear the physical hazard. */ +typedef struct { + Lardon3DSsdState state; + uint64_t generation; + + bool device_detected; + bool pairing_valid; + bool model_known; + bool serial_known; + bool connection_speed_known; + char model[LARDON3D_SSD_TEXT_CAPACITY]; + char serial[LARDON3D_SSD_TEXT_CAPACITY]; + char drive_identity[LARDON3D_SSD_IDENTITY_CAPACITY]; + uint64_t connection_speed_mbps; + + bool swap_detected; + bool scratch_detected; + char swap_uuid[LARDON3D_SSD_TEXT_CAPACITY]; + char scratch_uuid[LARDON3D_SSD_TEXT_CAPACITY]; + char swap_device[LARDON3D_SSD_PATH_CAPACITY]; + char scratch_device[LARDON3D_SSD_PATH_CAPACITY]; + + /* UDisks Block.Size is exact partition extent. The usable swap/filesystem + * totals below are separate and remain unknown until a bounded telemetry + * source can measure them; a mounted scratch filesystem may legitimately + * report total/free UNKNOWN. The controller never substitutes one meaning + * for another or performs an unbounded path lookup during a UI poll. */ + bool swap_partition_size_known; + bool scratch_partition_size_known; + uint64_t swap_partition_size_bytes; + uint64_t scratch_partition_size_bytes; + + bool swap_active; + bool swap_total_known; + bool swap_used_known; + uint64_t swap_total_bytes; + uint64_t swap_used_bytes; + + bool scratch_mounted; + bool scratch_total_known; + bool scratch_free_known; + uint64_t scratch_total_bytes; + uint64_t scratch_free_bytes; + char scratch_mount_path[LARDON3D_SSD_PATH_CAPACITY]; + + size_t scratch_lease_count; + size_t scratch_lease_capacity; + bool drain_requested; + /* Exact physical lease authority. This is true only for the validated + * current Drive/UUID pair at the exact mount path, outside drain/error and + * below the fixed lease/token capacity. Presentation and the Governor must + * consume this flag rather than reconstructing authority from fields. */ + bool scratch_allocations_allowed; + /* Sole control authority for UI/automation. These flags are derived under + * the controller mutex from the validated current pair plus sticky + * ownership state. Callers must not reconstruct authority from `state`, + * paths, labels, activity bits, or an apparently clean ERROR. At most one + * flag is true in a valid snapshot. */ + bool can_enable; + bool can_disable; + bool can_cancel_drain; + char reason[LARDON3D_SSD_REASON_CAPACITY]; +} Lardon3DSsdSnapshot; + +/* Creates one synchronous physical-lifecycle controller. The controller owns + * its GDBus connection/provider and serializes calls internally; it creates no + * scheduler or background polling thread. Its bounded system-bus connection + * helper is joined before this call returns. Discovery is cached for at least + * one second between ordinary observations. NULL reports allocation or + * provider-initialization failure. */ +Lardon3DSsdController *lardon3d_ssd_controller_create(void); + +/* Releases all controller/provider resources without mounting, unmounting, or + * changing swap state. NULL is an idempotent success. + * Destruction returns false and leaves the controller valid while any scratch + * lease is outstanding; callers must release those capabilities and retry. + * No other call may race with a successful destroy. */ +bool lardon3d_ssd_controller_destroy(Lardon3DSsdController *controller); + +/* Refreshes discovery and telemetry. force=false honors the monotonic + * one-second cache interval; force=true performs one bounded provider poll and + * is intended for explicit user actions and tests. A failed poll preserves + * truthful known active state where possible and publishes ERROR with a + * bounded reason. Thread-safe. */ +bool lardon3d_ssd_controller_refresh( + Lardon3DSsdController *controller, + bool force +); + +/* Copies the latest state after an ordinary cached refresh. The caller owns + * `snapshot`; no internal pointer escapes. A non-NULL output is initialized to + * a bounded all-UNKNOWN/ABSENT snapshot even when controller is NULL. Returns + * false for a NULL output, invalid controller, or refresh failure (the copied + * ERROR snapshot is still provided for a valid controller). Thread-safe. */ +bool lardon3d_ssd_controller_get_snapshot( + Lardon3DSsdController *controller, + Lardon3DSsdSnapshot *snapshot +); + +/* Copies the already-cached bounded snapshot without polling UDisks or other + * provider telemetry. This is the post-operation reconciliation seam used + * after a serialized physical lease call. Output is initialized on every + * failure and owned by the caller. Thread-safe. */ +bool lardon3d_ssd_controller_copy_snapshot( + Lardon3DSsdController *controller, + Lardon3DSsdSnapshot *snapshot +); + +/* Enables only the exact healthy paired LARDON_SWAP/LARDON_SCRATCH device. + * UDisks Swapspace.Start precedes Filesystem.Mount, whose observed path must be + * exactly LARDON3D_SSD_SCRATCH_MOUNT_PATH. Calls are synchronous but have a + * bounded D-Bus timeout. Partial success remains active and visible in ERROR; + * immediately before each potentially side-effecting call, control ownership + * is conservatively bound to the exact Drive/UUID tuple. A timeout or failed + * verification can then be recovered only by observing and draining that same + * pair; a replacement receives no control authority. The controller never + * formats, repairs, powers off, or deletes data. A verified already-enabled + * pair returns OK without repeating either action. */ +Lardon3DSsdControlResult lardon3d_ssd_controller_enable( + Lardon3DSsdController *controller +); + +/* Requests an idempotent drain. New leases are rejected immediately. Active + * leases produce PENDING and must be released before stop/unmount can proceed. + * Swap is stopped only when current PSI/swap-delta evidence is quiet and its + * used bytes can be absorbed while retaining the 3 GiB MemAvailable reserve. + * A last lease release automatically retries the pending bounded drain; + * pressure/telemetry PENDING results are retryable by calling disable again. + * After an unsafe disappearance, only the reconnected original Drive/UUID + * tuple is authorized for this drain; its verified inactive endpoint clears + * the sticky hazard and reaches SAFE_TO_UNPLUG. */ +Lardon3DSsdControlResult lardon3d_ssd_controller_disable( + Lardon3DSsdController *controller +); + +/* Cancels a pending drain without changing mount or swap state. A fully active + * pair resumes ENABLED/IN_USE according to the exact current lease count; + * partial state remains truthfully DETECTED/ERROR. Returns false for invalid + * arguments or when no drain is pending. */ +bool lardon3d_ssd_controller_cancel_drain( + Lardon3DSsdController *controller +); + +/* Low-level physical seam used by the Governor wrapper and controller tests. + * Production Task ownership must call + * lardon3d_resource_governor_acquire_scratch(), not this function directly. + * Acquires one of the fixed-capacity scratch-use capabilities. `lease` must be + * non-NULL and zero-initialized (or previously released), and its exclusively + * owned storage must not move or be copied until release succeeds. Acquisition + * requires a valid mounted pair in ENABLED/IN_USE and is rejected during + * DRAINING or ERROR. The lease is operational resource ownership, never + * Task/scientific identity. */ +bool lardon3d_ssd_controller_acquire_scratch( + Lardon3DSsdController *controller, + Lardon3DSsdScratchLease *lease +); + +/* Low-level physical seam used by the Governor wrapper and controller tests; + * production Task ownership releases through the matching Governor wrapper. + * Releases exactly the originally acquired object and zeros it on success. + * Invalid, copied, constructed, stale, foreign, or double-release objects + * return false without decrementing use. When this is the final lease of a + * pending drain, the same call advances the bounded stop/unmount sequence. */ +bool lardon3d_ssd_controller_release_scratch( + Lardon3DSsdController *controller, + Lardon3DSsdScratchLease *lease +); + +const char *lardon3d_ssd_state_name(Lardon3DSsdState state); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/include/lardon3d/task.h b/include/lardon3d/task.h index b6cf0b4..e60f356 100644 --- a/include/lardon3d/task.h +++ b/include/lardon3d/task.h @@ -2,6 +2,7 @@ #define LARDON3D_TASK_H #include +#include #include #include @@ -30,6 +31,17 @@ typedef void (*Lardon3DTaskFinishedCallback)( void *userdata ); +typedef struct { + size_t batch_size; + uint64_t memory_bytes; + uint64_t gpu_memory_bytes; + unsigned int cpu_threads; + unsigned int gpu_slots; + unsigned int io_slots; +} Lardon3DTaskExecutionContract; + +/* Historical ABI snapshot. Its field order and size are frozen: additive + * runtime observability belongs to Lardon3DTaskObservation below. */ typedef struct { uint64_t id; char name[LARDON3D_TASK_NAME_CAPACITY]; @@ -40,14 +52,32 @@ typedef struct { struct timespec finished_at; } Lardon3DTaskSnapshot; +/* Additive, caller-owned, mutex-consistent runtime observation. The legacy + * prefix is deliberately repeated rather than extending TaskSnapshot: old + * binaries must never receive a write larger than their compiled object. + * Typed identity, durable progress, sequence count and the installed + * execution contract are operational observations only. Durable counts exist + * only after the typed owner publishes its committed prefix; they are never + * inferred from generic percentage, name, or message. When + * has_execution_contract is false every contract field is zero. */ typedef struct { - size_t batch_size; - uint64_t memory_bytes; - uint64_t gpu_memory_bytes; - unsigned int cpu_threads; - unsigned int gpu_slots; - unsigned int io_slots; -} Lardon3DTaskExecutionContract; + uint64_t id; + char name[LARDON3D_TASK_NAME_CAPACITY]; + unsigned int progress; + Lardon3DTaskState state; + char message[LARDON3D_TASK_MESSAGE_CAPACITY]; + struct timespec started_at; + struct timespec finished_at; + bool has_task_kind; + char task_kind[LARDON3D_TASK_KIND_CAPACITY]; + uint32_t task_kind_version; + bool durable_progress_known; + uint64_t durable_completed; + uint64_t durable_total; + unsigned int sequence_count; + bool has_execution_contract; + Lardon3DTaskExecutionContract execution_contract; +} Lardon3DTaskObservation; typedef struct { uint64_t id; @@ -96,11 +126,32 @@ bool lardon3d_task_set_progress( unsigned int progress, const char *message ); +/* Publishes an already-durable typed-business prefix as operational + * observation and derives the generic percentage without overflow. The + * caller must invoke this only after its own transaction/cursor commit; + * Task/Queue do not persist or reinterpret these counts. A later ordinary + * set_progress clears them rather than retaining a stale exact-looking value. + * Untyped Tasks are rejected. Generic Task completion sets its percentage to + * 100 but never fabricates a missing typed-business commit. total must be + * positive and completed <= total. */ +bool lardon3d_task_set_durable_progress( + Lardon3DTask *task, + uint64_t completed, + uint64_t total, + const char *message +); bool lardon3d_task_fail(Lardon3DTask *task, const char *message); bool lardon3d_task_snapshot( const Lardon3DTask *task, Lardon3DTaskSnapshot *snapshot ); +/* Copies one caller-owned coherent value under the Task mutex. A non-NULL + * output is zeroed before validation, so invalid Task arguments never leave a + * stale typed identity, durable count, or execution contract visible. */ +bool lardon3d_task_observation( + const Lardon3DTask *task, + Lardon3DTaskObservation *observation +); bool lardon3d_task_durable_snapshot( const Lardon3DTask *task, Lardon3DTaskDurableSnapshot *snapshot diff --git a/include/lardon3d/task_queue.h b/include/lardon3d/task_queue.h index 147c071..a1481ab 100644 --- a/include/lardon3d/task_queue.h +++ b/include/lardon3d/task_queue.h @@ -9,6 +9,17 @@ typedef struct Lardon3DTaskQueue Lardon3DTaskQueue; +enum { + /* Application Queue ingress is intentionally bounded independently from + * terminal history. The runtime observer can therefore size one finite + * copy for every possible pending/active/history record. */ + LARDON3D_TASK_QUEUE_PRODUCTION_CAPACITY = 64, + /* The TUI consumes at most 64 rows. Retaining the same bounded number of + * terminal snapshots keeps recent work observable without retaining Task + * userdata for the Queue lifetime. */ + LARDON3D_TASK_QUEUE_HISTORY_CAPACITY = 64, +}; + typedef enum { LARDON3D_TASK_QUEUE_ADD_OK = 0, LARDON3D_TASK_QUEUE_ADD_FULL, @@ -20,21 +31,55 @@ typedef enum { typedef struct { size_t running; size_t pending; + /* Saturating count of every Task that became terminal since Queue + * creation, including terminal records later removed or aged out. */ size_t completed; + /* Saturating completed + currently non-terminal Tasks. */ size_t total; } Lardon3DTaskQueueSummary; +/* Creates a bounded FIFO Queue with adaptive admissible-work selection and one + * serialized worker. capacity bounds pending Tasks, not terminal history; the + * Governor retains resource-admission ownership and must outlive the Queue. + * + * File d'exécution bornée, à ordre d'attente FIFO avec sélection adaptative du + * premier travail admissible, et un seul worker: ownership d'ordonnancement et + * de backpressure seulement. L'admission des demandes reste au Governneur. + * + * CONTRACT: after a successful add, the Queue owns Task and its userdata. Once + * the Task is terminal and its finished callback has returned, the Queue keeps + * only a snapshot and promptly destroys the real Task outside the Queue lock. + * The oldest terminal snapshot is evicted above + * LARDON3D_TASK_QUEUE_HISTORY_CAPACITY. + * + * Finished callbacks execute without the Queue mutex. While the Queue owner + * keeps it alive, callbacks may use read-only get/get_at/count/snapshot APIs. + * They must not synchronously remove their own still-active record, destroy the + * same Queue, or invoke another operation whose completion depends on that + * callback returning; defer such work until after the callback completes. + */ Lardon3DTaskQueue *lardon3d_task_queue_create( Lardon3DResourceGovernor *governor, size_t capacity ); -/* File d'exécution bornée, à ordre d'attente FIFO avec sélection adaptative du - * premier travail admissible, et un seul worker: ownership d'ordonnancement et - * de backpressure seulement. L'admission des demandes reste au Governneur. - */ +/* Atomically closes call ingress, cancels live work, waits for the worker and + * every call whose registration preceded closing (including blocked + * producers), and destroys all remaining Queue-owned objects exactly once. + * + * The owner must prevent new API invocations before calling destroy and must + * invoke destroy only once. This is the standard external lifetime rule for an + * object addressed through a raw C pointer: the internal gate resolves calls + * already registered at the close race, but cannot make an invocation that + * starts after destruction safe. NULL is accepted. Never call destroy + * synchronously from a Task finished callback running on this Queue. */ void lardon3d_task_queue_destroy(Lardon3DTaskQueue *queue); /* La file devient propriétaire de task uniquement en cas de succès. - Bloquante : attend une place libre si la file est pleine. */ + * Bloquante : attend une place libre si la file est pleine. A zero Task ID is + * assigned from a nonzero monotonic sequence and is never generated twice + * during this Queue lifetime, including after terminal-history eviction or + * removal. Once UINT64_MAX has been generated (or consumed by a restored + * Task), automatic generation remains exhausted for this Queue lifetime; + * adding a lower preassigned ID cannot re-arm it. */ bool lardon3d_task_queue_add( Lardon3DTaskQueue *queue, Lardon3DTask *task, @@ -52,22 +97,49 @@ Lardon3DTaskQueueAddResult lardon3d_task_queue_try_add_ex( Lardon3DTask *task, uint64_t *task_id ); +/* Removes a retained terminal snapshot. Active/pending IDs are not removable; + * an evicted/unknown ID returns false. A finished callback must not remove its + * own record synchronously because retirement awaits that callback. */ bool lardon3d_task_queue_remove(Lardon3DTaskQueue *queue, uint64_t task_id); +/* Requests cancellation of a live Task. A retained terminal ID returns true + * as the request is already satisfied; an evicted/unknown ID returns false. */ bool lardon3d_task_queue_cancel(Lardon3DTaskQueue *queue, uint64_t task_id); +/* Pause/resume act only on retained live, non-terminal Tasks. Terminal-history + * and evicted/unknown IDs return false. */ bool lardon3d_task_queue_pause(Lardon3DTaskQueue *queue, uint64_t task_id); bool lardon3d_task_queue_resume(Lardon3DTaskQueue *queue, uint64_t task_id); void lardon3d_task_queue_resources_changed(Lardon3DTaskQueue *queue); +/* Number of Tasks still awaiting dispatch; active and history are excluded. */ size_t lardon3d_task_queue_count(Lardon3DTaskQueue *queue); +/* Returns a copy for live or retained terminal work. IDs that aged out of the + * bounded terminal history return false. */ bool lardon3d_task_queue_get( Lardon3DTaskQueue *queue, uint64_t task_id, Lardon3DTaskSnapshot *snapshot ); +/* Extended counterpart of get(); retained terminal observations preserve + * exact typed/durable/admission state after the real Task is destroyed. A + * non-NULL output is zeroed before lookup, including for unknown/aged-out IDs + * and closing/error returns. */ +bool lardon3d_task_queue_get_observation( + Lardon3DTaskQueue *queue, + uint64_t task_id, + Lardon3DTaskObservation *observation +); +/* Uses the same ordering as lardon3d_task_queue_snapshot(). */ bool lardon3d_task_queue_get_at( Lardon3DTaskQueue *queue, size_t index, Lardon3DTaskSnapshot *snapshot ); +/* Copies the most operationally relevant records first: live Tasks in reverse + * submission order, followed by terminal snapshots in reverse completion + * order. Thus a bounded caller sees current and newest work instead of an old + * prefix. The returned count never exceeds capacity; summary, when non-NULL, + * describes all current live Tasks plus the saturating lifetime terminal + * count, independently of snapshot eviction or explicit removal. snapshots may + * be NULL only when capacity is zero. */ size_t lardon3d_task_queue_snapshot( Lardon3DTaskQueue *queue, Lardon3DTaskSnapshot *snapshots, @@ -75,4 +147,17 @@ size_t lardon3d_task_queue_snapshot( Lardon3DTaskQueueSummary *summary ); +/* Additive extended observation. Ordering and lifetime-summary semantics are + * identical to snapshot(), but each record carries exact typed/durable/ + * admitted Task data. With the production capacity of 64, a capacity of 129 + * covers the maximum 64 pending + one active + 64 recent terminal records, so + * live work cannot be hidden by history. observations may be NULL only when + * capacity is zero. */ +size_t lardon3d_task_queue_observe( + Lardon3DTaskQueue *queue, + Lardon3DTaskObservation *observations, + size_t capacity, + Lardon3DTaskQueueSummary *summary +); + #endif diff --git a/include/lardon3d/tui.h b/include/lardon3d/tui.h index 1324552..bb07cfd 100644 --- a/include/lardon3d/tui.h +++ b/include/lardon3d/tui.h @@ -5,8 +5,40 @@ #include +typedef struct Lardon3DSsdController Lardon3DSsdController; +typedef struct Lardon3DTuiSsdAsync Lardon3DTuiSsdAsync; + +#ifdef __cplusplus +extern "C" { +#endif + bool lardon3d_tui_init(void); bool lardon3d_tui_run(Lardon3DAppState *state); +/* Additive compatibility entry point. The controller is borrowed for the call + * and remains outside the frozen AppState ABI; NULL preserves the legacy run. + * It creates an unbound physical observer, so production code that needs + * Governor scratch orchestration uses run_with_ssd_operation() instead. The + * App owner must keep the controller alive until this function returns. */ +bool lardon3d_tui_run_with_ssd( + Lardon3DAppState *state, + Lardon3DSsdController *ssd_controller +); +/* Production borrowed-operation entry point. The application owns `operation` + * across this call and the TUI never destroys it. After the UI returns, the + * owner must first destroy/join the sole Task Queue (so every Task-owned + * scratch lease is released), then destroy the operation with + * lardon3d_tui_ssd_async_destroy_checked(), then its controller and Governor. + * `state` must be non-NULL; a bound operation must use that state's Governor. + * NULL preserves a TUI without external-storage observation/control. Returns + * false for invalid input or a runtime/observation/control-owner failure. */ +bool lardon3d_tui_run_with_ssd_operation( + Lardon3DAppState *state, + Lardon3DTuiSsdAsync *operation +); void lardon3d_tui_shutdown(void); +#ifdef __cplusplus +} +#endif + #endif diff --git a/include/lardon3d/tui_model.h b/include/lardon3d/tui_model.h new file mode 100644 index 0000000..cd9848d --- /dev/null +++ b/include/lardon3d/tui_model.h @@ -0,0 +1,326 @@ +#ifndef LARDON3D_TUI_MODEL_H +#define LARDON3D_TUI_MODEL_H + +#include +#include +#include + +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +enum { + LARDON3D_TUI_STAGE_COUNT = 11, + LARDON3D_TUI_TEXT_CAPACITY = 256, + /* Production Queue capacity is 64 pending. This bound observes every + * possible live Task plus the complete bounded recent history. */ + LARDON3D_TUI_TASK_CAPACITY = 129, +}; + +/* The viewport decision is pure and terminal-independent so resize behavior is + * testable without entering ncurses. Compact is a supported layout, not an + * error path; only dimensions below 60x15 display the bounded fallback text. */ +typedef enum { + LARDON3D_TUI_VIEWPORT_TOO_SMALL = 0, + LARDON3D_TUI_VIEWPORT_COMPACT, + LARDON3D_TUI_VIEWPORT_FULL, +} Lardon3DTuiViewport; + +typedef enum { + LARDON3D_TUI_SEMANTIC_NORMAL = 0, + LARDON3D_TUI_SEMANTIC_HEALTHY, + LARDON3D_TUI_SEMANTIC_WARNING, + LARDON3D_TUI_SEMANTIC_ERROR, + LARDON3D_TUI_SEMANTIC_GPU, + LARDON3D_TUI_SEMANTIC_CPU, + LARDON3D_TUI_SEMANTIC_SSD, + LARDON3D_TUI_SEMANTIC_DIM, + LARDON3D_TUI_SEMANTIC_COUNT, +} Lardon3DTuiSemantic; + +enum { + LARDON3D_TUI_STYLE_BOLD = 1U << 0, + LARDON3D_TUI_STYLE_DIM = 1U << 1, +}; + +typedef struct { + bool color_enabled; + short color_pair[LARDON3D_TUI_SEMANTIC_COUNT]; + unsigned int attributes[LARDON3D_TUI_SEMANTIC_COUNT]; +} Lardon3DTuiPalette; + +Lardon3DTuiViewport lardon3d_tui_viewport_classify(int rows, int columns); + +/* `color_pairs` is the terminal's total pair capacity, including pair zero. + * Missing/limited color never removes semantic text labels: styles fall back + * deterministically to bold/dim attributes and pair zero. */ +void lardon3d_tui_palette_plan( + bool colors_supported, + int color_pairs, + Lardon3DTuiPalette *palette +); + +typedef enum { + LARDON3D_TUI_STAGE_ACQUISITION = 0, + LARDON3D_TUI_STAGE_RAW, + LARDON3D_TUI_STAGE_QUALITY, + LARDON3D_TUI_STAGE_FEATURES, + LARDON3D_TUI_STAGE_VISUAL_INDEX, + LARDON3D_TUI_STAGE_CANDIDATE, + LARDON3D_TUI_STAGE_MATCHER, + LARDON3D_TUI_STAGE_GV, + LARDON3D_TUI_STAGE_TRACKS, + LARDON3D_TUI_STAGE_SPARSE_SFM, + LARDON3D_TUI_STAGE_DENSE, +} Lardon3DTuiStage; + +typedef enum { + LARDON3D_TUI_STAGE_NOT_READY = 0, + LARDON3D_TUI_STAGE_READY, + LARDON3D_TUI_STAGE_QUEUED, + LARDON3D_TUI_STAGE_RUNNING, + LARDON3D_TUI_STAGE_THROTTLED, + LARDON3D_TUI_STAGE_BLOCKED, + LARDON3D_TUI_STAGE_COMPLETE, + LARDON3D_TUI_STAGE_FAILED, + LARDON3D_TUI_STAGE_NOT_APPLICABLE, +} Lardon3DTuiStageState; + +typedef struct { + Lardon3DTuiStage stage; + Lardon3DTuiStageState state; + char reason[LARDON3D_TUI_TEXT_CAPACITY]; +} Lardon3DTuiStageView; + +const char *lardon3d_tui_stage_name(Lardon3DTuiStage stage); +const char *lardon3d_tui_stage_state_name(Lardon3DTuiStageState state); +Lardon3DTuiSemantic lardon3d_tui_stage_semantic( + Lardon3DTuiStageState state +); + +/* Builds a runtime observation only from typed Task snapshots. Absence of a + * Task is never interpreted as scientific completion. Dense is deliberately + * NOT_APPLICABLE until a later production Task kind exists, so future science + * can never be rendered as active by a name/message coincidence. */ +void lardon3d_tui_stage_views_build( + bool project_loaded, + const Lardon3DTaskObservation *tasks, + size_t task_count, + Lardon3DResourcePressure pressure, + Lardon3DTuiStageView stages[LARDON3D_TUI_STAGE_COUNT] +); + +typedef enum { + LARDON3D_TUI_ETA_INDETERMINATE = 0, + LARDON3D_TUI_ETA_CALCULATING, + LARDON3D_TUI_ETA_KNOWN, + LARDON3D_TUI_ETA_STALLED, + LARDON3D_TUI_ETA_THROTTLED, + LARDON3D_TUI_ETA_COMPLETE, +} Lardon3DTuiEtaState; + +typedef struct { + uint64_t task_id; + uint64_t monotonic_ns; + Lardon3DTaskState task_state; + bool typed_task; + unsigned int progress_percent; + bool durable_counts_known; + uint64_t durable_completed; + uint64_t durable_total; + bool pressure_limited; +} Lardon3DTuiProgressSample; + +typedef struct { + uint64_t task_id; + uint64_t prefix_completed; + uint64_t previous_completed; + uint64_t previous_sample_ns; + uint64_t last_positive_ns; + double ewma_units_per_second; + unsigned int positive_sample_count; + Lardon3DTaskState previous_state; + bool initialized; + bool using_durable_counts; +} Lardon3DTuiProgressTracker; + +typedef struct { + bool present; + uint64_t task_id; + bool durable_counts_known; + bool runtime_percentage; + bool integrity_error; + uint64_t completed; + uint64_t total; + bool percentage_known; + unsigned int percentage; + bool elapsed_known; + uint64_t elapsed_seconds; + bool throughput_known; + double units_per_second; + Lardon3DTuiEtaState eta_state; + uint64_t eta_seconds; + bool resumed_prefix_excluded; +} Lardon3DTuiProgressView; + +typedef enum { + LARDON3D_TUI_INTERACTION_IDLE = 0, + LARDON3D_TUI_INTERACTION_TEXT_INPUT, + LARDON3D_TUI_INTERACTION_IMPORT_RUNNING, +} Lardon3DTuiInteractionMode; + +typedef struct { + bool enter; + bool escape; + bool f10; + bool cancel_import; + bool quit; + bool navigate; +} Lardon3DTuiKeyContract; + +/* Pure key/footer contract shared by the actual handler mode and renderer. + * A displayed action is therefore never broader than the active input owner. */ +Lardon3DTuiKeyContract lardon3d_tui_key_contract( + Lardon3DTuiInteractionMode mode +); + +/* Updates one fixed-size tracker. The first observation of a Task is a resume + * baseline and contributes no throughput. A transition into RUNNING also + * resets rate timing, excluding pending/pause time. ETA becomes known only + * after two positive intervals, and rate remains UNKNOWN until that same + * evidence exists. Five seconds without progress is a stall, regressions reset + * the baseline, and pressure is explicit. Only coherent durable completion or + * an untyped runtime completion is shown as exactly 100% with ETA zero; a typed + * terminal lifecycle with incomplete/unknown durable science is never filled + * in. */ +bool lardon3d_tui_progress_update( + Lardon3DTuiProgressTracker *tracker, + const Lardon3DTuiProgressSample *sample, + Lardon3DTuiProgressView *view +); + +const char *lardon3d_tui_eta_state_name(Lardon3DTuiEtaState state); + +typedef enum { + LARDON3D_TUI_GPU_BACKEND_UNKNOWN = 0, + LARDON3D_TUI_GPU_BACKEND_UNINITIALIZED, + LARDON3D_TUI_GPU_BACKEND_AVAILABLE, + LARDON3D_TUI_GPU_BACKEND_UNAVAILABLE, + LARDON3D_TUI_GPU_BACKEND_CPU, + LARDON3D_TUI_GPU_BACKEND_ORB_VULKAN, + LARDON3D_TUI_GPU_BACKEND_MIXED, +} Lardon3DTuiGpuBackendStatus; + +typedef struct { + bool valid; + uint64_t captured_monotonic_ns; + Lardon3DResourcePressure governor_pressure; + char governor_reason[LARDON3D_TUI_TEXT_CAPACITY]; + + unsigned int cpu_logical_total; + bool cpu_admitted_known; + unsigned int cpu_admitted; + unsigned int cpu_available; + unsigned int cpu_active; + bool cpu_utilization_known; + uint32_t cpu_utilization_basis_points; + char cpu_reason[LARDON3D_TUI_TEXT_CAPACITY]; + + bool gpu_present; + bool gpu_uses_shared_memory; + bool gpu_memory_known; + uint64_t gpu_memory_reserved_bytes; + uint64_t gpu_memory_available_bytes; + bool gpu_busy_known; + uint32_t gpu_busy_basis_points; + unsigned int gpu_slots_active; + unsigned int gpu_slots_available; + Lardon3DTuiGpuBackendStatus gpu_backend; + char gpu_backend_reason[LARDON3D_TUI_TEXT_CAPACITY]; + + uint64_t ram_total_bytes; + uint64_t ram_available_bytes; + uint64_t ram_reserve_bytes; + uint64_t ram_reserved_bytes; + bool swap_total_known; + uint64_t swap_total_bytes; + bool swap_used_known; + uint64_t swap_used_bytes; + bool swap_delta_known; + uint64_t swap_pages_in_delta; + uint64_t swap_pages_out_delta; + + bool batch_known; + size_t batch_size; + bool inflight_known; + size_t inflight_limit; + bool helpers_known; + unsigned int helper_limit; + unsigned int io_active; + unsigned int io_available; + + bool scratch_known; + bool scratch_mounted; + bool scratch_total_known; + bool scratch_free_known; + uint64_t scratch_total_bytes; + uint64_t scratch_free_bytes; + size_t scratch_leases; + + /* Governor-owned external usage. Physical device paths and F10 authority + * remain in the separate controller snapshot; this view cannot grant a + * lease or infer availability from a mount bit. */ + bool external_storage_registered; + Lardon3DResourceExternalStorageStatus external_storage_status; + bool scratch_new_allocations_allowed; + bool external_swap_total_known; + bool external_swap_used_known; + uint64_t external_swap_total_bytes; + uint64_t external_swap_used_bytes; + char external_storage_identity[LARDON3D_TUI_TEXT_CAPACITY]; + char external_storage_reason[LARDON3D_TUI_TEXT_CAPACITY]; +} Lardon3DTuiResourceView; + +const char *lardon3d_tui_gpu_backend_name( + Lardon3DTuiGpuBackendStatus status +); +const char *lardon3d_tui_pressure_name(Lardon3DResourcePressure pressure); + +typedef enum { + LARDON3D_TUI_OPTICS_NO_PROJECT = 0, + LARDON3D_TUI_OPTICS_UNRESOLVED, + LARDON3D_TUI_OPTICS_CONFIGURATION_ONLY, + LARDON3D_TUI_OPTICS_SELECTION_REQUIRED, + LARDON3D_TUI_OPTICS_SELECTED, + LARDON3D_TUI_OPTICS_INCOMPATIBLE, + LARDON3D_TUI_OPTICS_CORRUPT, +} Lardon3DTuiOpticsStatus; + +/* This presentation classifier never creates an "unknown" profile or guesses + * identity. A manual/no-electronics lens is ordinary configuration data and + * does not change compatibility or selection rules. */ +Lardon3DTuiOpticsStatus lardon3d_tui_optics_classify( + bool project_loaded, + bool assignment_found, + bool assignment_valid, + bool manual_lens, + size_t compatible_calibration_count, + bool selection_found, + bool selection_compatible +); + +const char *lardon3d_tui_optics_status_name(Lardon3DTuiOpticsStatus status); +const char *lardon3d_tui_optics_status_explanation( + Lardon3DTuiOpticsStatus status, + bool manual_lens +); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/include/lardon3d/tui_optics.h b/include/lardon3d/tui_optics.h new file mode 100644 index 0000000..826b933 --- /dev/null +++ b/include/lardon3d/tui_optics.h @@ -0,0 +1,161 @@ +#ifndef LARDON3D_TUI_OPTICS_H +#define LARDON3D_TUI_OPTICS_H + +#include +#include +#include + +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +enum { + LARDON3D_TUI_OPTICS_PAGE_CAPACITY = 16, +}; + +typedef struct Lardon3DTuiOptics Lardon3DTuiOptics; + +typedef enum { + LARDON3D_TUI_OPTICS_PANE_BODY = 0, + LARDON3D_TUI_OPTICS_PANE_LENS, + LARDON3D_TUI_OPTICS_PANE_CONFIGURATION, + LARDON3D_TUI_OPTICS_PANE_CALIBRATION, +} Lardon3DTuiOpticsPane; + +typedef struct { + bool project_bound; + Lardon3DTuiOpticsPane active_pane; + Lardon3DOpticalCameraBodyProfile + bodies[LARDON3D_TUI_OPTICS_PAGE_CAPACITY]; + size_t body_count; + size_t selected_body; + bool bodies_have_next; + Lardon3DOpticalLensProfile + lenses[LARDON3D_TUI_OPTICS_PAGE_CAPACITY]; + size_t lens_count; + size_t selected_lens; + bool lenses_have_next; + Lardon3DOpticalConfiguration + configurations[LARDON3D_TUI_OPTICS_PAGE_CAPACITY]; + size_t configuration_count; + size_t selected_configuration; + bool configurations_have_next; + Lardon3DOpticalCalibrationProfile + calibrations[LARDON3D_TUI_OPTICS_PAGE_CAPACITY]; + size_t calibration_count; + size_t selected_calibration; + bool calibrations_have_next; + + bool capture_inspected; + uint64_t capture_id; + Lardon3DTuiOpticsStatus capture_status; + bool capture_assignment_found; + Lardon3DOpticalCaptureAssignment capture_assignment; + bool capture_configuration_found; + Lardon3DOpticalConfiguration capture_configuration; + bool capture_body_found; + Lardon3DOpticalCameraBodyProfile capture_body; + bool capture_lens_found; + Lardon3DOpticalLensProfile capture_lens; + bool capture_selection_found; + Lardon3DOpticalCaptureCalibrationSelection capture_selection; + + bool metadata_lookup_performed; + bool metadata_body_found; + Lardon3DOpticalCameraBodyProfile metadata_body; + bool metadata_lens_found; + Lardon3DOpticalLensProfile metadata_lens; + char message[LARDON3D_TUI_TEXT_CAPACITY]; +} Lardon3DTuiOpticsSnapshot; + +/* One main-thread-only view model caches bounded pages and caller-owned copies; + * no function is thread-safe. NULL is rejected except destroy/unbind, which + * accept it. Binding borrows the Project DB until unbind/destroy and performs + * bounded list calls once; redraw/snapshot never queries SQLite. On bind + * failure no DB borrow is retained and snapshot.message preserves the exact + * BUSY/IO/CORRUPT-style reason for explicit retry. Unbind before DB close. */ +Lardon3DTuiOptics *lardon3d_tui_optics_create(void); +void lardon3d_tui_optics_destroy(Lardon3DTuiOptics *optics); +bool lardon3d_tui_optics_bind( + Lardon3DTuiOptics *optics, + Lardon3DProjectDb *database +); +void lardon3d_tui_optics_unbind(Lardon3DTuiOptics *optics); +/* Initializes a non-NULL output before validation; on success it is a + * caller-owned value with no internal pointers and remains valid after the + * next model call, unbind, or destroy. */ +bool lardon3d_tui_optics_snapshot( + const Lardon3DTuiOptics *optics, + Lardon3DTuiOpticsSnapshot *snapshot +); + +bool lardon3d_tui_optics_select_pane( + Lardon3DTuiOptics *optics, + Lardon3DTuiOpticsPane pane +); +bool lardon3d_tui_optics_move_selection( + Lardon3DTuiOptics *optics, + int direction +); +/* `next=true` loads the next ascending page; false returns to the first page. + * Calibration pages use the currently inspected Capture's exact optical + * configuration. There is no unbounded accumulated history. */ +bool lardon3d_tui_optics_page( + Lardon3DTuiOptics *optics, + Lardon3DTuiOpticsPane pane, + bool next +); + +/* Creation is immutable: editing means creating/selecting a new versioned + * profile/configuration. Inputs are borrowed for the call and exact retry or + * conflict semantics remain those of optical_profiles.h. */ +bool lardon3d_tui_optics_create_body( + Lardon3DTuiOptics *optics, + const Lardon3DOpticalCameraBodyProfile *input +); +bool lardon3d_tui_optics_create_lens( + Lardon3DTuiOptics *optics, + const Lardon3DOpticalLensProfile *input +); +bool lardon3d_tui_optics_create_configuration( + Lardon3DTuiOptics *optics, + bool has_focal_length, + uint32_t focal_length_um +); + +bool lardon3d_tui_optics_assign_campaign_group( + Lardon3DTuiOptics *optics, + uint64_t campaign_task_id, + uint32_t group_id +); +bool lardon3d_tui_optics_assign_capture( + Lardon3DTuiOptics *optics, + uint64_t capture_id +); +bool lardon3d_tui_optics_inspect_capture( + Lardon3DTuiOptics *optics, + uint64_t capture_id +); +bool lardon3d_tui_optics_select_capture_calibration( + Lardon3DTuiOptics *optics +); + +/* Exact metadata lookup only. Empty lens make is valid for data-driven manual + * aliases; no alias match leaves that side unresolved and creates nothing. */ +bool lardon3d_tui_optics_lookup_exact_metadata( + Lardon3DTuiOptics *optics, + const char *body_make, + const char *body_model, + const char *lens_make, + const char *lens_model +); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/include/lardon3d/tui_ssd_async.h b/include/lardon3d/tui_ssd_async.h new file mode 100644 index 0000000..435ac63 --- /dev/null +++ b/include/lardon3d/tui_ssd_async.h @@ -0,0 +1,142 @@ +#ifndef LARDON3D_TUI_SSD_ASYNC_H +#define LARDON3D_TUI_SSD_ASYNC_H + +#include +#include + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +typedef struct Lardon3DTuiSsdAsync Lardon3DTuiSsdAsync; + +typedef enum { + LARDON3D_TUI_SSD_ACTION_NONE = 0, + /* Internal coalesced telemetry operation; F10 never selects it. */ + LARDON3D_TUI_SSD_ACTION_OBSERVE, + LARDON3D_TUI_SSD_ACTION_ENABLE, + LARDON3D_TUI_SSD_ACTION_DRAIN, + LARDON3D_TUI_SSD_ACTION_CANCEL_DRAIN, +} Lardon3DTuiSsdAction; + +typedef struct { + bool running; + Lardon3DTuiSsdAction action; + bool result_known; + Lardon3DSsdControlResult result; + uint64_t generation; + bool controller_snapshot_known; + bool controller_snapshot_actionable; + Lardon3DSsdSnapshot controller_snapshot; + char reason[LARDON3D_SSD_REASON_CAPACITY]; +} Lardon3DTuiSsdAsyncSnapshot; + +typedef struct { + bool (*monotonic_now_ns)(void *context, uint64_t *now_ns); + bool (*snapshot)(void *context, Lardon3DSsdSnapshot *snapshot); + Lardon3DSsdControlResult (*enable)(void *context); + Lardon3DSsdControlResult (*disable)(void *context); + bool (*cancel_drain)(void *context); + void (*destroy)(void *context); +} Lardon3DTuiSsdAsyncProviderOps; + +typedef struct { + const Lardon3DTuiSsdAsyncProviderOps *ops; + void *context; +} Lardon3DTuiSsdAsyncProvider; + +/* The caller owns one object; TUI request/poll calls remain on the + * ncurses/main thread. `controller` is borrowed and must outlive this object. + * Exactly one joinable operation thread may exist. It runs only one bounded + * synchronous controller transition; it never renders, schedules a Task, + * polls in a loop, or becomes a second Queue. */ +Lardon3DTuiSsdAsync *lardon3d_tui_ssd_async_create( + Lardon3DSsdController *controller +); + +/* Production binding. Both borrowed owners must outlive the async object. + * Creation registers a conservative ERROR until the first validated worker + * observation; each later worker outcome updates the Governor registry. The + * registry is unregistered after the worker is joined and before destruction + * returns. Existing create() remains an unbound compatibility entry point. */ +Lardon3DTuiSsdAsync *lardon3d_tui_ssd_async_create_with_governor( + Lardon3DSsdController *controller, + Lardon3DResourceGovernor *governor +); + +/* Deterministic provider seam. All five callbacks are required; destroy is + * optional. Ownership of provider.context transfers only on success. Tests use + * condition-controlled providers and never invoke real mount/swap operations. + * Production callers normally use create() above. */ +Lardon3DTuiSsdAsync *lardon3d_tui_ssd_async_create_with_provider( + Lardon3DTuiSsdAsyncProvider provider +); + +/* Checked production teardown. `operation` and `*operation` must be non-NULL. + * It joins the bounded worker, then unregisters the Governor binding before + * destroying provider/synchronization state. On success it frees the object, + * stores NULL, and returns true. If an exact scratch lease still prevents + * unregister, it returns false and retains the stopped object plus binding so + * the owner can release the lease and retry; no borrowed pointer is orphaned. */ +bool lardon3d_tui_ssd_async_destroy_checked( + Lardon3DTuiSsdAsync **operation +); + +/* Compatibility teardown for objects made by the unbound create() or provider + * constructor. Bound production owners must use destroy_checked() so a failed + * unregister remains observable and retryable. A + * D-Bus call is not asynchronously cancelled: production controller timeouts + * bound the join and preserve its exact side-effect verification contract. */ +void lardon3d_tui_ssd_async_destroy(Lardon3DTuiSsdAsync *operation); + +/* Starts one exact controller-authorized transition from the last validated + * snapshot. Requests are rejected while busy, after shutdown, for invalid + * actions, or when the corresponding capability flag is absent. */ +bool lardon3d_tui_ssd_async_request( + Lardon3DTuiSsdAsync *operation, + Lardon3DTuiSsdAction action +); + +/* Starts one background telemetry refresh only when the one-second cache is + * due and the sole worker is idle. Returning true means the request was + * accepted, already cached, or coalesced behind an active control operation; + * it never performs controller/D-Bus work on the caller/ncurses thread. */ +bool lardon3d_tui_ssd_async_refresh(Lardon3DTuiSsdAsync *operation); + +/* Poll is non-blocking except for joining a worker already known complete. + * Output is always initialized; caller owns the bounded copy. Controller + * telemetry remains unknown until one background refresh completes. A later + * failed controller refresh publishes its truthful ERROR snapshot. Malformed + * provider output is replaced by a bounded ERROR copy with + * controller_snapshot_actionable=false, so it cannot authorize F10. */ +bool lardon3d_tui_ssd_async_poll( + Lardon3DTuiSsdAsync *operation, + Lardon3DTuiSsdAsyncSnapshot *snapshot +); + +/* Waits on the operation condition using CLOCK_MONOTONIC, then joins a + * completed worker. A timeout returns false without cancelling or losing + * ownership. This is also the deterministic validation seam. */ +bool lardon3d_tui_ssd_async_wait_idle( + Lardon3DTuiSsdAsync *operation, + uint64_t timeout_ns +); + +/* Selects the exact F10 transition solely from controller-owned capability + * flags. State/activity/path fields are presentation only and never grant + * authority. Malformed or ambiguous capability combinations return false. */ +bool lardon3d_tui_ssd_action_for_snapshot( + const Lardon3DSsdSnapshot *snapshot, + Lardon3DTuiSsdAction *action +); + +const char *lardon3d_tui_ssd_action_name(Lardon3DTuiSsdAction action); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/meson.build b/meson.build index 9c135ea..7a5c911 100644 --- a/meson.build +++ b/meson.build @@ -24,6 +24,7 @@ ncursesw = dependency('ncursesw', required: true) threads = dependency('threads') sqlite3 = dependency('sqlite3', required: true) openssl = dependency('openssl', required: true) +gio = dependency('gio-2.0', required: true, include_type: 'system') ceres = dependency( 'Ceres', method: 'cmake', @@ -145,6 +146,11 @@ lardon3d_app = executable( 'src/app_state.c', 'src/tui.c', 'src/layout.c', + 'src/tui_model.c', + 'src/runtime_observer.c', + 'src/runtime_session.c', + 'src/tui_ssd_async.c', + 'src/tui_optics.c', 'src/import.c', 'src/import_task.c', 'src/image_catalog.c', @@ -159,7 +165,6 @@ lardon3d_app = executable( 'src/photo_quality_task.cpp', 'src/feature_extractor_opencv.cpp', 'src/feature_store.c', - 'src/feature_extractor_opencv.cpp', 'src/feature_task.c', 'src/sift_task.c', 'src/precision_features.c', 'src/visual_index.c', 'src/visual_index_task.c', @@ -170,6 +175,7 @@ lardon3d_app = executable( 'src/image_view.c', 'src/project.c', 'src/project_db.c', 'src/project_db_sparse_sfm.c', + 'src/optical_profiles.c', 'src/calibration_bootstrap.c', 'src/sparse_sfm_geometry.cpp', 'src/sparse_sfm_incremental.cpp', @@ -187,8 +193,10 @@ lardon3d_app = executable( 'src/task_kinds.c', 'src/task_queue.c', 'src/resource_governor.c', + 'src/resource_governor_external_storage.c', 'src/resource_snapshot.c', 'src/hardware_profile.c', + 'src/ssd_controller.c', 'src/match_file.c', 'src/matcher.cpp', 'src/geometric_verifier.cpp', @@ -210,6 +218,7 @@ lardon3d_app = executable( threads, sqlite3, openssl, + gio, libraw, libexif, libpng, @@ -317,7 +326,10 @@ persistent_image_catalog_test = executable( 'src/resource_governor.c', 'src/resource_snapshot.c', ], - c_args: ['-DLARDON3D_PROJECT_DB_TESTING'], + c_args: [ + '-DLARDON3D_PROJECT_DB_TESTING', + '-DLARDON3D_IMAGE_CATALOG_PERSISTENT_TESTING', + ], include_directories: include_directories('include'), dependencies: [threads, sqlite3, openssl], ) @@ -731,6 +743,7 @@ feature_task_test = executable( '-DLARDON3D_FEATURE_STORE_TESTING', '-DLARDON3D_VISUAL_INDEX_TASK_TESTING', '-DLARDON3D_VISUAL_INDEX_TESTING', + '-DLARDON3D_RESOURCE_GOVERNOR_CAPTURE_TESTING', ], cpp_args: ['-DLARDON3D_FEATURE_TASK_TESTING'], include_directories: include_directories('include'), @@ -864,6 +877,21 @@ calibration_bootstrap_test = executable( test('calibration-bootstrap', calibration_bootstrap_test, timeout: 30) +optical_profiles_test = executable( + 'test-optical-profiles', + sources: [ + 'tests/test_optical_profiles.c', + 'src/optical_profiles.c', + 'src/project_db.c', 'src/project_db_sparse_sfm.c', + 'src/task.c', 'src/resource_governor.c', 'src/resource_snapshot.c', + ], + c_args: ['-DLARDON3D_PROJECT_DB_TESTING'], + include_directories: include_directories('include'), + dependencies: [threads, sqlite3, openssl], +) + +test('optical-profiles', optical_profiles_test, timeout: 30) + sparse_sfm_model_test = executable( 'test-sparse-sfm-model', sources: [ @@ -975,6 +1003,7 @@ project_test = executable( 'tests/test_project.c', 'src/app_state.c', 'src/project.c', + 'src/runtime_session.c', 'src/project_db.c', 'src/project_db_sparse_sfm.c', 'src/task.c', 'src/task_checkpoint.c', @@ -1045,6 +1074,7 @@ task_queue_test = executable( 'src/resource_governor.c', 'src/resource_snapshot.c', ], + c_args: ['-DLARDON3D_TASK_QUEUE_TESTING'], include_directories: include_directories('include'), dependencies: [threads], ) @@ -1062,6 +1092,112 @@ hardware_profile_test = executable( test('hardware-profile', hardware_profile_test, timeout: 30) +ssd_controller_test = executable( + 'test-ssd-controller', + sources: ['tests/test_ssd_controller.c', 'src/ssd_controller.c'], + c_args: ['-DLARDON3D_SSD_CONTROLLER_TESTING'], + include_directories: include_directories('include'), + dependencies: [threads, gio], +) + +test('ssd-controller', ssd_controller_test, timeout: 30) + +resource_external_storage_test = executable( + 'test-resource-external-storage', + sources: [ + 'tests/test_resource_external_storage.c', + 'src/resource_governor.c', + 'src/resource_governor_external_storage.c', + 'src/resource_snapshot.c', + 'src/ssd_controller.c', + 'src/task.c', + 'src/task_queue.c', + 'src/tui_ssd_async.c', + ], + c_args: [ + '-DLARDON3D_SSD_CONTROLLER_TESTING', + '-DLARDON3D_RESOURCE_EXTERNAL_STORAGE_TESTING', + '-DLARDON3D_TASK_QUEUE_TESTING', + ], + include_directories: include_directories('include'), + dependencies: [threads, gio], +) + +test('resource-external-storage', resource_external_storage_test, timeout: 30) + +tui_model_test = executable( + 'test-tui-model', + sources: ['tests/test_tui_model.c', 'src/tui_model.c'], + include_directories: include_directories('include'), +) + +test('tui-model', tui_model_test, timeout: 30) + +tui_layout_test = executable( + 'test-tui-layout', + sources: [ + 'tests/test_tui_layout.c', + 'src/layout.c', + 'src/tui_model.c', + ], + include_directories: include_directories('include'), + dependencies: [ncursesw], +) + +test('tui-layout', tui_layout_test, timeout: 30) + +public_runtime_abi_test = executable( + 'test-public-runtime-abi', + sources: ['tests/test_public_runtime_abi.c'], + include_directories: include_directories('include'), +) + +test('public-runtime-abi', public_runtime_abi_test, timeout: 30) + +runtime_observer_test = executable( + 'test-runtime-observer', + sources: [ + 'tests/test_runtime_observer.c', + 'src/runtime_observer.c', + 'src/tui_model.c', + ], + include_directories: include_directories('include'), +) + +test('runtime-observer', runtime_observer_test, timeout: 30) + +tui_ssd_async_test = executable( + 'test-tui-ssd-async', + sources: [ + 'tests/test_tui_ssd_async.c', + 'src/tui_ssd_async.c', + 'src/ssd_controller.c', + 'src/resource_governor.c', + 'src/resource_governor_external_storage.c', + 'src/resource_snapshot.c', + ], + include_directories: include_directories('include'), + dependencies: [threads, gio], +) + +test('tui-ssd-async', tui_ssd_async_test, timeout: 30) + +tui_optics_test = executable( + 'test-tui-optics', + sources: [ + 'tests/test_tui_optics.c', + 'src/tui_optics.c', + 'src/tui_model.c', + 'src/optical_profiles.c', + 'src/project_db.c', 'src/project_db_sparse_sfm.c', + 'src/task.c', 'src/resource_governor.c', 'src/resource_snapshot.c', + ], + include_directories: include_directories('include'), + dependencies: [threads, sqlite3, openssl], +) + +test('tui-optics', tui_optics_test, timeout: 30) + resource_snapshot_test = executable( 'test-resource-snapshot', sources: [ diff --git a/src/acquisition_campaign_task.cpp b/src/acquisition_campaign_task.cpp index d2361f1..0dcc765 100644 --- a/src/acquisition_campaign_task.cpp +++ b/src/acquisition_campaign_task.cpp @@ -6,6 +6,9 @@ extern "C" { #include "task_internal.h" } +#include "opencv_task_thread_guard.h" + +#include #include #include #include @@ -18,6 +21,10 @@ namespace { constexpr unsigned char magic[8] = {'L', '3', 'D', 'A', 'C', 'T', '1', '\0'}; constexpr size_t max_request_size = LARDON3D_ACQUISITION_CAMPAIGN_TASK_REQUEST_MAX_BYTES; +constexpr uint64_t kRawWorkingBytes = + UINT64_C(2) * 1024u * 1024u * 1024u; +constexpr uint64_t kCampaignWorkingBytes = UINT64_C(256) * 1024u; +constexpr uint64_t kGroupWorkingBytes = UINT64_C(64) * 1024u; /* Codec is transport-safe and deterministic: no native struct serialization is * used, integers are fixed-width with explicit endianness, and strings/counts are @@ -183,6 +190,20 @@ struct Context { std::vector encoded; Lardon3DAcquisitionCampaignPlan plan{}; Lardon3DAcquisitionIngestOptions options{}; +#ifdef LARDON3D_ACQUISITION_CAMPAIGN_TASK_TESTING + enum { kTestGroupCapacity = 8 }; + /* Test builds can substitute only S3-E's returned Capture IDs. The complete + * Task/DB/checkpoint/sequence-break path remains production code, while no + * filesystem decoder or scientific grouping behavior is replaced. Fixed + * storage keeps the production resource estimate deterministic in tests. */ + uint64_t test_capture_ids[kTestGroupCapacity]{}; + unsigned int test_observed_threads[kTestGroupCapacity]{}; + size_t test_capture_count{}; + size_t test_observed_count{}; + uint32_t test_failure_group{}; + unsigned int test_mutate_threads_before_break{}; + bool test_materialization_fixture{}; +#endif }; bool context_owned_bytes(const Context &context, uint64_t &bytes) { @@ -209,6 +230,64 @@ bool context_owned_bytes(const Context &context, uint64_t &bytes) { return true; } +bool estimate_equals(const Lardon3DResourceEstimate &left, + const Lardon3DResourceEstimate &right) { + return left.memory_fixed_bytes == right.memory_fixed_bytes && + left.gpu_memory_fixed_bytes == right.gpu_memory_fixed_bytes && + left.memory_bytes_per_item == right.memory_bytes_per_item && + left.gpu_memory_bytes_per_item == right.gpu_memory_bytes_per_item && + left.minimum_batch_size == right.minimum_batch_size && + left.maximum_batch_size == right.maximum_batch_size && + left.desired_cpu_threads == right.desired_cpu_threads && + left.desired_gpu_slots == right.desired_gpu_slots && + left.desired_io_slots == right.desired_io_slots && + left.task_class == right.task_class; +} + +bool campaign_estimate(const Context &context, bool historical, + Lardon3DResourceEstimate &estimate) { + if (context.options.representation != + LARDON3D_ACQUISITION_SELECT_JPEG_SOURCE && + context.options.representation != + LARDON3D_ACQUISITION_SELECT_DEVELOP_RAW) + return false; + uint64_t fixed = 0; + if (!context_owned_bytes(context, fixed) || + kCampaignWorkingBytes > UINT64_MAX - fixed) + return false; + fixed += kCampaignWorkingBytes; + Lardon3DResourceTaskClass task_class = LARDON3D_RESOURCE_TASK_IMPORT; + if (!historical) { + const uint64_t transient_request = + static_cast(context.encoded.size()); + if (transient_request > UINT64_MAX - fixed) + return false; + fixed += transient_request; + if (context.options.representation == + LARDON3D_ACQUISITION_SELECT_DEVELOP_RAW) { + if (kRawWorkingBytes > UINT64_MAX - fixed) + return false; + fixed += kRawWorkingBytes; + task_class = LARDON3D_RESOURCE_TASK_MIXED; + } + } + estimate = Lardon3DResourceEstimate{ + fixed, 0, kGroupWorkingBytes, 0, 1, 1, 1, 0, 1, task_class}; + return true; +} + +bool apply_admitted_opencv_threads( + const Lardon3DTaskExecutionContract &contract) noexcept { + if (contract.cpu_threads == 0 || contract.cpu_threads > INT_MAX) + return false; + try { + cv::setNumThreads(static_cast(contract.cpu_threads)); + return cv::getNumThreads() == static_cast(contract.cpu_threads); + } catch (...) { + return false; + } +} + void destroy(void *p) { delete static_cast(p); } void runtime(Context *c, Lardon3DAppState &s) { lardon3d_app_state_init(&s); @@ -234,18 +313,38 @@ bool checkpoint(Context *c, Lardon3DTask *t, uint32_t cursor) { bool run_impl(Lardon3DTask *t, void *p) { auto *c = static_cast(p); Lardon3DProjectDbAcquisitionCampaignTask persisted{}; - // Request is reconstructed from persisted blob and treated as immutable durable - // input during recovery and replay. - std::vector blob(max_request_size); + /* The retained exact encoding is the immutable replay input and also bounds + * this transient verification buffer. A maximum-capacity allocation would + * charge almost 20 MiB even for a small campaign without adding safety. */ + std::unique_ptr blob( + new unsigned char[c->encoded.size()]); if (lardon3d_project_db_load_acquisition_campaign_task( - c->db, lardon3d_task_id(t), blob.data(), blob.size(), &persisted) != + c->db, lardon3d_task_id(t), blob.get(), c->encoded.size(), + &persisted) != LARDON3D_PROJECT_DB_OK) return lardon3d_task_fail(t, "Campagne durable introuvable."); + if (persisted.scanset_id != c->scanset || + persisted.group_count != c->plan.group_count || + persisted.request_size != c->encoded.size() || + std::memcmp(blob.get(), c->encoded.data(), c->encoded.size()) != 0) + return lardon3d_task_fail(t, "Requête durable de campagne incohérente."); + if (!lardon3d_task_set_durable_progress( + t, persisted.next_group_id, persisted.group_count, + "Préfixe durable de campagne observé.")) + return false; for (uint32_t cursor = persisted.next_group_id; cursor < c->plan.group_count; ++cursor) { const uint32_t group_id = cursor + 1u; if (!lardon3d_task_checkpoint(t)) return false; +#ifdef LARDON3D_ACQUISITION_CAMPAIGN_TASK_TESTING + if (c->test_materialization_fixture) { + if (c->test_observed_count >= Context::kTestGroupCapacity) + return lardon3d_task_fail(t, "Trop d'observations CPU de test."); + c->test_observed_threads[c->test_observed_count++] = + static_cast(cv::getNumThreads()); + } +#endif Lardon3DProjectDbAcquisitionCampaignCapture retained{}; uint64_t resume = 0; /* Durable replay uses the stable mapping (task_id, group_id) -> capture_id. @@ -258,20 +357,36 @@ bool run_impl(Lardon3DTask *t, void *p) { resume = retained.capture_id; else if (lr != LARDON3D_PROJECT_DB_NOT_FOUND) return lardon3d_task_fail(t, "Lecture de rétention impossible."); +#ifdef LARDON3D_ACQUISITION_CAMPAIGN_TASK_TESTING + if (c->test_materialization_fixture && + c->test_failure_group == group_id) + return lardon3d_task_fail(t, "Échec de callback campagne injecté."); +#endif auto options = c->options; options.grouping = LARDON3D_ACQUISITION_GROUP_CALLER_EXPLICIT; options.resume_capture_id = resume; options.producer_task_id = lardon3d_task_id(t); - Lardon3DAcquisitionIngestOutput out{}; - Lardon3DAcquisitionIngestResult ir{}; - Lardon3DAppState s; - runtime(c, s); - auto cr = lardon3d_acquisition_campaign_materialize_group( - &s, c->scanset, c->sources.data(), c->sources.size(), &c->plan, - group_id, &options, &out, &ir); - if (cr != LARDON3D_ACQUISITION_CAMPAIGN_OK || - ir != LARDON3D_ACQUISITION_INGEST_OK || out.group_count != 1) - return lardon3d_task_fail(t, "Échec de matérialisation de campagne."); + uint64_t materialized_capture_id = 0; +#ifdef LARDON3D_ACQUISITION_CAMPAIGN_TASK_TESTING + if (c->test_materialization_fixture) { + if (group_id == 0 || group_id > c->test_capture_count) + return lardon3d_task_fail(t, "Fixture de Capture de campagne invalide."); + materialized_capture_id = c->test_capture_ids[group_id - 1u]; + } else +#endif + { + Lardon3DAcquisitionIngestOutput out{}; + Lardon3DAcquisitionIngestResult ir{}; + Lardon3DAppState s; + runtime(c, s); + auto cr = lardon3d_acquisition_campaign_materialize_group( + &s, c->scanset, c->sources.data(), c->sources.size(), &c->plan, + group_id, &options, &out, &ir); + if (cr != LARDON3D_ACQUISITION_CAMPAIGN_OK || + ir != LARDON3D_ACQUISITION_INGEST_OK || out.group_count != 1) + return lardon3d_task_fail(t, "Échec de matérialisation de campagne."); + materialized_capture_id = out.groups[0].capture_id; + } #ifdef LARDON3D_ACQUISITION_CAMPAIGN_TASK_TESTING const char *before_retention = std::getenv("LARDON3D_TEST_CAMPAIGN_FAIL_BEFORE_RETENTION"); @@ -279,7 +394,7 @@ bool run_impl(Lardon3DTask *t, void *p) { return lardon3d_task_fail(t, "Échec injecté avant rétention."); #endif if (lardon3d_project_db_retain_acquisition_campaign_capture( - c->db, lardon3d_task_id(t), group_id, out.groups[0].capture_id, + c->db, lardon3d_task_id(t), group_id, materialized_capture_id, group_id) != LARDON3D_PROJECT_DB_OK) return lardon3d_task_fail(t, "Rétention de Capture impossible."); /* Limite de reprise acceptée: entre le retour de S3-E et cette rétention @@ -292,27 +407,51 @@ bool run_impl(Lardon3DTask *t, void *p) { if (after_retention && std::strcmp(after_retention, "1") == 0) return lardon3d_task_fail(t, "Échec injecté après rétention."); #endif - unsigned progress = static_cast( - (uint64_t(group_id) * 100u / c->plan.group_count)); - if (!lardon3d_task_set_progress(t, progress, - "Groupe de campagne matérialisé.") || + /* The retained mapping/cursor transaction above is the authority for this + * exact TUI count. Observation may lag it, but must never lead it. */ + if (!lardon3d_task_set_durable_progress( + t, group_id, c->plan.group_count, + "Groupe de campagne matérialisé.") || !checkpoint(c, t, group_id)) return lardon3d_task_fail(t, "Checkpoint de campagne impossible."); if (group_id < c->plan.group_count) { - // sequence_break: libère la réservation courante et renégocie l'admission - // via le Governor avant le groupe suivant. + /* Campaign cardinality never extends a reservation lifetime: this + * boundary releases the current group admission and Task/Queue asks the + * Governor for a fresh bounded contract before the next group. */ Lardon3DTaskExecutionContract contract{}; Lardon3DResourceReservation *reservation = nullptr; +#ifdef LARDON3D_ACQUISITION_CAMPAIGN_TASK_TESTING + if (c->test_materialization_fixture && + c->test_mutate_threads_before_break > 0) { + cv::setNumThreads( + static_cast(c->test_mutate_threads_before_break)); + if (cv::getNumThreads() != + static_cast(c->test_mutate_threads_before_break)) + return lardon3d_task_fail(t, "Mutation OpenCV de test impossible."); + } +#endif if (!lardon3d_task_sequence_break(t, c->governor, &reservation, &contract)) return false; + if (!apply_admitted_opencv_threads(contract)) + return lardon3d_task_fail( + t, "Contrat CPU OpenCV renouvelé de campagne invalide."); } } return true; } bool run(Lardon3DTask *t, void *p) noexcept { try { - return run_impl(t, p); + Lardon3DOpenCvTaskThreadGuard threads(t); + if (!threads.valid()) + return lardon3d_task_fail(t, "Contrat CPU OpenCV de campagne invalide."); + /* Queue owns the sole campaign callback. Apply its admitted CPU count to + * OpenCV for direct in-task RAW/JPEG work; campaign execution does not + * create a nested Task or a second reservation owner. */ + bool result = run_impl(t, p); + if (!threads.restore()) + return lardon3d_task_fail(t, "Restauration OpenCV de campagne impossible."); + return result; } catch (const std::bad_alloc &) { return lardon3d_task_fail(t, "Mémoire insuffisante pour la campagne."); } catch (...) { @@ -333,7 +472,9 @@ Context *make_context(const char *path, Lardon3DProjectDb *db, Lardon3DResourceGovernor *g, uint64_t scanset, const Lardon3DAcquisitionCampaignTaskRequest &r, const unsigned char *encoded, size_t n) { - if (!path || !path[0]) + if (!path || !path[0] || !db || !g || scanset == 0 || !r.sources || + r.source_count == 0 || (r.confirmation_count > 0 && !r.confirmations) || + !encoded || n == 0) return nullptr; std::unique_ptr c(new (std::nothrow) Context); if (!c) @@ -346,8 +487,9 @@ Context *make_context(const char *path, Lardon3DProjectDb *db, c->governor = g; c->scanset = scanset; c->sources.assign(r.sources, r.sources + r.source_count); - c->confirmations.assign(r.confirmations, - r.confirmations + r.confirmation_count); + if (r.confirmation_count > 0) + c->confirmations.assign(r.confirmations, + r.confirmations + r.confirmation_count); c->options = r.ingest_options; c->encoded.assign(encoded, encoded + n); if (lardon3d_acquisition_campaign_plan(c->sources.data(), c->sources.size(), @@ -360,26 +502,75 @@ Context *make_context(const char *path, Lardon3DProjectDb *db, } } // namespace +#ifdef LARDON3D_ACQUISITION_CAMPAIGN_TASK_TESTING +extern "C" bool lardon3d_acquisition_campaign_task_test_configure_execution( + void *userdata, const uint64_t *capture_ids, size_t capture_count, + uint32_t failure_group, unsigned int mutate_threads_before_break) { + try { + auto *context = static_cast(userdata); + if (!context || !capture_ids || capture_count == 0 || + capture_count > Context::kTestGroupCapacity || + capture_count != context->plan.group_count || + failure_group > capture_count || mutate_threads_before_break > INT_MAX) + return false; + for (size_t index = 0; index < capture_count; ++index) { + if (capture_ids[index] == 0 || capture_ids[index] > INT64_MAX) + return false; + context->test_capture_ids[index] = capture_ids[index]; + } + context->test_capture_count = capture_count; + context->test_observed_count = 0; + context->test_failure_group = failure_group; + context->test_mutate_threads_before_break = mutate_threads_before_break; + context->test_materialization_fixture = true; + return true; + } catch (...) { + return false; + } +} + +extern "C" bool lardon3d_acquisition_campaign_task_test_observed_threads( + void *userdata, unsigned int *threads, size_t capacity, size_t *count) { + if (count) + *count = 0; + auto *context = static_cast(userdata); + if (!context || !count || + (context->test_observed_count > 0 && + (!threads || capacity < context->test_observed_count))) + return false; + if (context->test_observed_count > 0) + std::memcpy(threads, context->test_observed_threads, + context->test_observed_count * sizeof(*threads)); + *count = context->test_observed_count; + return true; +} +#endif + extern "C" bool lardon3d_acquisition_campaign_task_internal_configure_restored( Lardon3DTask *task, void *userdata) { try { auto *context = static_cast(userdata); - uint64_t retained = 0; - if (!task || !context || !context_owned_bytes(*context, retained) || - retained > UINT64_MAX - 256 * 1024) + Lardon3DTaskDurableSnapshot snapshot{}; + Lardon3DResourceEstimate current{}; + Lardon3DResourceEstimate historical{}; + if (!task || !context || !lardon3d_task_durable_snapshot(task, &snapshot) || + !campaign_estimate(*context, false, current) || + !campaign_estimate(*context, true, historical) || + (!estimate_equals(snapshot.estimate, current) && + !estimate_equals(snapshot.estimate, historical))) return false; Lardon3DTaskCapability capability{}; - capability.estimate = Lardon3DResourceEstimate{ - retained + 256 * 1024, 0, 64 * 1024, 0, 1, 1, 1, 0, 1, - LARDON3D_RESOURCE_TASK_IMPORT}; + capability.estimate = current; capability.backend = LARDON3D_RESOURCE_BACKEND_FIXED; capability.inflight_limit = 1; Lardon3DTaskCapabilityEnvelope envelope{}; envelope.count = 1; envelope.capabilities[0] = capability; - /* Recovery may carry the historical under-estimate, but admission uses the - * exact newly reconstructed retained context. Durable bytes stay untouched. */ + /* Exact v22 historical signatures remain recoverable, but admission uses + * the estimate derived from the immutable request. Operational policy is + * normalized only in memory; durable Task/scientific identities stay + * untouched and arbitrary estimate corruption is rejected. */ return lardon3d_task_internal_set_capability_envelope(task, &envelope); } catch (...) { return false; @@ -420,7 +611,8 @@ bool request_decode_impl( uint64_t imported, maxbytes; /* Version 1 is the only accepted codec shape; any mismatch rejects replay. */ if (!q.bytes(m, 8) || std::memcmp(m, magic, 8) || !q.u32(version) || - version != 1 || !q.u32(n) || n == 0 || n > sc || + version != LARDON3D_ACQUISITION_CAMPAIGN_REQUEST_VERSION || + !q.u32(n) || n == 0 || n > sc || n > LARDON3D_ACQUISITION_CAMPAIGN_MAX_SOURCES || !q.u32(c) || c > cc || c > n || !sources || (c > 0 && !confirmations) || !q.u32(rep) || rep < 1 || rep > 2 || !q.u32(select) || select > 1 || @@ -488,10 +680,16 @@ bool task_reconstruct_impl( auto *rt = static_cast(userdata); if (!s || !rt || !b) return false; - std::vector blob(max_request_size); + Lardon3DProjectDbAcquisitionCampaignTask measured{}; + if (lardon3d_project_db_load_acquisition_campaign_task( + rt->project_db, s->id, nullptr, 0, &measured) != + LARDON3D_PROJECT_DB_OK) + return false; + std::unique_ptr blob( + new unsigned char[measured.request_size]); Lardon3DProjectDbAcquisitionCampaignTask p{}; if (lardon3d_project_db_load_acquisition_campaign_task( - rt->project_db, s->id, blob.data(), blob.size(), &p) != + rt->project_db, s->id, blob.get(), measured.request_size, &p) != LARDON3D_PROJECT_DB_OK) return false; std::vector sources( @@ -500,17 +698,20 @@ bool task_reconstruct_impl( LARDON3D_ACQUISITION_CAMPAIGN_MAX_SOURCES); Lardon3DAcquisitionCampaignTaskRequest r{}; if (!lardon3d_acquisition_campaign_request_decode( - blob.data(), p.request_size, sources.data(), sources.size(), + blob.get(), p.request_size, sources.data(), sources.size(), confirmations.data(), confirmations.size(), &r)) return false; auto *c = make_context(rt->project_path, rt->project_db, rt->resource_governor, - p.scanset_id, r, blob.data(), p.request_size); - if (!c) + p.scanset_id, r, blob.get(), p.request_size); + if (!c || p.group_count != c->plan.group_count || + p.next_group_id > c->plan.group_count) { + delete c; return false; - // This Task kind has no legacy operational-resource reconciliation. Keep - // every optional Registry hook explicit so a future binding extension - // cannot accidentally inherit non-null recovery behavior. + } + /* Binding owns the reconstructed context. Registry's private post-restore + * hook validates the exact current or historical operational estimate before + * Queue admission; the typed request itself is never rewritten. */ *b = {}; b->callback = run; b->userdata = c; @@ -549,20 +750,19 @@ Lardon3DTask *create_task_impl( delete c; return nullptr; } - uint64_t retained = 0; - if (!context_owned_bytes(*c, retained) || retained > UINT64_MAX - 256 * 1024) { + Lardon3DResourceEstimate e{}; + if (!campaign_estimate(*c, false, e)) { delete c; return nullptr; } - /* The request vectors and inline campaign plan remain live for the Task - * lifetime. Charge them before admission; the per-group working estimate is - * separate and this operational bound does not limit scientific cardinality. */ - Lardon3DResourceEstimate e{ - retained + 256 * 1024, 0, 64 * 1024, 0, 1, - 1, 1, 0, 1, LARDON3D_RESOURCE_TASK_IMPORT}; + /* Admission charges retained context, one exact transient request reload, + * and group work. DEVELOP_RAW additionally owns raw.develop's conservative + * 2 GiB callback allowance. These are per-execution operational bounds, not + * scientific limits on campaign cardinality. */ auto *t = lardon3d_task_create_typed("Campagne d'acquisition", &e, LARDON3D_ACQUISITION_CAMPAIGN_TASK_KIND, - 1, run, c, destroy); + LARDON3D_ACQUISITION_CAMPAIGN_TASK_KIND_VERSION, + run, c, destroy); if (!t || !lardon3d_task_assign_id(t, task_id) || !lardon3d_task_set_finished_callback(t, finished, c) || !checkpoint(c, t, 0)) { @@ -633,18 +833,20 @@ extern "C" Lardon3DTask *lardon3d_project_create_acquisition_campaign_task( extern "C" bool lardon3d_project_enqueue_acquisition_campaign( Lardon3DAppState *s, uint64_t scanset, const Lardon3DAcquisitionCampaignTaskRequest *r, uint64_t *id) { + if (id) + *id = 0; try { - if (!s || !s->task_queue) - return false; - auto *t = - lardon3d_project_create_acquisition_campaign_task(s, scanset, r, id); - if (!t) - return false; - if (!lardon3d_task_queue_add(s->task_queue, t, nullptr)) { - lardon3d_task_destroy(t); - return false; - } - return true; + if (!s || !s->task_queue || !r || !id) + return false; + std::unique_ptr t( + lardon3d_project_create_acquisition_campaign_task(s, scanset, r, id), + lardon3d_task_destroy); + if (!t) + return false; + if (!lardon3d_task_queue_add(s->task_queue, t.get(), nullptr)) + return false; + (void)t.release(); + return true; } catch (const std::bad_alloc &) { return false; } catch (...) { diff --git a/src/app.c b/src/app.c index 2afd6e1..be7df74 100644 --- a/src/app.c +++ b/src/app.c @@ -10,8 +10,10 @@ #include #include #include +#include #include #include +#include #include "resource_governor_internal.h" @@ -48,24 +50,27 @@ lardon3d_app_run(void) )) { return EXIT_FAILURE; } - unsigned int feature_threads = state.hardware_profile.logical_cpu_count - - resource_policy.system_cpu_reserve; - if (feature_threads > 12) { - feature_threads = 12; - } - /* OpenCV owns one process-wide CPU setting. Startup establishes the safe - * audited baseline/ceiling before Queue exists; the sole Queue callback may - * temporarily apply its immutable admitted count and must restore this - * baseline on every path. Concurrent multi-worker mutation is unsupported. - * This operational count is never FeatureSet identity or fingerprint. */ - if (!lardon3d_feature_opencv_configure_threads(feature_threads)) { - return EXIT_FAILURE; - } state.resource_governor = lardon3d_resource_governor_create( &state.hardware_profile, &resource_policy ); - if (!state.resource_governor) { + Lardon3DResourceCpuPolicyDiagnostic cpu_policy; + if (!state.resource_governor + || !lardon3d_resource_governor_internal_cpu_policy( + state.resource_governor, &cpu_policy) + || cpu_policy.compute_cpu_count == 0) { + lardon3d_resource_governor_destroy(state.resource_governor); + return EXIT_FAILURE; + } + /* OpenCV owns one process-wide CPU setting. Startup establishes the safe + * derived compute-pool baseline before Queue exists; the sole Queue callback + * may temporarily apply its immutable admitted count and must restore this + * baseline on every path. Topology/core-group overshoot and external affinity + * constraints are therefore honored once, rather than independently capped. + * This operational count is never FeatureSet identity or fingerprint. */ + if (!lardon3d_feature_opencv_configure_threads( + cpu_policy.compute_cpu_count)) { + lardon3d_resource_governor_destroy(state.resource_governor); return EXIT_FAILURE; } state.orb_vulkan_backend = lardon3d_orb_vulkan_backend_create(); @@ -73,29 +78,70 @@ lardon3d_app_run(void) lardon3d_resource_governor_destroy(state.resource_governor); return EXIT_FAILURE; } - state.task_queue = lardon3d_task_queue_create(state.resource_governor, 64); + state.task_queue = lardon3d_task_queue_create( + state.resource_governor, LARDON3D_TASK_QUEUE_PRODUCTION_CAPACITY); if (!state.task_queue) { lardon3d_orb_vulkan_backend_destroy(state.orb_vulkan_backend); lardon3d_resource_governor_destroy(state.resource_governor); return EXIT_FAILURE; } + /* SSD support is optional physical-resource control. Failure to connect to + * UDisks leaves the TUI truthful but does not make scientific execution or + * Project DB availability depend on removable hardware. */ + Lardon3DSsdController *ssd_controller = + lardon3d_ssd_controller_create(); + Lardon3DTuiSsdAsync *ssd_operation = ssd_controller + ? lardon3d_tui_ssd_async_create_with_governor( + ssd_controller, state.resource_governor) + : NULL; + + if (ssd_controller && !ssd_operation) { + lardon3d_task_queue_destroy(state.task_queue); + state.task_queue = NULL; + (void)lardon3d_ssd_controller_destroy(ssd_controller); + lardon3d_orb_vulkan_backend_destroy(state.orb_vulkan_backend); + lardon3d_resource_governor_destroy(state.resource_governor); + return EXIT_FAILURE; + } + if (!lardon3d_tui_init()) { lardon3d_task_queue_destroy(state.task_queue); + state.task_queue = NULL; + bool storage_released = !ssd_operation + || lardon3d_tui_ssd_async_destroy_checked(&ssd_operation); + if (storage_released) { + (void)lardon3d_ssd_controller_destroy(ssd_controller); + } lardon3d_orb_vulkan_backend_destroy(state.orb_vulkan_backend); - lardon3d_resource_governor_destroy(state.resource_governor); + if (storage_released) { + lardon3d_resource_governor_destroy(state.resource_governor); + } return EXIT_FAILURE; } - bool success = lardon3d_tui_run(&state); + bool success = lardon3d_tui_run_with_ssd_operation( + &state, ssd_operation); lardon3d_tui_shutdown(); + /* INVARIANT: Queue destruction cancels and joins the sole Task worker, so + * every production Task-owned scratch lease is released before the + * Governor registration loses its exact physical-controller identity. */ lardon3d_task_queue_destroy(state.task_queue); state.task_queue = NULL; if (state.project_loaded) { lardon3d_project_close(&state); } + bool storage_released = !ssd_operation + || lardon3d_tui_ssd_async_destroy_checked(&ssd_operation); + if (!storage_released) { + success = false; + } else if (!lardon3d_ssd_controller_destroy(ssd_controller)) { + success = false; + } lardon3d_orb_vulkan_backend_destroy(state.orb_vulkan_backend); - lardon3d_resource_governor_destroy(state.resource_governor); + if (storage_released) { + lardon3d_resource_governor_destroy(state.resource_governor); + } return success ? EXIT_SUCCESS : EXIT_FAILURE; } diff --git a/src/candidate_pair_gen.c b/src/candidate_pair_gen.c index bd3f917..7d18510 100644 --- a/src/candidate_pair_gen.c +++ b/src/candidate_pair_gen.c @@ -8,7 +8,7 @@ #include #include -#include +#include #include "candidate_pair_gen_internal.h" @@ -192,17 +192,36 @@ void lardon3d_candidate_pair_generation_fingerprint( uint64_t visual_index_id, uint64_t source_feature_set_id, const Lardon3DVisualIndexQueryOptions *query_options, unsigned char fingerprint[32]) { - SHA256_CTX sha; - SHA256_Init(&sha); - SHA256_Update(&sha, &visual_index_id, sizeof(visual_index_id)); - SHA256_Update(&sha, &source_feature_set_id, sizeof(source_feature_set_id)); - if (query_options) { - SHA256_Update(&sha, &query_options->top_k, sizeof(query_options->top_k)); - SHA256_Update(&sha, &query_options->minimum_evidence_count, - sizeof(query_options->minimum_evidence_count)); - SHA256_Update(&sha, &query_options->scanset_filter, sizeof(query_options->scanset_filter)); - bool exclude = query_options->exclude_same_asset; - SHA256_Update(&sha, &exclude, sizeof(exclude)); + if (!fingerprint) return; + /* FROZEN SCIENTIFIC CONTRACT: v1 hashes the same 29 bytes historically + * produced on validated little-endian hosts, but spells out widths and byte + * order so a new architecture cannot silently change Candidate identity. + * The optional-query form remains the historical 16-byte ID prefix. */ + unsigned char canonical[29]; + for (unsigned int index = 0; index < 8; ++index) { + canonical[index] = (unsigned char)(visual_index_id >> (8U * index)); + canonical[8 + index] = + (unsigned char)(source_feature_set_id >> (8U * index)); + } + size_t size = 16; + if (query_options) { + const uint32_t values[] = { + query_options->top_k, + query_options->minimum_evidence_count, + (uint32_t)query_options->scanset_filter, + }; + for (size_t value = 0; value < 3; ++value) { + for (unsigned int index = 0; index < 4; ++index) { + canonical[16 + value * 4 + index] = + (unsigned char)(values[value] >> (8U * index)); + } + } + canonical[28] = query_options->exclude_same_asset ? 1U : 0U; + size = sizeof(canonical); + } + unsigned int digest_size = 0; + if (EVP_Digest(canonical, size, fingerprint, &digest_size, EVP_sha256(), NULL) != 1 || + digest_size != 32) { + memset(fingerprint, 0, 32); } - SHA256_Final(fingerprint, &sha); } diff --git a/src/candidate_pair_gen_internal.h b/src/candidate_pair_gen_internal.h index c347370..020b534 100644 --- a/src/candidate_pair_gen_internal.h +++ b/src/candidate_pair_gen_internal.h @@ -38,6 +38,20 @@ Lardon3DVisualIndexResult lardon3d_candidate_pair_publish( void lardon3d_candidate_pair_task_test_reset_parallel_counters(void); size_t lardon3d_candidate_pair_task_test_started_participants(void); size_t lardon3d_candidate_pair_task_test_computed_work_items(void); +/* Deterministic failure/ownership seam for the production partial-create + * cleanup path. SIZE_MAX disables failure; the active-handle count must return + * to zero before the callback exits on every result. */ +void lardon3d_candidate_pair_task_test_fail_thread_create_after( + size_t successful_children); +size_t lardon3d_candidate_pair_task_test_active_private_databases(void); +bool lardon3d_candidate_pair_task_test_compute_window( + const char *project_path, Lardon3DProjectDb *database, + uint64_t visual_index_id, + const Lardon3DVisualIndexQueryOptions *query_options, + const uint64_t *source_ids, size_t source_count, + unsigned int admitted_threads, + Lardon3DCandidatePairComputation *computations, + Lardon3DVisualIndexResult *results); #endif #endif diff --git a/src/candidate_pair_task.c b/src/candidate_pair_task.c index ad871ff..918b784 100644 --- a/src/candidate_pair_task.c +++ b/src/candidate_pair_task.c @@ -19,12 +19,20 @@ enum { CANDIDATE_PAIR_MINIMUM_BATCH = 1, CANDIDATE_PAIR_MAXIMUM_BATCH = 64, - CANDIDATE_PAIR_CPU_THREADS = 12, + /* ALGORITHMIC / RESOURCE: a participant needs one independent source, so + * the existing 64-item batch bound is also the portable CPU safety bound. + * Governor admission may select fewer CPUs without changing Candidate + * identity, ordering, or the number of items admitted for this sequence. */ + CANDIDATE_PAIR_CPU_MAX_SAFE = CANDIDATE_PAIR_MAXIMUM_BATCH, CANDIDATE_PAIR_WINDOW_PER_THREAD = 2, - CANDIDATE_PAIR_WINDOW_MAX = - CANDIDATE_PAIR_CPU_THREADS * CANDIDATE_PAIR_WINDOW_PER_THREAD, + CANDIDATE_PAIR_WINDOW_MAX = CANDIDATE_PAIR_MAXIMUM_BATCH, + CANDIDATE_PAIR_CHILD_STACK_BYTES = 4 * 1024 * 1024, CANDIDATE_PAIR_FIXED_MEMORY = 256 * 1024, - CANDIDATE_PAIR_MEMORY_PER_ITEM = 256 * 256, + /* RESOURCE: one admitted item can own one participant. Eight MiB covers + * its bounded 4 MiB pthread stack plus the Visual Index query buffers and + * private SQLite reader/cache allowance. This is sequence memory, not a + * dataset-size limit; Governor may reduce the batch on smaller hosts. */ + CANDIDATE_PAIR_MEMORY_PER_ITEM = 8 * 1024 * 1024, }; typedef struct { @@ -87,6 +95,8 @@ typedef struct { #ifdef LARDON3D_CANDIDATE_PAIR_TASK_TESTING static atomic_size_t test_started_participants; static atomic_size_t test_computed_work_items; +static atomic_size_t test_active_private_databases; +static atomic_size_t test_fail_thread_create_after = SIZE_MAX; void lardon3d_candidate_pair_task_test_reset_parallel_counters(void) { atomic_store(&test_started_participants, 0); @@ -100,8 +110,26 @@ size_t lardon3d_candidate_pair_task_test_started_participants(void) { size_t lardon3d_candidate_pair_task_test_computed_work_items(void) { return atomic_load(&test_computed_work_items); } + +size_t lardon3d_candidate_pair_task_test_active_private_databases(void) { + return atomic_load(&test_active_private_databases); +} + +void lardon3d_candidate_pair_task_test_fail_thread_create_after( + size_t successful_children) { + atomic_store(&test_fail_thread_create_after, successful_children); +} #endif +static void close_worker_database(CandidateComputeWorker *worker) { + if (!worker || !worker->database) return; + lardon3d_project_db_close(worker->database); + worker->database = NULL; +#ifdef LARDON3D_CANDIDATE_PAIR_TASK_TESTING + atomic_fetch_sub(&test_active_private_databases, 1); +#endif +} + static void *compute_worker(void *userdata) { CandidateComputeWorker *worker = userdata; #ifdef LARDON3D_CANDIDATE_PAIR_TASK_TESTING @@ -121,11 +149,27 @@ static void *compute_worker(void *userdata) { } #endif } - lardon3d_project_db_close(worker->database); - worker->database = NULL; + close_worker_database(worker); return NULL; } +static int create_compute_thread(pthread_t *thread, + const pthread_attr_t *attributes, + CandidateComputeWorker *worker, + size_t successful_children) { +#ifdef LARDON3D_CANDIDATE_PAIR_TASK_TESTING + /* This private seam fails before pthread_create, after an exact number of + * successful children. It deterministically exercises the production + * join/handle cleanup path without creating an unowned thread. */ + if (atomic_load(&test_fail_thread_create_after) == successful_children) { + return -1; + } +#else + (void)successful_children; +#endif + return pthread_create(thread, attributes, compute_worker, worker); +} + static unsigned int membership_progress(uint64_t completed, uint64_t total) { if (completed >= total && total != 0) return 99U; if (total == 0) return 0U; @@ -161,12 +205,29 @@ static bool compute_window(const Lardon3DCandidatePairTaskContext *context, unsigned int admitted_threads, Lardon3DCandidatePairComputation *computations, Lardon3DVisualIndexResult *results) { + if (!context || !query_options || !source_ids || !computations || !results || + source_count == 0 || source_count > CANDIDATE_PAIR_WINDOW_MAX || + admitted_threads == 0 || + admitted_threads > CANDIDATE_PAIR_CPU_MAX_SAFE) { + return false; + } unsigned int worker_count = admitted_threads; + /* source_count is validated against the 64-item bound before narrowing. */ if (worker_count > source_count) worker_count = (unsigned int)source_count; - CandidateComputeWorker workers[CANDIDATE_PAIR_CPU_THREADS]; - pthread_t threads[CANDIDATE_PAIR_CPU_THREADS - 1]; + CandidateComputeWorker workers[CANDIDATE_PAIR_CPU_MAX_SAFE]; + pthread_t threads[CANDIDATE_PAIR_CPU_MAX_SAFE - 1]; size_t created = 0; bool started = true; + pthread_attr_t attributes; + if (pthread_attr_init(&attributes) != 0) return false; + /* INVARIANT: child stack reservation is explicitly bounded and included + * in memory_bytes_per_item; relying on the host pthread default would make + * a 64-participant admission unaccountable and non-portable. */ + if (pthread_attr_setstacksize(&attributes, + CANDIDATE_PAIR_CHILD_STACK_BYTES) != 0) { + (void)pthread_attr_destroy(&attributes); + return false; + } for (size_t i = 0; i < source_count; ++i) { results[i] = LARDON3D_VISUAL_INDEX_IO_ERROR; } @@ -187,32 +248,59 @@ static bool compute_window(const Lardon3DCandidatePairTaskContext *context, &workers[i].database, error) != LARDON3D_PROJECT_DB_OK) { for (unsigned int opened = 0; opened < i; ++opened) { - lardon3d_project_db_close(workers[opened].database); - workers[opened].database = NULL; + close_worker_database(&workers[opened]); } + (void)pthread_attr_destroy(&attributes); return false; } +#ifdef LARDON3D_CANDIDATE_PAIR_TASK_TESTING + atomic_fetch_add(&test_active_private_databases, 1); +#endif } for (unsigned int i = 1; i < worker_count; ++i) { - if (pthread_create(&threads[created], NULL, compute_worker, &workers[i]) != 0) { + if (create_compute_thread(&threads[created], &attributes, &workers[i], + created) != 0) { started = false; break; } ++created; } + if (pthread_attr_destroy(&attributes) != 0) started = false; if (worker_count > 0) (void)compute_worker(&workers[0]); for (size_t i = 0; i < created; ++i) { if (pthread_join(threads[i], NULL) != 0) started = false; } for (unsigned int i = (unsigned int)created + 1; i < worker_count; ++i) { - if (workers[i].database) { - lardon3d_project_db_close(workers[i].database); - workers[i].database = NULL; - } + close_worker_database(&workers[i]); } return started; } +#ifdef LARDON3D_CANDIDATE_PAIR_TASK_TESTING +bool lardon3d_candidate_pair_task_test_compute_window( + const char *project_path, Lardon3DProjectDb *database, + uint64_t visual_index_id, + const Lardon3DVisualIndexQueryOptions *query_options, + const uint64_t *source_ids, size_t source_count, + unsigned int admitted_threads, + Lardon3DCandidatePairComputation *computations, + Lardon3DVisualIndexResult *results) { + if (!project_path || !database) return false; + Lardon3DCandidatePairTaskContext context = {0}; + int written = snprintf(context.project_path, sizeof(context.project_path), + "%s", project_path); + if (written <= 0 || (size_t)written >= sizeof(context.project_path) || + !lardon3d_project_db_copy_path(database, context.database_path)) { + return false; + } + /* The seam delegates to the production fan-out and owns no publication. + * Tests can therefore exercise all 64 participants without inventing Task + * progress, Candidate identities, or a second scheduling implementation. */ + return compute_window(&context, visual_index_id, query_options, source_ids, + source_count, admitted_threads, computations, results); +} +#endif + static bool run(Lardon3DTask *task, void *userdata) { Lardon3DCandidatePairTaskContext *context = userdata; @@ -245,7 +333,7 @@ static bool run(Lardon3DTask *task, void *userdata) { contract.batch_size < CANDIDATE_PAIR_MINIMUM_BATCH || contract.batch_size > CANDIDATE_PAIR_MAXIMUM_BATCH || contract.cpu_threads == 0 || - contract.cpu_threads > CANDIDATE_PAIR_CPU_THREADS) { + contract.cpu_threads > CANDIDATE_PAIR_CPU_MAX_SAFE) { return lardon3d_task_fail(task, "Contrat de lot Candidate Pair invalide."); } @@ -260,11 +348,12 @@ static bool run(Lardon3DTask *task, void *userdata) { while (processed_in_sequence < contract.batch_size) { if (!lardon3d_task_checkpoint(task)) return false; size_t remaining = contract.batch_size - processed_in_sequence; - size_t window_capacity = (size_t)contract.cpu_threads * - CANDIDATE_PAIR_WINDOW_PER_THREAD; - if (window_capacity > CANDIDATE_PAIR_WINDOW_MAX) { - window_capacity = CANDIDATE_PAIR_WINDOW_MAX; - } + size_t window_capacity = contract.cpu_threads > + CANDIDATE_PAIR_WINDOW_MAX / + CANDIDATE_PAIR_WINDOW_PER_THREAD + ? CANDIDATE_PAIR_WINDOW_MAX + : (size_t)contract.cpu_threads * + CANDIDATE_PAIR_WINDOW_PER_THREAD; if (window_capacity > remaining) window_capacity = remaining; uint64_t source_ids[CANDIDATE_PAIR_WINDOW_MAX]; size_t source_count = 0; @@ -298,10 +387,12 @@ static bool run(Lardon3DTask *task, void *userdata) { Lardon3DCandidatePairComputation computations[CANDIDATE_PAIR_WINDOW_MAX]; Lardon3DVisualIndexResult results[CANDIDATE_PAIR_WINDOW_MAX]; - /* The Queue callback is one admitted CPU participant. At most - * cpu_threads-1 child threads are created, every reader owns a - * private SQLite handle, and all are joined before reservation - * release or sequence_break. */ + /* CONTRACT: the Queue callback is one admitted CPU participant. + * At most cpu_threads-1 child threads are created, every useful + * participant owns one private SQLite handle, and all are joined + * and closed before reservation release or sequence_break. The + * per-item charge covers this bounded participant state and stack; + * batch=1 therefore cannot allocate idle CPU workers. */ if (!compute_window(context, context->parameters.visual_index_id, &query_options, source_ids, source_count, contract.cpu_threads, computations, results)) { @@ -472,12 +563,12 @@ Lardon3DTask *lardon3d_project_create_candidate_pair_generate_task( Lardon3DCandidatePairTaskContext *context = make_context(&runtime, ¶meters); if (!context) return NULL; - unsigned int desired_cpu_threads = CANDIDATE_PAIR_CPU_THREADS; + unsigned int desired_cpu_threads = CANDIDATE_PAIR_CPU_MAX_SAFE; #ifdef LARDON3D_CANDIDATE_PAIR_TASK_TESTING const char *test_threads = getenv("LARDON3D_TEST_CANDIDATE_PAIR_THREADS"); if (test_threads) { unsigned long parsed = strtoul(test_threads, NULL, 10); - if (parsed >= 1 && parsed <= CANDIDATE_PAIR_CPU_THREADS) { + if (parsed >= 1 && parsed <= CANDIDATE_PAIR_CPU_MAX_SAFE) { desired_cpu_threads = (unsigned int)parsed; } } diff --git a/src/feature_task.c b/src/feature_task.c index 7adc76a..8d08c18 100644 --- a/src/feature_task.c +++ b/src/feature_task.c @@ -1,5 +1,6 @@ #include #include +#include #include #include #include @@ -206,13 +207,21 @@ static bool run(Lardon3DTask *task, void *userdata) { struct timespec end = {0}; bool timing_known = clock_gettime(CLOCK_MONOTONIC, &begin) == 0; Lardon3DOpenCvTaskThreadControl threads; - if (!lardon3d_opencv_task_threads_begin(task, 12, &threads)) { + if (!lardon3d_opencv_task_threads_begin(task, INT_MAX, &threads)) { + /* begin may have changed OpenCV before verification failed. If its first + * rollback was transiently unsuccessful, this bounded second attempt must + * discharge the process-global ownership before the callback returns. */ + if (threads.restore_required && + !lardon3d_opencv_task_threads_end(&threads)) { + return lardon3d_task_fail(task, "Restauration OpenCV Features impossible."); + } return lardon3d_task_fail(task, "Contrat CPU OpenCV Features invalide."); } - /* Queue has one callback owner, so this process-wide OpenCV setting cannot - * race another Task. The immutable admission selects 1..12, and this guard - * applies exactly that count before extraction and restores it on every - * return without changing Feature identity. */ + /* EXTERNAL LIBRARY: OpenCV accepts a positive signed-int thread count. + * Queue has one callback owner, so this process-wide setting cannot race + * another Task. Governor clamps the portable INT_MAX capability to the host + * compute pool; the guard applies that immutable admission and restores it + * on every return without changing Feature identity. */ size_t durable_items = 0; bool result = run_body(task, userdata, &durable_items); if (!lardon3d_opencv_task_threads_end(&threads)) { @@ -331,9 +340,9 @@ lardon3d_project_create_feature_extract_task(Lardon3DAppState *state, uint64_t i .memory_bytes_per_item = 512ULL * 1024 * 1024, .minimum_batch_size = 1, .maximum_batch_size = 1, - /* Canonical durable maximum. Governor admission may select - * any validated OpenCV count in 1..12 for this execution. */ - .desired_cpu_threads = 12, + /* EXTERNAL LIBRARY: OpenCV's positive-int API is the safe + * ceiling; Governor supplies the portable host maximum. */ + .desired_cpu_threads = INT_MAX, .desired_io_slots = 1, .task_class = LARDON3D_RESOURCE_TASK_CPU}; Lardon3DTask *task = lardon3d_task_create_typed("Extraction de features", &estimate, diff --git a/src/geometric_verifier.cpp b/src/geometric_verifier.cpp index aa13d53..e8390a6 100644 --- a/src/geometric_verifier.cpp +++ b/src/geometric_verifier.cpp @@ -1,4 +1,5 @@ #include +#include #include #include #include @@ -11,6 +12,7 @@ #include #include #include +#include #include extern "C" { @@ -19,10 +21,23 @@ extern "C" { #include } +#include "geometric_verifier_internal.h" + +struct Lardon3DGeometricVerifierPrepared { + uint64_t match_result_id{}; + uint32_t verifier_version{}; + unsigned char fingerprint[32]{}; + Lardon3DGeometricVerificationStatus status{LARDON3D_GEOMETRIC_REJECTED}; + uint32_t inlier_count{}; + std::vector inlier_mask; + bool has_model{}; + double model[9]{}; +}; + namespace { #ifdef LARDON3D_GEOMETRIC_VERIFIER_TESTING -uint32_t estimator_calls; +std::atomic estimator_calls{0}; #endif void put_u32(unsigned char *bytes, uint32_t value) { @@ -65,15 +80,23 @@ bool read_points(const char *project_path, if (lardon3d_feature_reader_open(project_path, &set, &reader, &metadata) != LARDON3D_FEATURE_STORE_OK) return false; - points.resize(set.feature_count); - bool ok = metadata.feature_count == set.feature_count; - for (uint32_t start = 0; ok && start < set.feature_count; start += 256) { - size_t count = std::min(256, set.feature_count - start); - ok = lardon3d_feature_reader_keypoints(reader, start, points.data() + start, - count) == LARDON3D_FEATURE_STORE_OK; + try { + points.resize(set.feature_count); + bool ok = metadata.feature_count == set.feature_count; + for (uint32_t start = 0; ok && start < set.feature_count; start += 256) { + size_t count = std::min(256, set.feature_count - start); + ok = lardon3d_feature_reader_keypoints( + reader, start, points.data() + start, count) == + LARDON3D_FEATURE_STORE_OK; + } + lardon3d_feature_reader_close(reader); + return ok; + } catch (...) { + // The preparation call owns this reader. Allocation failure must not leak + // one descriptor per concurrent participant before the C ABI translates it. + lardon3d_feature_reader_close(reader); + throw; } - lardon3d_feature_reader_close(reader); - return ok; } bool estimate_fundamental(const std::vector &points_a, @@ -81,7 +104,7 @@ bool estimate_fundamental(const std::vector &points_a, const Lardon3DGeometricVerifierParameters *parameters, uint32_t seed, cv::Mat &model, cv::Mat &mask) { #ifdef LARDON3D_GEOMETRIC_VERIFIER_TESTING - ++estimator_calls; + (void)estimator_calls.fetch_add(1, std::memory_order_relaxed); const char *behavior = std::getenv("LARDON3D_TEST_GEOMETRIC_ESTIMATOR"); if (behavior && std::strcmp(behavior, "error") == 0) return false; @@ -171,11 +194,11 @@ bool has_minimal_support(const Lardon3DMatchFileEntry *entries, size_t count, #ifdef LARDON3D_GEOMETRIC_VERIFIER_TESTING extern "C" void lardon3d_geometric_verifier_test_reset_estimator_calls(void) { - estimator_calls = 0; + estimator_calls.store(0, std::memory_order_relaxed); } extern "C" uint32_t lardon3d_geometric_verifier_test_estimator_calls(void) { - return estimator_calls; + return estimator_calls.load(std::memory_order_relaxed); } extern "C" bool lardon3d_geometric_verifier_test_has_minimal_support( @@ -332,13 +355,36 @@ lardon3d_geometric_verifier_verify_and_publish_version( const char *project_path, Lardon3DProjectDb *db, uint64_t match_id, const Lardon3DGeometricVerifierParameters *p, uint32_t verifier_version, Lardon3DProjectDbGeometricVerificationResult *result, bool *reused) { - if (!project_path || !db || match_id == 0 || !p || !result || !reused || - !lardon3d_geometric_verifier_parameters_valid(p) || + Lardon3DGeometricVerifierPrepared *prepared = nullptr; + Lardon3DGeometricVerifierResult status = + lardon3d_geometric_verifier_internal_prepare_version( + project_path, db, match_id, p, verifier_version, &prepared, result, + reused); + if (status == LARDON3D_GEOMETRIC_VERIFIER_OK && prepared) { + status = lardon3d_geometric_verifier_internal_publish_prepared( + db, prepared, result, reused); + } + lardon3d_geometric_verifier_internal_prepared_destroy(prepared); + return status; +} + +extern "C" Lardon3DGeometricVerifierResult +lardon3d_geometric_verifier_internal_prepare_version( + const char *project_path, Lardon3DProjectDb *db, uint64_t match_id, + const Lardon3DGeometricVerifierParameters *p, uint32_t verifier_version, + Lardon3DGeometricVerifierPrepared **prepared, + Lardon3DProjectDbGeometricVerificationResult *result, bool *reused) { + if (prepared) + *prepared = nullptr; + if (reused) + *reused = false; + if (!project_path || !db || match_id == 0 || !p || !prepared || !result || + !reused || !lardon3d_geometric_verifier_parameters_valid(p) || (verifier_version != LARDON3D_GEOMETRIC_VERIFIER_VERSION_V1 && verifier_version != LARDON3D_GEOMETRIC_VERIFIER_VERSION_V2 && verifier_version != LARDON3D_GEOMETRIC_VERIFIER_VERSION_V3)) return LARDON3D_GEOMETRIC_VERIFIER_INVALID_ARGUMENT; - *reused = false; + std::memset(result, 0, sizeof(*result)); unsigned char fingerprint[32]; if (!lardon3d_geometric_verifier_fingerprint_for_version( p, verifier_version, fingerprint)) @@ -419,8 +465,7 @@ lardon3d_geometric_verifier_verify_and_publish_version( const bool acceptance_feasible = verifier_version != LARDON3D_GEOMETRIC_VERIFIER_VERSION_V3 || count >= p->min_inlier_count; - const bool sufficient_support = minimal_support && acceptance_feasible; - if (sufficient_support) { + if (minimal_support && acceptance_feasible) { uint32_t seed = lardon3d_geometric_verifier_seed( parent.match_asset_sha256, fingerprint); if (!estimate_fundamental(points_a, points_b, p, seed, model, mask)) @@ -454,42 +499,25 @@ lardon3d_geometric_verifier_verify_and_publish_version( if (mask.ptr()[index] != 0) return LARDON3D_GEOMETRIC_VERIFIER_ESTIMATOR_ERROR; } - bool accepted = !model.empty() && inliers >= p->min_inlier_count && - static_cast(inliers) / count >= p->min_inlier_ratio; - int64_t now = static_cast(std::time(nullptr)); - if (now < 0) - return LARDON3D_GEOMETRIC_VERIFIER_DATABASE_ERROR; -#ifdef LARDON3D_GEOMETRIC_VERIFIER_TESTING - const char *publication_failure = - std::getenv("LARDON3D_TEST_GEOMETRIC_PUBLICATION_FAILURE"); - if (publication_failure && std::strcmp(publication_failure, "1") == 0) - return LARDON3D_GEOMETRIC_VERIFIER_DATABASE_ERROR; -#endif - Lardon3DProjectDbResult created = - lardon3d_project_db_create_geometric_verification_result( - db, match_id, LARDON3D_GEOMETRIC_VERIFIER_FUNDAMENTAL, - verifier_version, fingerprint, - accepted ? LARDON3D_GEOMETRIC_VERIFIED - : LARDON3D_GEOMETRIC_REJECTED, - inliers, bitset.data(), bitset.size(), - accepted ? canonical : nullptr, now, result); - if (created == LARDON3D_PROJECT_DB_CONSTRAINT) { - Lardon3DProjectDbResult concurrent = - lardon3d_project_db_find_geometric_verification_result( - db, match_id, LARDON3D_GEOMETRIC_VERIFIER_FUNDAMENTAL, - verifier_version, fingerprint, result); - if (concurrent == LARDON3D_PROJECT_DB_OK) { - *reused = true; - return LARDON3D_GEOMETRIC_VERIFIER_OK; - } - return LARDON3D_GEOMETRIC_VERIFIER_DATABASE_ERROR; - } - return created == LARDON3D_PROJECT_DB_OK - ? LARDON3D_GEOMETRIC_VERIFIER_OK - : LARDON3D_GEOMETRIC_VERIFIER_DATABASE_ERROR; - // This catch set governs the complete public C entry point: unexpected - // OpenCV/malformed estimator behavior is execution failure and can never - // escape the ABI or be converted into a persisted scientific rejection. + const bool accepted = + !model.empty() && inliers >= p->min_inlier_count && + static_cast(inliers) / count >= p->min_inlier_ratio; + auto *stage = new Lardon3DGeometricVerifierPrepared; + stage->match_result_id = match_id; + stage->verifier_version = verifier_version; + std::memcpy(stage->fingerprint, fingerprint, sizeof(stage->fingerprint)); + stage->status = accepted ? LARDON3D_GEOMETRIC_VERIFIED + : LARDON3D_GEOMETRIC_REJECTED; + stage->inlier_count = inliers; + stage->inlier_mask = std::move(bitset); + stage->has_model = accepted; + if (accepted) + std::memcpy(stage->model, canonical, sizeof(stage->model)); + *prepared = stage; + return LARDON3D_GEOMETRIC_VERIFIER_OK; + // This catch set governs the complete preparation C entry point: unexpected + // OpenCV behavior is execution failure and can never escape the ABI or be + // converted into a persisted scientific rejection. } catch (const std::bad_alloc &) { return LARDON3D_GEOMETRIC_VERIFIER_OUT_OF_MEMORY; } catch (const cv::Exception &) { @@ -498,3 +526,52 @@ lardon3d_geometric_verifier_verify_and_publish_version( return LARDON3D_GEOMETRIC_VERIFIER_ESTIMATOR_ERROR; } } + +extern "C" Lardon3DGeometricVerifierResult +lardon3d_geometric_verifier_internal_publish_prepared( + Lardon3DProjectDb *db, + const Lardon3DGeometricVerifierPrepared *prepared, + Lardon3DProjectDbGeometricVerificationResult *result, bool *reused) { + if (reused) + *reused = false; + if (!db || !prepared || !result || !reused) + return LARDON3D_GEOMETRIC_VERIFIER_INVALID_ARGUMENT; + std::memset(result, 0, sizeof(*result)); + int64_t now = static_cast(std::time(nullptr)); + if (now < 0) + return LARDON3D_GEOMETRIC_VERIFIER_DATABASE_ERROR; +#ifdef LARDON3D_GEOMETRIC_VERIFIER_TESTING + const char *publication_failure = + std::getenv("LARDON3D_TEST_GEOMETRIC_PUBLICATION_FAILURE"); + if (publication_failure && std::strcmp(publication_failure, "1") == 0) + return LARDON3D_GEOMETRIC_VERIFIER_DATABASE_ERROR; +#endif + Lardon3DProjectDbResult created = + lardon3d_project_db_create_geometric_verification_result( + db, prepared->match_result_id, + LARDON3D_GEOMETRIC_VERIFIER_FUNDAMENTAL, + prepared->verifier_version, prepared->fingerprint, prepared->status, + prepared->inlier_count, prepared->inlier_mask.data(), + prepared->inlier_mask.size(), + prepared->has_model ? prepared->model : nullptr, now, result); + if (created == LARDON3D_PROJECT_DB_CONSTRAINT) { + Lardon3DProjectDbResult concurrent = + lardon3d_project_db_find_geometric_verification_result( + db, prepared->match_result_id, + LARDON3D_GEOMETRIC_VERIFIER_FUNDAMENTAL, + prepared->verifier_version, prepared->fingerprint, result); + if (concurrent == LARDON3D_PROJECT_DB_OK) { + *reused = true; + return LARDON3D_GEOMETRIC_VERIFIER_OK; + } + return LARDON3D_GEOMETRIC_VERIFIER_DATABASE_ERROR; + } + return created == LARDON3D_PROJECT_DB_OK + ? LARDON3D_GEOMETRIC_VERIFIER_OK + : LARDON3D_GEOMETRIC_VERIFIER_DATABASE_ERROR; +} + +extern "C" void lardon3d_geometric_verifier_internal_prepared_destroy( + Lardon3DGeometricVerifierPrepared *prepared) { + delete prepared; +} diff --git a/src/geometric_verifier_internal.h b/src/geometric_verifier_internal.h new file mode 100644 index 0000000..a6e0b54 --- /dev/null +++ b/src/geometric_verifier_internal.h @@ -0,0 +1,52 @@ +#ifndef LARDON3D_GEOMETRIC_VERIFIER_INTERNAL_H +#define LARDON3D_GEOMETRIC_VERIFIER_INTERNAL_H + +#include +#include + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +typedef struct Lardon3DGeometricVerifierPrepared + Lardon3DGeometricVerifierPrepared; + +/* Worker-owned preparation validates every immutable input and computes the + * exact frozen v1/v2/v3 scientific payload, but never mutates Project DB. If + * the exact identity already exists, `prepared` remains NULL and the + * caller-owned `result` is filled with that durable row. Otherwise the caller + * owns one bounded opaque preparation until destroy, including on publication + * failure. Independent preparations may run concurrently against the shared, + * internally serialized Project DB handle. Every Match/Feature file handle is + * owned by one preparation call and closed before that call returns. */ +Lardon3DGeometricVerifierResult +lardon3d_geometric_verifier_internal_prepare_version( + const char *project_path, Lardon3DProjectDb *database, + uint64_t match_result_id, + const Lardon3DGeometricVerifierParameters *parameters, + uint32_t verifier_version, Lardon3DGeometricVerifierPrepared **prepared, + Lardon3DProjectDbGeometricVerificationResult *result, bool *reused); + +/* The sequence owner alone publishes prepared payloads in canonical parent + * order. Retry is exact: a concurrent/existing identical row is returned as + * reuse, while no different identity is inferred. The function does not + * consume `prepared`; destroy remains mandatory after every return. */ +Lardon3DGeometricVerifierResult +lardon3d_geometric_verifier_internal_publish_prepared( + Lardon3DProjectDb *database, + const Lardon3DGeometricVerifierPrepared *prepared, + Lardon3DProjectDbGeometricVerificationResult *result, bool *reused); + +/* Releases one preparation. NULL is accepted so a batch cleanup sweep remains + * safe after clearing owned slots; every non-NULL object must be released + * exactly once on partial thread creation, cancellation, success, or failure. */ +void lardon3d_geometric_verifier_internal_prepared_destroy( + Lardon3DGeometricVerifierPrepared *prepared); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/src/geometric_verifier_task.c b/src/geometric_verifier_task.c index 7238411..647daf6 100644 --- a/src/geometric_verifier_task.c +++ b/src/geometric_verifier_task.c @@ -1,4 +1,6 @@ +#include #include +#include #include #include #include @@ -9,11 +11,18 @@ #include #include +#include "geometric_verifier_internal.h" +#include "task_internal.h" + enum { GEOMETRIC_VERIFIER_PAGE_CAPACITY = LARDON3D_GEOMETRIC_VERIFIER_TASK_MAXIMUM_BATCH + 1, - GEOMETRIC_VERIFIER_MEMORY_BYTES = 4 * 1024 * 1024, - GEOMETRIC_VERIFIER_CPU_THREADS = 1, + /* Each participant owns at most the established conservative 4 MiB core + * allowance plus one explicitly bounded 4 MiB child stack. Charging 8 MiB + * per batch item safely covers the owner too and remains an operational + * admission bound, never a Match-Result dataset limit. */ + GEOMETRIC_VERIFIER_MEMORY_BYTES_PER_ITEM = 8 * 1024 * 1024, + GEOMETRIC_VERIFIER_CHILD_STACK_BYTES = 4 * 1024 * 1024, }; typedef struct { @@ -24,6 +33,119 @@ typedef struct { uint32_t verifier_version; } Lardon3DGeometricVerifierTaskContext; +typedef struct { + Lardon3DGeometricVerifierResult status; + Lardon3DGeometricVerifierPrepared *prepared; + Lardon3DProjectDbGeometricVerificationResult result; + bool reused; + bool applicable; +} Lardon3DGeometricVerifierJob; + +typedef struct { + Lardon3DGeometricVerifierTaskContext *context; + const Lardon3DProjectDbMatchResult *parents; + Lardon3DGeometricVerifierJob *jobs; + Lardon3DGeometricVerifierParameters parameters; + size_t count; + atomic_size_t next; + atomic_bool stop; +} Lardon3DGeometricVerifierBatch; + +#ifdef LARDON3D_GEOMETRIC_VERIFIER_TASK_TESTING +static atomic_uint observed_cpu_contracts; +typedef struct { + pthread_mutex_t mutex; + pthread_cond_t condition; + bool armed; + bool reached; + bool released; +} Lardon3DGeometricVerifierTestBarrier; + +#define GEOMETRIC_VERIFIER_TEST_BARRIER_INITIALIZER \ + { PTHREAD_MUTEX_INITIALIZER, PTHREAD_COND_INITIALIZER, false, false, false } + +static Lardon3DGeometricVerifierTestBarrier sequence_barrier = + GEOMETRIC_VERIFIER_TEST_BARRIER_INITIALIZER; +static Lardon3DGeometricVerifierTestBarrier prepublication_barrier = + GEOMETRIC_VERIFIER_TEST_BARRIER_INITIALIZER; + +void lardon3d_geometric_verifier_task_test_reset_cpu_contracts(void) { + atomic_store_explicit(&observed_cpu_contracts, 0, memory_order_relaxed); +} + +unsigned int lardon3d_geometric_verifier_task_test_cpu_contracts(void) { + return atomic_load_explicit(&observed_cpu_contracts, memory_order_relaxed); +} + +static void test_barrier_arm(Lardon3DGeometricVerifierTestBarrier *barrier) { + (void)pthread_mutex_lock(&barrier->mutex); + barrier->armed = true; + barrier->reached = false; + barrier->released = false; + (void)pthread_mutex_unlock(&barrier->mutex); +} + +static bool test_barrier_wait(Lardon3DGeometricVerifierTestBarrier *barrier) { + struct timespec deadline; + if (clock_gettime(CLOCK_REALTIME, &deadline) != 0) { + return false; + } + deadline.tv_sec += 5; + (void)pthread_mutex_lock(&barrier->mutex); + bool ok = true; + while (!barrier->reached && ok) { + ok = pthread_cond_timedwait(&barrier->condition, &barrier->mutex, + &deadline) == 0; + } + (void)pthread_mutex_unlock(&barrier->mutex); + return ok; +} + +static void test_barrier_release(Lardon3DGeometricVerifierTestBarrier *barrier) { + (void)pthread_mutex_lock(&barrier->mutex); + barrier->released = true; + (void)pthread_cond_broadcast(&barrier->condition); + (void)pthread_mutex_unlock(&barrier->mutex); +} + +static void test_barrier_reach(Lardon3DGeometricVerifierTestBarrier *barrier) { + (void)pthread_mutex_lock(&barrier->mutex); + if (barrier->armed) { + barrier->reached = true; + (void)pthread_cond_broadcast(&barrier->condition); + while (!barrier->released) { + (void)pthread_cond_wait(&barrier->condition, &barrier->mutex); + } + barrier->armed = false; + } + (void)pthread_mutex_unlock(&barrier->mutex); +} + +void lardon3d_geometric_verifier_task_test_arm_sequence_barrier(void) { + test_barrier_arm(&sequence_barrier); +} + +bool lardon3d_geometric_verifier_task_test_wait_sequence_barrier(void) { + return test_barrier_wait(&sequence_barrier); +} + +void lardon3d_geometric_verifier_task_test_release_sequence_barrier(void) { + test_barrier_release(&sequence_barrier); +} + +void lardon3d_geometric_verifier_task_test_arm_prepublication_barrier(void) { + test_barrier_arm(&prepublication_barrier); +} + +bool lardon3d_geometric_verifier_task_test_wait_prepublication_barrier(void) { + return test_barrier_wait(&prepublication_barrier); +} + +void lardon3d_geometric_verifier_task_test_release_prepublication_barrier(void) { + test_barrier_release(&prepublication_barrier); +} +#endif + static void destroy_context(void *userdata) { free(userdata); } static Lardon3DGeometricVerifierParameters @@ -74,36 +196,202 @@ static uint64_t elapsed_ns(struct timespec begin, struct timespec end) { return seconds * 1000000000ULL + (uint64_t)nanoseconds; } -static bool process_parent(Lardon3DTask *task, - Lardon3DGeometricVerifierTaskContext *context, - const Lardon3DProjectDbMatchResult *parent) { - if (!lardon3d_task_checkpoint(task)) { - return false; - } - if (parent->result_status != LARDON3D_MATCH_RESULT_STATUS_MATCHED || - parent->match_count == 0) { - return true; - } - Lardon3DGeometricVerifierParameters parameters = - parameters_from_durable(&context->durable); - Lardon3DProjectDbGeometricVerificationResult result; - bool reused = false; - if (lardon3d_geometric_verifier_verify_and_publish_version( - context->project_path, context->database, parent->match_result_id, - ¶meters, context->verifier_version, &result, - &reused) != LARDON3D_GEOMETRIC_VERIFIER_OK) { - // Failure transition success does not imply callback success: once an - // estimate fails for this parent, processing must stop before checkpointing. - (void)lardon3d_task_fail(task, "Vérification géométrique impossible."); - return false; +static void *prepare_worker(void *userdata) { + Lardon3DGeometricVerifierBatch *batch = userdata; + for (;;) { + if (atomic_load_explicit(&batch->stop, memory_order_relaxed)) { + return NULL; + } + size_t index = + atomic_fetch_add_explicit(&batch->next, 1, memory_order_relaxed); + if (index >= batch->count) { + return NULL; + } + const Lardon3DProjectDbMatchResult *parent = &batch->parents[index]; + Lardon3DGeometricVerifierJob *job = &batch->jobs[index]; + job->status = LARDON3D_GEOMETRIC_VERIFIER_OK; + if (parent->result_status != LARDON3D_MATCH_RESULT_STATUS_MATCHED || + parent->match_count == 0) { + continue; + } + job->applicable = true; + job->status = lardon3d_geometric_verifier_internal_prepare_version( + batch->context->project_path, batch->context->database, + parent->match_result_id, &batch->parameters, + batch->context->verifier_version, &job->prepared, &job->result, + &job->reused); } +} + #ifdef LARDON3D_GEOMETRIC_VERIFIER_TASK_TESTING - const char *pause = getenv("LARDON3D_TEST_GEOMETRIC_PAUSE_AFTER_PUBLICATION"); - if (pause && strcmp(pause, "1") == 0) { - (void)lardon3d_task_pause(task); - return lardon3d_task_checkpoint(task); +static bool inject_thread_create_failure(size_t child_index) { + const char *value = getenv("LARDON3D_TEST_GEOMETRIC_THREAD_FAIL_AFTER"); + if (!value || !value[0]) { + return false; + } + char *end = NULL; + unsigned long parsed = strtoul(value, &end, 10); + return end && *end == '\0' && parsed == child_index; +} +#endif + +static void destroy_jobs(Lardon3DGeometricVerifierJob *jobs, size_t count) { + for (size_t index = 0; index < count; ++index) { + lardon3d_geometric_verifier_internal_prepared_destroy( + jobs[index].prepared); + jobs[index].prepared = NULL; + } +} + +static bool prepare_batch(Lardon3DGeometricVerifierTaskContext *context, + const Lardon3DProjectDbMatchResult *parents, + size_t count, unsigned int cpu_threads, + Lardon3DGeometricVerifierJob *jobs) { + memset(jobs, 0, sizeof(*jobs) * count); + Lardon3DGeometricVerifierBatch batch = { + .context = context, + .parents = parents, + .jobs = jobs, + .parameters = parameters_from_durable(&context->durable), + .count = count, + }; + atomic_init(&batch.next, 0); + atomic_init(&batch.stop, false); + size_t participants = cpu_threads < count ? cpu_threads : count; +#ifdef LARDON3D_GEOMETRIC_VERIFIER_TASK_TESTING + const char *forced = getenv("LARDON3D_TEST_GEOMETRIC_FORCE_PARTICIPANTS"); + if (forced && forced[0]) { + char *end = NULL; + unsigned long parsed = strtoul(forced, &end, 10); + /* This seam exists only to reach partial pthread_create cleanup while the + * production Governor is deliberately in CPU slow-start. It is never an + * equivalence or performance input and cannot exceed the production-safe + * participant/window bounds. */ + if (end && *end == '\0' && parsed >= 1 && + parsed <= + LARDON3D_GEOMETRIC_VERIFIER_TASK_MAXIMUM_SAFE_CPU_THREADS) { + participants = (size_t)parsed; + } } #endif + size_t child_count = participants > 0 ? participants - 1 : 0; + pthread_t children + [LARDON3D_GEOMETRIC_VERIFIER_TASK_MAXIMUM_SAFE_CPU_THREADS - 1]; + pthread_attr_t attributes; + bool attributes_initialized = false; + bool attributes_ready = child_count == 0; + if (child_count > 0 && pthread_attr_init(&attributes) == 0) { + attributes_initialized = true; + attributes_ready = pthread_attr_setstacksize( + &attributes, + GEOMETRIC_VERIFIER_CHILD_STACK_BYTES) == 0; + } + size_t created = 0; + bool creation_ok = attributes_ready; + while (creation_ok && created < child_count) { +#ifdef LARDON3D_GEOMETRIC_VERIFIER_TASK_TESTING + if (inject_thread_create_failure(created)) { + /* Deterministically let already-created participants and the owner claim + * the fresh batch before injecting failure. This exercises destruction of + * real opaque stages after a partial pthread_create sequence; it never + * changes production scheduling or publication. */ + (void)prepare_worker(&batch); + creation_ok = false; + break; + } +#endif + if (pthread_create(&children[created], &attributes, prepare_worker, + &batch) != 0) { + creation_ok = false; + break; + } + ++created; + } + if (creation_ok) { + (void)prepare_worker(&batch); + } else { + /* A partial-create failure cannot hand stack storage back while a child + * still uses it. Stop new claims, join every created child, then let the + * owner destroy any completed opaque stages. */ + atomic_store_explicit(&batch.stop, true, memory_order_relaxed); + } + bool joined = true; + for (size_t index = 0; index < created; ++index) { + joined = pthread_join(children[index], NULL) == 0 && joined; + } + if (attributes_initialized) { + (void)pthread_attr_destroy(&attributes); + } + return creation_ok && joined; +} + +static bool preflight_batch(Lardon3DTask *task, + Lardon3DGeometricVerifierJob *jobs, size_t count) { + for (size_t index = 0; index < count; ++index) { + const Lardon3DGeometricVerifierJob *job = &jobs[index]; + bool ownership_valid = + !job->applicable + ? !job->prepared && !job->reused + : (job->reused ? !job->prepared : job->prepared != NULL); + if (job->status != LARDON3D_GEOMETRIC_VERIFIER_OK || !ownership_valid) { + /* CONTRACT: preparation is the all-or-nothing half of a GV batch. The + * callback owner must discover every participant failure before it may + * publish index zero or advance the contiguous business cursor. */ + destroy_jobs(jobs, count); + (void)lardon3d_task_fail(task, "Vérification géométrique impossible."); + return false; + } + } + return true; +} + +static bool publish_batch(Lardon3DTask *task, + Lardon3DGeometricVerifierTaskContext *context, + const Lardon3DProjectDbMatchResult *parents, + Lardon3DGeometricVerifierJob *jobs, size_t count, + uint64_t *processed, size_t *durable_items) { + /* INVARIANT: preflight has proved every joined slot. From here through the + * typed cursor + generic checkpoint, this dispatched batch is one engaged + * unit. Pause/cancel is deliberately not observed between ordered owner-only + * publications; the next control boundary follows the durable checkpoint. */ + for (size_t index = 0; index < count; ++index) { + Lardon3DGeometricVerifierJob *job = &jobs[index]; + if (job->applicable && job->prepared) { + job->status = lardon3d_geometric_verifier_internal_publish_prepared( + context->database, job->prepared, &job->result, &job->reused); + lardon3d_geometric_verifier_internal_prepared_destroy(job->prepared); + job->prepared = NULL; + if (job->status != LARDON3D_GEOMETRIC_VERIFIER_OK) { + destroy_jobs(jobs, count); + (void)lardon3d_task_fail(task, + "Publication géométrique impossible."); + return false; + } + if (!job->reused) { + ++*durable_items; + } + } +#ifdef LARDON3D_GEOMETRIC_VERIFIER_TASK_TESTING + const char *pause = + getenv("LARDON3D_TEST_GEOMETRIC_PAUSE_AFTER_PUBLICATION"); + if (job->applicable && pause && strcmp(pause, "1") == 0) { + /* This testing-only crash seam intentionally interrupts the normal + * engaged-unit rule to preserve proof of the frozen residual restart + * boundary: publication may be durable while the business cursor still + * names the prior parent. Production never observes controls here. */ + (void)lardon3d_task_pause(task); + destroy_jobs(jobs, count); + (void)lardon3d_task_checkpoint(task); + return false; + } +#endif + // Publication/reuse must be durable before the business cursor advances. + // Restart may repeat an already published parent, but exact identity + // reuse makes that retry converge without duplicating or guessing GVRs. + context->durable.after_match_result_id = parents[index].match_result_id; + ++*processed; + } + destroy_jobs(jobs, count); return true; } @@ -132,11 +420,19 @@ static bool run(Lardon3DTask *task, void *userdata) { } Lardon3DTaskExecutionContract contract; if (!lardon3d_task_execution_contract(task, &contract) || + contract.cpu_threads == 0 || + contract.cpu_threads > + LARDON3D_GEOMETRIC_VERIFIER_TASK_VALIDATED_USEFUL_CPU_THREADS || contract.batch_size < LARDON3D_GEOMETRIC_VERIFIER_TASK_MINIMUM_BATCH || contract.batch_size > LARDON3D_GEOMETRIC_VERIFIER_TASK_MAXIMUM_BATCH) { - return lardon3d_task_fail(task, - "Contrat de lot Geometric Verifier invalide."); + return lardon3d_task_fail( + task, "Contrat CPU/lot Geometric Verifier invalide."); } +#ifdef LARDON3D_GEOMETRIC_VERIFIER_TASK_TESTING + (void)atomic_fetch_or_explicit(&observed_cpu_contracts, + 1U << contract.cpu_threads, + memory_order_relaxed); +#endif Lardon3DProjectDbMatchResult page[GEOMETRIC_VERIFIER_PAGE_CAPACITY]; size_t count = 0; size_t capacity = contract.batch_size + 1; @@ -154,29 +450,63 @@ static bool run(Lardon3DTask *task, void *userdata) { struct timespec begin; struct timespec end; (void)clock_gettime(CLOCK_MONOTONIC, &begin); - for (size_t index = 0; index < batch_count; ++index) { - if (!process_parent(task, context, &page[index])) { - return false; - } - // Publication/reuse must be durable before the business cursor advances. - // Restart may repeat an already published parent, but exact identity - // reuse makes that retry converge without duplicating or guessing GVRs. - context->durable.after_match_result_id = page[index].match_result_id; - ++processed; + Lardon3DGeometricVerifierJob jobs + [LARDON3D_GEOMETRIC_VERIFIER_TASK_MAXIMUM_BATCH]; + size_t durable_items = 0; + /* Queue owns this one callback and Governor owns the admitted width. + * Children only prepare independent immutable Match Results; the callback + * owner joins them all, publishes in parent order, and advances one + * contiguous durable cursor before any sequence_break releases admission. */ + if (!prepare_batch(context, page, batch_count, contract.cpu_threads, + jobs)) { + destroy_jobs(jobs, batch_count); + return lardon3d_task_fail(task, + "Création des participants GV impossible."); + } + if (!preflight_batch(task, jobs, batch_count)) { + return false; + } +#ifdef LARDON3D_GEOMETRIC_VERIFIER_TASK_TESTING + /* This barrier is after the all-slot preflight and before index zero, so + * tests can issue control requests without timing luck or weakening the + * production engaged-batch boundary. */ + test_barrier_reach(&prepublication_barrier); +#endif + if (!publish_batch(task, context, page, jobs, batch_count, &processed, + &durable_items)) { + return false; } - (void)clock_gettime(CLOCK_MONOTONIC, &end); - (void)lardon3d_resource_governor_record_batch( - context->governor, LARDON3D_RESOURCE_TASK_CPU, batch_count, - elapsed_ns(begin, end), 0); bool exhausted = count <= contract.batch_size; if (!checkpoint_batch(task, context, exhausted ? 100U : 99U, processed)) { return lardon3d_task_fail(task, "Checkpoint Geometric Verifier impossible."); } + (void)clock_gettime(CLOCK_MONOTONIC, &end); + uint64_t duration_ns = elapsed_ns(begin, end); + /* Feedback covers scientific preparation, owner-only publication, cursor + * advancement, and the durable batch checkpoint—not CPU utilization. */ + (void)lardon3d_resource_governor_record_batch( + context->governor, LARDON3D_RESOURCE_TASK_CPU, durable_items, + duration_ns, 0); + (void)lardon3d_task_internal_record_sequence(task, duration_ns, + durable_items); if (exhausted) { + /* The final dispatched batch needs the same post-checkpoint control + * boundary as a non-final sequence. Otherwise a pause arriving during + * preparation/publication could be silently converted to COMPLETED; a + * cancel must likewise win only after the engaged prefix is durable. */ + if (!lardon3d_task_checkpoint(task)) { + return false; + } return lardon3d_task_set_progress(task, 100, "Vérification géométrique terminée."); } +#ifdef LARDON3D_GEOMETRIC_VERIFIER_TASK_TESTING + /* Test acknowledgement occurs only after the cursor checkpoint and before + * sequence_break releases admission. Policy changes made at the barrier + * therefore affect the real next Governor decision. */ + test_barrier_reach(&sequence_barrier); +#endif Lardon3DResourceReservation *reservation = NULL; if (!lardon3d_task_sequence_break(task, context->governor, &reservation, &contract)) { @@ -199,8 +529,8 @@ make_context(const Lardon3DTaskReconstructionContext *runtime, if (!lardon3d_geometric_verifier_parameters_valid(¶meters)) { return NULL; } - // Project DB v22 intentionally has no duplicate verifier-version column in - // this task payload. The exact versioned fingerprint unambiguously recovers + // Project DB intentionally has no duplicate verifier-version column in this + // task payload. The exact versioned fingerprint unambiguously recovers // historical v1/v2 or current v3 while keeping all identities immutable. for (uint32_t candidate = LARDON3D_GEOMETRIC_VERIFIER_VERSION_V1; candidate <= LARDON3D_GEOMETRIC_VERIFIER_VERSION_V3; ++candidate) { @@ -304,10 +634,15 @@ Lardon3DTask *lardon3d_project_create_geometric_verifier_task( return NULL; } const Lardon3DResourceEstimate estimate = { - .memory_fixed_bytes = GEOMETRIC_VERIFIER_MEMORY_BYTES, + .memory_bytes_per_item = GEOMETRIC_VERIFIER_MEMORY_BYTES_PER_ITEM, .minimum_batch_size = LARDON3D_GEOMETRIC_VERIFIER_TASK_MINIMUM_BATCH, .maximum_batch_size = LARDON3D_GEOMETRIC_VERIFIER_TASK_MAXIMUM_BATCH, - .desired_cpu_threads = GEOMETRIC_VERIFIER_CPU_THREADS, + /* CPU8 is the largest width whose complete preparation + ordered publish + * + checkpoint rate improved materially. CPU12 was exact and safe but + * added only 2.68% over CPU8, below Governor's 5% acceptance threshold; + * the independently safe participant/window bound remains 16. */ + .desired_cpu_threads = + LARDON3D_GEOMETRIC_VERIFIER_TASK_VALIDATED_USEFUL_CPU_THREADS, .desired_io_slots = 1, .task_class = LARDON3D_RESOURCE_TASK_CPU, }; @@ -331,6 +666,9 @@ bool lardon3d_project_enqueue_geometric_verifier_task( Lardon3DAppState *state, const Lardon3DGeometricVerifierTaskConfiguration *configuration, uint64_t *task_id) { + if (task_id) { + *task_id = 0; + } if (!state || !state->task_queue) { return false; } diff --git a/src/hardware_profile.c b/src/hardware_profile.c index 86ea67a..0db0c2a 100644 --- a/src/hardware_profile.c +++ b/src/hardware_profile.c @@ -106,7 +106,8 @@ shared_memory_evidence( /* WHY: amdgpu exposes a small stolen/dedicated VRAM aperture even for an * integrated GPU. Treating that positive number as separate free memory * undercharges host RAM. A low-VRAM uncertain device may conservatively - * become UMA; the reverse error could violate the 3 GiB/2 GiB host floors. */ + * become UMA; the reverse error could violate the 3 GiB hard reserve and + * the 3--4 GiB host-caution zone. */ return conservatively_small || system_scale_gtt; } diff --git a/src/image_catalog_persistent.c b/src/image_catalog_persistent.c index ce82d03..ee293dd 100644 --- a/src/image_catalog_persistent.c +++ b/src/image_catalog_persistent.c @@ -12,6 +12,8 @@ #include +#include "image_catalog_persistent_internal.h" + enum { COPY_BUFFER_SIZE = 64 * 1024 }; typedef enum { @@ -230,16 +232,21 @@ lardon3d_image_catalog_publish_asset_file(Lardon3DAppState *state, return LARDON3D_IMAGE_CATALOG_ASSET_PUBLISHED; } +#if defined(LARDON3D_IMAGE_CATALOG_PERSISTENT_TESTING) Lardon3DImageCatalogAssetPublishResult lardon3d_image_catalog_test_copy_hash(int input, int output) { unsigned char hash[LARDON3D_PROJECT_DB_SHA256_SIZE]; uint64_t size = 0; CopyResult result = hash_stream(input, output, hash, &size); - return result == COPY_OK ? LARDON3D_IMAGE_CATALOG_ASSET_PUBLISHED - : result == COPY_DESTINATION_ERROR ? LARDON3D_IMAGE_CATALOG_ASSET_PUBLICATION_ERROR - : LARDON3D_IMAGE_CATALOG_ASSET_SOURCE_ERROR; + if (result == COPY_OK) { + return LARDON3D_IMAGE_CATALOG_ASSET_PUBLISHED; + } + return result == COPY_DESTINATION_ERROR + ? LARDON3D_IMAGE_CATALOG_ASSET_PUBLICATION_ERROR + : LARDON3D_IMAGE_CATALOG_ASSET_SOURCE_ERROR; } +#endif bool lardon3d_image_catalog_create_scanset(Lardon3DAppState *state, diff --git a/src/image_catalog_persistent_internal.h b/src/image_catalog_persistent_internal.h new file mode 100644 index 0000000..6ff63b9 --- /dev/null +++ b/src/image_catalog_persistent_internal.h @@ -0,0 +1,19 @@ +#ifndef LARDON3D_IMAGE_CATALOG_PERSISTENT_INTERNAL_H +#define LARDON3D_IMAGE_CATALOG_PERSISTENT_INTERNAL_H + +#include + +#if defined(LARDON3D_IMAGE_CATALOG_PERSISTENT_TESTING) + +/* TEST CONTRACT: this direct descriptor seam exists only in + * test-persistent-image-catalog so its /dev/full fixture can distinguish a + * destination write failure. It owns neither descriptor and is absent from + * every production object and public ABI. */ +Lardon3DImageCatalogAssetPublishResult lardon3d_image_catalog_test_copy_hash( + int input, + int output +); + +#endif + +#endif diff --git a/src/layout.c b/src/layout.c index 9c644cd..5ac2bea 100644 --- a/src/layout.c +++ b/src/layout.c @@ -2,25 +2,59 @@ #include #include -#include #include #include +#include -enum { - MINIMUM_ROWS = 20, - MINIMUM_COLUMNS = 72, -}; +static attr_t +semantic_attribute( + const Lardon3DTuiPalette *palette, + Lardon3DTuiSemantic semantic +) +{ + if (!palette || semantic < LARDON3D_TUI_SEMANTIC_NORMAL + || semantic >= LARDON3D_TUI_SEMANTIC_COUNT) { + return A_NORMAL; + } + attr_t attribute = A_NORMAL; + if (palette->color_enabled && palette->color_pair[semantic] > 0) { + attribute |= COLOR_PAIR(palette->color_pair[semantic]); + } + if ((palette->attributes[semantic] & LARDON3D_TUI_STYLE_BOLD) != 0) { + attribute |= A_BOLD; + } + if ((palette->attributes[semantic] & LARDON3D_TUI_STYLE_DIM) != 0) { + attribute |= A_DIM; + } + return attribute; +} + +static void +draw_text_style( + int row, + int column, + int available, + const char *text, + Lardon3DTuiSemantic semantic, + const Lardon3DTuiPalette *palette +) +{ + if (available <= 0 || row < 0 || column < 0 || !text) { + return; + } + size_t length = strnlen(text, LARDON3D_TUI_TEXT_CAPACITY * 4U); + int displayed = length < (size_t)available ? (int)length : available; + attr_t attribute = semantic_attribute(palette, semantic); + (void)attron(attribute); + (void)mvaddnstr(row, column, text, displayed); + (void)attroff(attribute); +} static void draw_text(int row, int column, int available, const char *text) { - if (available <= 0) { - return; - } - - size_t length = strlen(text); - int displayed = length < (size_t)available ? (int)length : available; - (void)mvaddnstr(row, column, text, displayed); + draw_text_style(row, column, available, text, + LARDON3D_TUI_SEMANTIC_NORMAL, NULL); } static void @@ -31,73 +65,77 @@ draw_too_small(int rows, int columns) int column = columns > (int)(sizeof(message) - 1) ? (columns - (int)(sizeof(message) - 1)) / 2 : 0; - draw_text(row, column, columns, message); } static void draw_frame(int rows, int columns) { - int journal_row = rows - 7; - int footer_row = rows - 3; - + int status_separator = rows - 4; (void)box(stdscr, 0, 0); (void)mvhline(2, 1, ACS_HLINE, columns - 2); - (void)mvhline(journal_row, 1, ACS_HLINE, columns - 2); - (void)mvhline(footer_row, 1, ACS_HLINE, columns - 2); + (void)mvhline(status_separator, 1, ACS_HLINE, columns - 2); (void)mvaddch(2, 0, ACS_LTEE); (void)mvaddch(2, columns - 1, ACS_RTEE); - (void)mvaddch(journal_row, 0, ACS_LTEE); - (void)mvaddch(journal_row, columns - 1, ACS_RTEE); - (void)mvaddch(footer_row, 0, ACS_LTEE); - (void)mvaddch(footer_row, columns - 1, ACS_RTEE); + (void)mvaddch(status_separator, 0, ACS_LTEE); + (void)mvaddch(status_separator, columns - 1, ACS_RTEE); } -static void -screen_texts( - Lardon3DScreen screen, - const char **title, - const char **content, - const char **footer -) +static const char * +screen_title(Lardon3DScreen screen) { - *footer = "ESC Accueil F1 Aide F2 Projets F3 Import F4 Viewer F5 Tâches F6 Ressources Q"; - switch (screen) { case LARDON3D_SCREEN_PROJECTS: - *title = "Projets"; - *content = "Gestion des projets"; - *footer = "N Nouveau O Ouvrir C Fermer ESC Accueil Q Quit"; - break; + return "Projets"; case LARDON3D_SCREEN_IMPORT: - *title = "Import"; - *content = "Import des images"; - *footer = "I Importer R Recharger S Tri / Filtre X Effacer ↑/↓ ESC Q"; - break; + return "Import"; case LARDON3D_SCREEN_VIEWER: - *title = "Viewer"; - *content = "Le viewer Vulkan n'est pas encore disponible."; - break; + return "Viewer"; case LARDON3D_SCREEN_HELP: - *title = "Aide"; - *content = "Raccourcis clavier."; - break; + return "Aide / contrats runtime"; case LARDON3D_SCREEN_TASKS: - *title = "Tâches"; - *content = "Tâches en arrière-plan"; - *footer = "F5 Tâches ESC Accueil Q Quit"; - break; + return "Tâches"; case LARDON3D_SCREEN_RESOURCES: - *title = "Ressources"; - *content = "Gouverneur de ressources"; - *footer = "F6 Ressources ESC Accueil Q Quit"; - break; + return "Ressources / Governor"; + case LARDON3D_SCREEN_OPTICS: + return "Profils optiques immuables"; + case LARDON3D_SCREEN_SSD: + return "SSD externe"; case LARDON3D_SCREEN_HOME: default: - *title = "Accueil"; - *content = "Bienvenue dans Lardon3D"; - *footer = "F1 Aide F2 Projets F3 Import F4 Viewer F5 Tâches F6 Ressources Q"; - break; + return "Observatoire Lardon3D"; + } +} + +static const char * +screen_footer( + Lardon3DScreen screen, + Lardon3DTuiInteractionMode interaction_mode +) +{ + Lardon3DTuiKeyContract keys = lardon3d_tui_key_contract( + interaction_mode); + if (keys.enter && keys.escape && keys.f10) { + return "F10 SSD | Enter valider | ESC annuler"; + } + if (keys.cancel_import && keys.f10) { + return "F10 SSD | X annuler l'import | Q/ESC désactivés"; + } + switch (screen) { + case LARDON3D_SCREEN_PROJECTS: + return "F10 SSD | N Nouveau O Ouvrir C Fermer | ESC Accueil F7 Optique Q"; + case LARDON3D_SCREEN_IMPORT: + return "F10 SSD | I Importer R Recharger S Tri/Filtre X Effacer | ESC Q"; + case LARDON3D_SCREEN_TASKS: + return "F10 SSD | ↑/↓ P pause R reprise C annuler | ESC F6 Ressources Q"; + case LARDON3D_SCREEN_RESOURCES: + return "F10 SSD | Observation seule: CPU/GPU/batch par Governor | ESC Q"; + case LARDON3D_SCREEN_OPTICS: + return "F10 SSD | TAB ↑/↓ [ première ] suivante B/L/C V/A/G/K/E R retry ESC Q"; + case LARDON3D_SCREEN_SSD: + return "F10 SSD | activer/drainer/annuler drain (asynchrone) | ESC Q"; + default: + return "F10 SSD | F1 Aide F2 Projets F3 Import F4 Viewer F5 Tâches F6 Ressources Q"; } } @@ -109,14 +147,12 @@ draw_input_field( int columns ) { - if (!input_text) { + if (!input_text || row + 1 >= LINES - 4) { return; } - - draw_text(row, 4, columns - 6, input_label); + draw_text(row, 3, columns - 6, input_label); (void)mvaddch(row + 1, 2, '['); (void)mvaddch(row + 1, columns - 3, ']'); - int available = columns - 8; size_t length = strlen(input_text); const char *visible = input_text; @@ -129,401 +165,1027 @@ draw_input_field( } static void -draw_project_screen( - const char *input_text, - const char *input_label, - int columns -) +format_bytes(uint64_t bytes, char output[64]) { - draw_text(6, 4, columns - 6, "N : Nouveau projet"); - draw_text(7, 4, columns - 6, "O : Ouvrir un projet"); - draw_text(8, 4, columns - 6, "C : Fermer le projet"); - draw_text(9, 4, columns - 6, "ESC : Accueil"); - draw_text(10, 4, columns - 6, "Q : Quitter"); - draw_input_field(input_text, input_label, 11, columns); + lardon3d_image_catalog_format_size(bytes, output, 64); } static void -draw_catalog( - const Lardon3DAppState *state, - int rows, - int columns +format_duration(uint64_t seconds, char output[64]) +{ + if (seconds < 60) { + (void)snprintf(output, 64, "%llus", (unsigned long long)seconds); + } else if (seconds < 3600) { + (void)snprintf(output, 64, "%llum", + (unsigned long long)(seconds / 60 + + (seconds % 60 >= 30 ? 1U : 0U))); + } else { + uint64_t hours = seconds / 3600; + uint64_t minutes = (seconds % 3600) / 60; + (void)snprintf(output, 64, "%lluh%02llu", + (unsigned long long)hours, (unsigned long long)minutes); + } +} + +static void +draw_progress_bar( + int row, + int column, + int width, + unsigned int percentage, + const Lardon3DTuiPalette *palette ) { - if (!state->project_loaded) { - draw_text(7, 4, columns - 6, "Aucun projet chargé."); + if (width < 8) return; + if (width > 52) width = 52; + int bar_width = width - 7; + int filled = (int)((unsigned int)bar_width * percentage / 100U); + char line[64]; + line[0] = '['; + for (int index = 0; index < bar_width; ++index) { + line[index + 1] = index < filled ? '#' : '-'; + } + (void)snprintf(line + bar_width + 1, + sizeof(line) - (size_t)bar_width - 1, "] %3u%%", percentage); + draw_text_style(row, column, width, line, + percentage == 100 ? LARDON3D_TUI_SEMANTIC_HEALTHY + : LARDON3D_TUI_SEMANTIC_CPU, + palette); +} + +static void +format_compact_progress_bar( + unsigned int percentage, + char output[11] +) +{ + unsigned int bounded = percentage > 100 ? 100 : percentage; + unsigned int filled = bounded * 8U / 100U; + output[0] = '['; + for (unsigned int index = 0; index < 8; ++index) { + output[index + 1] = index < filled ? '#' : '-'; + } + output[9] = ']'; + output[10] = '\0'; +} + +static void +draw_project_line(const Lardon3DAppState *state, int row, int columns) +{ + char line[512]; + (void)snprintf(line, sizeof(line), "Projet: %.120s%s%.370s", + state->project_loaded ? state->project_name : "aucun", + state->project_loaded ? " " : "", + state->project_loaded ? state->project_path : ""); + draw_text(row, 2, columns - 4, line); +} + +static void +draw_home( + const Lardon3DRuntimeSnapshot *runtime, + Lardon3DTuiViewport viewport, + int columns, + const Lardon3DTuiPalette *palette +) +{ + int start = 4; + int stage_column_width = (columns - 6) / 2; + int stage_name_width = viewport == LARDON3D_TUI_VIEWPORT_COMPACT ? 11 : 14; + for (size_t index = 0; index < LARDON3D_TUI_STAGE_COUNT; ++index) { + int column_group = (int)(index / 6); + int row = start + (int)(index % 6); + int column = 2 + column_group * stage_column_width; + char line[128]; + (void)snprintf(line, sizeof(line), "%-*.*s %-14s", + stage_name_width, stage_name_width, + lardon3d_tui_stage_name(runtime->stages[index].stage), + lardon3d_tui_stage_state_name(runtime->stages[index].state)); + draw_text_style(row, column, stage_column_width - 1, line, + lardon3d_tui_stage_semantic(runtime->stages[index].state), palette); + } + + int active_row = start + 6; + if (!runtime->active_task_known) { + draw_text_style(active_row, 2, columns - 4, + "Tâche active: aucune", LARDON3D_TUI_SEMANTIC_DIM, palette); + return; + } + const Lardon3DTaskObservation *task = + &runtime->tasks[runtime->active_task_index]; + char line[512]; + if (viewport == LARDON3D_TUI_VIEWPORT_COMPACT + && runtime->active_progress.durable_counts_known) { + char bar[11]; + format_compact_progress_bar(runtime->active_progress.percentage, bar); + (void)snprintf(line, sizeof(line), + "%s %3u%% | %llu/%llu durable | #%llu %.12s", bar, + runtime->active_progress.percentage_known + ? runtime->active_progress.percentage : 0U, + (unsigned long long)runtime->active_progress.completed, + (unsigned long long)runtime->active_progress.total, + (unsigned long long)task->id, task->name); + } else if (runtime->active_progress.durable_counts_known) { + (void)snprintf(line, sizeof(line), + "Active #%llu %s [%s] seq=%u | %llu/%llu durable", + (unsigned long long)task->id, task->name, + lardon3d_task_state_name(task->state), task->sequence_count, + (unsigned long long)runtime->active_progress.completed, + (unsigned long long)runtime->active_progress.total); + } else if (viewport == LARDON3D_TUI_VIEWPORT_COMPACT) { + if (runtime->active_progress.percentage_known) { + char bar[11]; + format_compact_progress_bar( + runtime->active_progress.percentage, bar); + (void)snprintf(line, sizeof(line), + "%s runtime %3u%% | #%llu %.18s", bar, + runtime->active_progress.percentage, + (unsigned long long)task->id, task->name); + } else { + (void)snprintf(line, sizeof(line), + "scientifique indéterminé | #%llu %.18s", + (unsigned long long)task->id, task->name); + } + } else { + (void)snprintf(line, sizeof(line), + "Active #%llu %s [%s] seq=%u", + (unsigned long long)task->id, task->name, + lardon3d_task_state_name(task->state), task->sequence_count); + } + draw_text_style(active_row, 2, columns - 4, line, + task->state == TASK_RUNNING ? LARDON3D_TUI_SEMANTIC_CPU + : LARDON3D_TUI_SEMANTIC_WARNING, + palette); + if (viewport == LARDON3D_TUI_VIEWPORT_FULL + && runtime->active_progress.percentage_known) { + draw_progress_bar(active_row + 1, 2, + viewport == LARDON3D_TUI_VIEWPORT_FULL ? 52 : columns - 4, + runtime->active_progress.percentage, palette); + } + if (viewport == LARDON3D_TUI_VIEWPORT_FULL) { + char elapsed[64] = "UNKNOWN"; + char eta[64] = "UNKNOWN"; + if (runtime->active_progress.elapsed_known) { + format_duration(runtime->active_progress.elapsed_seconds, elapsed); + } + if (runtime->active_progress.eta_state == LARDON3D_TUI_ETA_KNOWN) { + format_duration(runtime->active_progress.eta_seconds, eta); + } else { + (void)snprintf(eta, sizeof(eta), "%s", + lardon3d_tui_eta_state_name(runtime->active_progress.eta_state)); + } + char throughput[64]; + if (runtime->active_progress.throughput_known) { + (void)snprintf(throughput, sizeof(throughput), + runtime->active_progress.runtime_percentage + ? "%.1f%%/s" : "%.1f unité/s", + runtime->active_progress.units_per_second); + } else { + (void)snprintf(throughput, sizeof(throughput), "UNKNOWN"); + } + (void)snprintf(line, sizeof(line), + "Durée %s | ETA %s | débit %s%s", elapsed, eta, throughput, + runtime->active_progress.resumed_prefix_excluded + ? " | préfixe repris exclu" : ""); + draw_text(active_row + 2, 2, columns - 4, line); + } +} + +static void +draw_projects(const char *input_text, const char *input_label, int columns) +{ + draw_text(5, 4, columns - 6, "N : Nouveau projet"); + draw_text(6, 4, columns - 6, "O : Ouvrir un projet"); + draw_text(7, 4, columns - 6, "C : Fermer le projet"); + draw_input_field(input_text, input_label, 9, columns); +} + +static void +draw_catalog(const Lardon3DAppState *state, int rows, int columns) +{ + if (!state->project_loaded || !state->image_view || !state->image_catalog) { + draw_text(5, 4, columns - 6, "Aucun projet chargé."); return; } size_t count = lardon3d_image_view_count(state->image_view); size_t total = lardon3d_image_catalog_count(state->image_catalog); char line[512]; - (void)snprintf( - line, - sizeof(line), - "Images visibles : %zu / %zu", - count, - total - ); - draw_text(6, 4, columns - 6, line); - char size_text[64]; - lardon3d_image_catalog_format_size( - lardon3d_image_view_total_size(state->image_view), - size_text, - sizeof(size_text) - ); - (void)snprintf( - line, - sizeof(line), - "Taille totale visible : %s", - size_text - ); - draw_text(7, 4, columns - 6, line); - (void)snprintf( - line, - sizeof(line), - "Tri : %s", - lardon3d_image_view_sort_name( - lardon3d_image_view_sort(state->image_view) - ) - ); - draw_text(8, 4, columns - 6, line); + (void)snprintf(line, sizeof(line), "Images visibles: %zu / %zu | Tri: %s", + count, total, lardon3d_image_view_sort_name( + lardon3d_image_view_sort(state->image_view))); + draw_text(4, 2, columns - 4, line); const char *filter = lardon3d_image_view_filter(state->image_view); - (void)snprintf( - line, - sizeof(line), - "Filtre : %s", - filter[0] ? filter : "Aucun" - ); - draw_text(9, 4, columns - 6, line); - if (count == 0) { - draw_text( - 11, - 4, - columns - 6, - filter[0] - ? "Aucune image ne correspond au filtre." - : "Aucune image importée." - ); - return; - } - - int journal_row = rows - 7; - size_t visible = journal_row > 11 ? (size_t)(journal_row - 11) : 0; + (void)snprintf(line, sizeof(line), "Filtre: %s", + filter[0] ? filter : "aucun"); + draw_text(5, 2, columns - 4, line); + size_t visible = rows > 12 ? (size_t)(rows - 12) : 1; size_t offset = lardon3d_image_view_offset(state->image_view); size_t selection = lardon3d_image_view_selection(state->image_view); for (size_t row = 0; row < visible; ++row) { size_t index = offset + row; const Lardon3DImageEntry *entry = lardon3d_image_view_get( - state->image_view, - index - ); - if (!entry) { - break; - } - lardon3d_image_catalog_format_size( - entry->size_bytes, - size_text, - sizeof(size_text) - ); - (void)snprintf( - line, - sizeof(line), - "%c %s %s", - index == selection ? '>' : ' ', - entry->filename, - size_text - ); - draw_text(11 + (int)row, 4, columns - 6, line); + state->image_view, index); + if (!entry) break; + char size_text[64]; + format_bytes(entry->size_bytes, size_text); + (void)snprintf(line, sizeof(line), "%c %-40.40s %s", + index == selection ? '>' : ' ', entry->filename, size_text); + draw_text(7 + (int)row, 2, columns - 4, line); } } static void -draw_import_screen( +draw_import( const Lardon3DAppState *state, const char *input_text, const char *input_label, const Lardon3DImportTaskSnapshot *snapshot, int rows, - int columns + int columns, + const Lardon3DTuiPalette *palette ) { if (snapshot && snapshot->status == LARDON3D_IMPORT_TASK_RUNNING) { - draw_text(6, 4, columns - 6, "Import en cours"); - char line[128]; - (void)snprintf( - line, - sizeof(line), - "Fichiers : %zu / %zu", - snapshot->processed, - snapshot->total - ); - draw_text(7, 4, columns - 6, line); - (void)snprintf(line, sizeof(line), "Copiés : %zu", snapshot->copied); - draw_text(8, 4, columns - 6, line); - (void)snprintf( - line, - sizeof(line), - "Déjà présents : %zu", - snapshot->already_present - ); - draw_text(9, 4, columns - 6, line); - (void)snprintf(line, sizeof(line), "Ignorés : %zu", snapshot->ignored); - draw_text(10, 4, columns - 6, line); - - int percent = snapshot->total == 0 - ? 0 - : (int)((snapshot->processed * 100) / snapshot->total); - if (percent > 100) { - percent = 100; + char line[256]; + (void)snprintf(line, sizeof(line), + "Import: %zu/%zu | copiés %zu | présents %zu | ignorés %zu", + snapshot->processed, snapshot->total, snapshot->copied, + snapshot->already_present, snapshot->ignored); + draw_text_style(5, 2, columns - 4, line, + LARDON3D_TUI_SEMANTIC_CPU, palette); + unsigned int percent = 0; + if (snapshot->total > 0) { + size_t quotient = snapshot->total / 100U; + size_t remainder = snapshot->total % 100U; + for (unsigned int candidate = 100; candidate > 0; --candidate) { + size_t threshold = quotient * candidate + + (remainder * candidate + 99U) / 100U; + if (snapshot->processed >= threshold) { + percent = candidate; + break; + } + } } - int bar_width = columns - 18; - if (bar_width > 40) { - bar_width = 40; - } - if (bar_width < 1) { - bar_width = 1; - } - int filled = (bar_width * percent) / 100; - char bar[64]; - bar[0] = '['; - for (int index = 0; index < bar_width; ++index) { - bar[index + 1] = index < filled ? '#' : '-'; - } - (void)snprintf( - bar + bar_width + 1, - sizeof(bar) - (size_t)bar_width - 1, - "] %d %%", - percent - ); - draw_text(11, 4, columns - 6, bar); - draw_text(12, 4, columns - 6, "C : Annuler l'import"); - return; + if (percent > 100) percent = 100; + draw_progress_bar(7, 2, columns - 4, percent, palette); + draw_text(9, 2, columns - 4, "X : annuler l'import"); + } else if (input_text) { + draw_input_field(input_text, input_label, 5, columns); + } else { + draw_catalog(state, rows, columns); } - if (input_text) { - draw_input_field(input_text, input_label, 7, columns); - return; - } - draw_catalog(state, rows, columns); } static void draw_tasks( - const Lardon3DTaskSnapshot *snapshots, - size_t count, - const Lardon3DTaskQueueSummary *summary, + const Lardon3DRuntimeSnapshot *runtime, + size_t selected, int rows, - int columns + int columns, + const Lardon3DTuiPalette *palette ) { char line[512]; - (void)snprintf( - line, - sizeof(line), - "Nombre de tâches : %zu En cours : %zu En attente : %zu Terminées : %zu", - summary->total, - summary->running, - summary->pending, - summary->completed - ); - draw_text(6, 2, columns - 4, line); - draw_text(8, 2, columns - 4, "ID Nom Etat Progression Message"); - int journal_row = rows - 7; - size_t visible = journal_row > 9 ? (size_t)(journal_row - 9) : 0; - if (count == 0) { - draw_text(10, 4, columns - 6, "Aucune tâche."); + (void)snprintf(line, sizeof(line), + "Total %zu | running %zu | pending %zu | terminal cumulées %zu", + runtime->task_summary.total, runtime->task_summary.running, + runtime->task_summary.pending, runtime->task_summary.completed); + draw_text(4, 2, columns - 4, line); + if (runtime->task_count == 0) { + draw_text_style(6, 4, columns - 6, "Aucune tâche retenue.", + LARDON3D_TUI_SEMANTIC_DIM, palette); return; } - size_t displayed = count < visible ? count : visible; - for (size_t index = 0; index < displayed; ++index) { - (void)snprintf( - line, - sizeof(line), - "%-6llu %-20.20s %-12.12s %3u %% %s", - (unsigned long long)snapshots[index].id, - snapshots[index].name, - lardon3d_task_state_name(snapshots[index].state), - snapshots[index].progress, - snapshots[index].message - ); - draw_text(9 + (int)index, 2, columns - 4, line); + if (selected >= runtime->task_count) selected = runtime->task_count - 1; + const Lardon3DTaskObservation *chosen = &runtime->tasks[selected]; + (void)snprintf(line, sizeof(line), "Sélection #%llu %s | %s | %s", + (unsigned long long)chosen->id, chosen->name, + chosen->has_task_kind ? chosen->task_kind : "untyped", + lardon3d_task_state_name(chosen->state)); + draw_text_style(6, 2, columns - 4, line, + chosen->state == TASK_FAILED + || (chosen->state == TASK_COMPLETED + && chosen->durable_progress_known + && chosen->durable_completed < chosen->durable_total) + ? LARDON3D_TUI_SEMANTIC_ERROR + : (chosen->state == TASK_COMPLETED + ? LARDON3D_TUI_SEMANTIC_HEALTHY + : LARDON3D_TUI_SEMANTIC_CPU), palette); + if (chosen->durable_progress_known) { + (void)snprintf(line, sizeof(line), + "Progression durable: %llu/%llu%s", + (unsigned long long)chosen->durable_completed, + (unsigned long long)chosen->durable_total, + chosen->state == TASK_COMPLETED + && chosen->durable_completed < chosen->durable_total + ? " — INTEGRITY ERROR" : ""); + draw_text_style(7, 2, columns - 4, line, + chosen->state == TASK_COMPLETED + && chosen->durable_completed < chosen->durable_total + ? LARDON3D_TUI_SEMANTIC_ERROR + : LARDON3D_TUI_SEMANTIC_NORMAL, palette); + } else if (chosen->has_task_kind) { + draw_text_style(7, 2, columns - 4, + "Progression scientifique: indéterminée", + LARDON3D_TUI_SEMANTIC_WARNING, palette); + } else { + (void)snprintf(line, sizeof(line), "Progression runtime: %u%%", + chosen->progress); + draw_text(7, 2, columns - 4, line); + } + int list_start = 9; + int list_end = rows - 5; + for (size_t index = 0; index < runtime->task_count + && list_start + (int)index <= list_end; ++index) { + const Lardon3DTaskObservation *task = &runtime->tasks[index]; + if (task->durable_progress_known) { + (void)snprintf(line, sizeof(line), + "%c #%llu %-18.18s %-10.10s %llu/%llu durable %.42s", + index == selected ? '>' : ' ', + (unsigned long long)task->id, task->name, + lardon3d_task_state_name(task->state), + (unsigned long long)task->durable_completed, + (unsigned long long)task->durable_total, task->message); + } else { + (void)snprintf(line, sizeof(line), + "%c #%llu %-18.18s %-10.10s %s %.52s", + index == selected ? '>' : ' ', + (unsigned long long)task->id, task->name, + lardon3d_task_state_name(task->state), + task->has_task_kind ? "science ?" : "runtime", + task->message); + } + draw_text(list_start + (int)index, 2, columns - 4, line); } } static void -draw_resource_line( - int row, - int columns, - const char *label, - uint64_t reserved, - uint64_t available +external_storage_status_text( + Lardon3DResourceExternalStorageStatus status, + char output[16] ) { - char reserved_text[64]; - char available_text[64]; - char line[256]; - lardon3d_image_catalog_format_size( - reserved, - reserved_text, - sizeof(reserved_text) - ); - lardon3d_image_catalog_format_size( - available, - available_text, - sizeof(available_text) - ); - (void)snprintf( - line, - sizeof(line), - "%s réservée : %s Restante : %s", - label, - reserved_text, - available_text - ); - draw_text(row, 4, columns - 6, line); + const char *name = "UNKNOWN"; + switch (status) { + case LARDON3D_RESOURCE_EXTERNAL_STORAGE_ABSENT: + name = "ABSENT"; + break; + case LARDON3D_RESOURCE_EXTERNAL_STORAGE_DETECTED: + name = "DETECTED"; + break; + case LARDON3D_RESOURCE_EXTERNAL_STORAGE_AVAILABLE: + name = "AVAILABLE"; + break; + case LARDON3D_RESOURCE_EXTERNAL_STORAGE_IN_USE: + name = "IN_USE"; + break; + case LARDON3D_RESOURCE_EXTERNAL_STORAGE_DRAINING: + name = "DRAINING"; + break; + case LARDON3D_RESOURCE_EXTERNAL_STORAGE_SAFE: + name = "SAFE"; + break; + case LARDON3D_RESOURCE_EXTERNAL_STORAGE_ERROR: + name = "ERROR"; + break; + } + (void)snprintf(output, 16, "%s", name); +} + +static void +format_external_storage_summary( + const Lardon3DTuiResourceView *resource, + char *line, + size_t capacity +) +{ + if (!resource->external_storage_registered) { + (void)snprintf(line, capacity, + "Governor SSD: UNREGISTERED | scratch/swap UNKNOWN"); + return; + } + char scratch_total[64] = "UNKNOWN"; + char scratch_free[64] = "UNKNOWN"; + if (resource->scratch_total_known) { + format_bytes(resource->scratch_total_bytes, scratch_total); + } + if (resource->scratch_free_known) { + format_bytes(resource->scratch_free_bytes, scratch_free); + } + char status[16]; + external_storage_status_text(resource->external_storage_status, status); + (void)snprintf(line, capacity, + "Governor SSD %s | alloc %s | scratch total/free %s/%s | leases %zu", + status, + resource->scratch_new_allocations_allowed ? "oui" : "non", + scratch_total, scratch_free, resource->scratch_leases); } static void draw_resources( - const Lardon3DResourceAvailability *availability, - int columns + const Lardon3DTuiResourceView *resource, + Lardon3DTuiViewport viewport, + int columns, + const Lardon3DTuiPalette *palette ) { - if (!availability) { - draw_text(7, 4, columns - 6, "Ressources système indisponibles."); + if (!resource->valid) { + draw_text_style(5, 4, columns - 6, + "Ressources système indisponibles (UNKNOWN).", + LARDON3D_TUI_SEMANTIC_WARNING, palette); + char external[512]; + format_external_storage_summary( + resource, external, sizeof(external)); + draw_text(7, 2, columns - 4, external); return; } - char line[256]; - (void)snprintf( - line, - sizeof(line), - "Réservations actives : %zu", - availability->active_reservations - ); - draw_text(6, 4, columns - 6, line); - draw_resource_line( - 8, - columns, - "RAM", - availability->memory_reserved_bytes, - availability->memory_available_bytes - ); - if (availability->gpu_memory_known) { - draw_resource_line( - 9, - columns, - "GPU", - availability->gpu_memory_reserved_bytes, - availability->gpu_memory_available_bytes - ); - } else { - draw_text(9, 4, columns - 6, "Mémoire GPU disponible : inconnue"); + char line[512]; + Lardon3DTuiSemantic health = resource->governor_pressure + == LARDON3D_RESOURCE_PRESSURE_GREEN + ? LARDON3D_TUI_SEMANTIC_HEALTHY + : (resource->governor_pressure == LARDON3D_RESOURCE_PRESSURE_YELLOW + ? LARDON3D_TUI_SEMANTIC_WARNING + : LARDON3D_TUI_SEMANTIC_ERROR); + (void)snprintf(line, sizeof(line), "Governor %s — %s", + lardon3d_tui_pressure_name(resource->governor_pressure), + resource->governor_reason); + draw_text_style(4, 2, columns - 4, line, health, palette); + char admitted[32] = "UNKNOWN"; + if (resource->cpu_admitted_known) { + (void)snprintf(admitted, sizeof(admitted), "%u", + resource->cpu_admitted); } - (void)snprintf( - line, - sizeof(line), - "CPU réservés : %u Restants : %u / %u", - availability->cpu_reserved, - availability->cpu_available, - availability->cpu_budget - ); - draw_text(10, 4, columns - 6, line); - (void)snprintf( - line, - sizeof(line), - "Slots GPU réservés : %u Restants : %u / %u", - availability->gpu_slots_reserved, - availability->gpu_slots_available, - availability->gpu_slot_budget - ); - draw_text(11, 4, columns - 6, line); - (void)snprintf( - line, - sizeof(line), - "Slots IO réservés : %u Restants : %u / %u", - availability->io_slots_reserved, - availability->io_slots_available, - availability->io_slot_budget - ); - draw_text(12, 4, columns - 6, line); + (void)snprintf(line, sizeof(line), + "CPU active/admis/disponible: %u/%s/%u (hôte %u) | utilisation %s", + resource->cpu_active, admitted, resource->cpu_available, + resource->cpu_logical_total, + resource->cpu_utilization_known ? "connue" : "UNKNOWN"); + draw_text_style(6, 2, columns - 4, line, + LARDON3D_TUI_SEMANTIC_CPU, palette); + if (viewport == LARDON3D_TUI_VIEWPORT_FULL) { + if (resource->cpu_utilization_known) { + (void)snprintf(line, sizeof(line), + "CPU raison: %s | utilisation %u.%02u%%", + resource->cpu_reason, + resource->cpu_utilization_basis_points / 100U, + resource->cpu_utilization_basis_points % 100U); + } else { + (void)snprintf(line, sizeof(line), + "CPU raison: %s | utilisation UNKNOWN", + resource->cpu_reason); + } + draw_text(7, 4, columns - 6, line); + } + char gpu_busy[64]; + if (resource->gpu_busy_known) { + (void)snprintf(gpu_busy, sizeof(gpu_busy), "%u.%02u%%", + resource->gpu_busy_basis_points / 100U, + resource->gpu_busy_basis_points % 100U); + } else { + (void)snprintf(gpu_busy, sizeof(gpu_busy), "UNKNOWN"); + } + (void)snprintf(line, sizeof(line), + "GPU %s | slots active/dispo %u/%u | busy %s | backend %s", + resource->gpu_present ? "présent" : "absent", + resource->gpu_slots_active, resource->gpu_slots_available, + gpu_busy, + lardon3d_tui_gpu_backend_name(resource->gpu_backend)); + draw_text_style(viewport == LARDON3D_TUI_VIEWPORT_FULL ? 9 : 7, + 2, columns - 4, line, LARDON3D_TUI_SEMANTIC_GPU, palette); + if (viewport == LARDON3D_TUI_VIEWPORT_FULL) { + (void)snprintf(line, sizeof(line), "GPU raison: %s", + resource->gpu_backend_reason); + draw_text(10, 4, columns - 6, line); + } + char total[64], available[64], reserve[64], reserved[64]; + format_bytes(resource->ram_total_bytes, total); + format_bytes(resource->ram_available_bytes, available); + format_bytes(resource->ram_reserve_bytes, reserve); + format_bytes(resource->ram_reserved_bytes, reserved); + (void)snprintf(line, sizeof(line), + "RAM total %s | MemAvailable %s | réserve %s | réservée Task %s", + total, available, reserve, reserved); + draw_text(viewport == LARDON3D_TUI_VIEWPORT_FULL ? 11 : 8, + 2, columns - 4, line); + if (resource->swap_total_known && resource->swap_used_known) { + char swap_total[64], swap_used[64]; + format_bytes(resource->swap_total_bytes, swap_total); + format_bytes(resource->swap_used_bytes, swap_used); + if (resource->swap_delta_known) { + (void)snprintf(line, sizeof(line), + "Swap total %s | utilisé %s | delta in/out %llu/%llu pages", + swap_total, swap_used, + (unsigned long long)resource->swap_pages_in_delta, + (unsigned long long)resource->swap_pages_out_delta); + } else { + (void)snprintf(line, sizeof(line), + "Swap total %s | utilisé %s | delta in/out UNKNOWN", + swap_total, swap_used); + } + } else { + (void)snprintf(line, sizeof(line), "Swap: UNKNOWN"); + } + draw_text(viewport == LARDON3D_TUI_VIEWPORT_FULL ? 12 : 9, + 2, columns - 4, line); + if (viewport == LARDON3D_TUI_VIEWPORT_FULL) { + char batch[32] = "UNKNOWN"; + char inflight[32] = "UNKNOWN"; + char helpers[32] = "UNKNOWN"; + if (resource->batch_known) { + (void)snprintf(batch, sizeof(batch), "%zu", resource->batch_size); + } + if (resource->inflight_known) { + (void)snprintf(inflight, sizeof(inflight), "%zu", + resource->inflight_limit); + } + if (resource->helpers_known) { + (void)snprintf(helpers, sizeof(helpers), "%u", + resource->helper_limit); + } + (void)snprintf(line, sizeof(line), + "Contrat: batch %s | inflight %s | helpers %s | I/O active/dispo %u/%u", + batch, inflight, helpers, resource->io_active, + resource->io_available); + draw_text(14, 2, columns - 4, line); + char gpu_reserved[64] = "UNKNOWN"; + char gpu_available[64] = "UNKNOWN"; + if (resource->gpu_memory_known) { + format_bytes(resource->gpu_memory_reserved_bytes, gpu_reserved); + format_bytes(resource->gpu_memory_available_bytes, gpu_available); + } + const char *gpu_memory_kind = !resource->gpu_present + ? "no GPU" + : (resource->gpu_uses_shared_memory + ? "UMA once in RAM" : "dedicated"); + (void)snprintf(line, sizeof(line), + "GPU memory (%s): reserved/available %s/%s", + gpu_memory_kind, gpu_reserved, gpu_available); + draw_text(15, 2, columns - 4, line); + } + + format_external_storage_summary(resource, line, sizeof(line)); + draw_text(viewport == LARDON3D_TUI_VIEWPORT_FULL ? 16 : 10, + 2, columns - 4, line); + if (viewport == LARDON3D_TUI_VIEWPORT_FULL) { + char external_swap_total[64] = "UNKNOWN"; + char external_swap_used[64] = "UNKNOWN"; + if (resource->external_swap_total_known) { + format_bytes(resource->external_swap_total_bytes, + external_swap_total); + } + if (resource->external_swap_used_known) { + format_bytes(resource->external_swap_used_bytes, + external_swap_used); + } + (void)snprintf(line, sizeof(line), + "Governor SSD swap total/used %s/%s | identité %.120s", + external_swap_total, external_swap_used, + resource->external_storage_registered + ? resource->external_storage_identity : "UNKNOWN"); + draw_text(17, 2, columns - 4, line); + (void)snprintf(line, sizeof(line), "SSD raison: %.220s", + resource->external_storage_registered + ? resource->external_storage_reason : "UNREGISTERED"); + draw_text(18, 2, columns - 4, line); + draw_text(20, 2, columns - 4, + "Les choix CPU/GPU/batch sont observés; aucun réglage utilisateur normal."); + } +} + +static Lardon3DTuiSemantic +ssd_semantic(Lardon3DSsdState state) +{ + if (state == LARDON3D_SSD_ERROR) return LARDON3D_TUI_SEMANTIC_ERROR; + if (state == LARDON3D_SSD_DRAINING || state == LARDON3D_SSD_ENABLING) + return LARDON3D_TUI_SEMANTIC_WARNING; + if (state == LARDON3D_SSD_ENABLED || state == LARDON3D_SSD_IN_USE + || state == LARDON3D_SSD_SAFE_TO_UNPLUG) + return LARDON3D_TUI_SEMANTIC_HEALTHY; + return LARDON3D_TUI_SEMANTIC_SSD; } static void -draw_content( +draw_ssd( + const Lardon3DRuntimeSnapshot *runtime, + const Lardon3DTuiSsdAsyncSnapshot *operation, + Lardon3DTuiViewport viewport, + int columns, + const Lardon3DTuiPalette *palette +) +{ + const Lardon3DSsdSnapshot *ssd = &runtime->ssd; + char line[640]; + if (!runtime->ssd_controller_available) { + draw_text_style(4, 2, columns - 4, "Etat: UNKNOWN", + LARDON3D_TUI_SEMANTIC_WARNING, palette); + if (operation && operation->running) { + (void)snprintf(line, sizeof(line), + "Opération asynchrone: %s (ncurses reste réactif)", + lardon3d_tui_ssd_action_name(operation->action)); + draw_text_style(5, 2, columns - 4, line, + LARDON3D_TUI_SEMANTIC_WARNING, palette); + } + draw_text(7, 2, columns - 4, + "Contrôleur/télémétrie SSD indisponible; identité, swap, scratch et usage UNKNOWN."); + return; + } + bool telemetry_actionable = !operation + || !operation->controller_snapshot_known + || operation->controller_snapshot_actionable; + Lardon3DSsdState displayed_state = ssd->state; + if (operation && operation->running + && operation->action == LARDON3D_TUI_SSD_ACTION_ENABLE) { + displayed_state = LARDON3D_SSD_ENABLING; + } else if (operation && operation->running + && operation->action == LARDON3D_TUI_SSD_ACTION_DRAIN) { + displayed_state = LARDON3D_SSD_DRAINING; + } + /* The async owner is exact operation state, not inferred device state. It + * is the only way ENABLING can remain visible while the synchronous + * controller holds its mutex through bounded side-effect verification. */ + (void)snprintf(line, sizeof(line), "Etat: %s%s", + lardon3d_ssd_state_name(displayed_state), + displayed_state == LARDON3D_SSD_SAFE_TO_UNPLUG + ? " — SAFE TO UNPLUG" : ""); + draw_text_style(4, 2, columns - 4, line, + ssd_semantic(displayed_state), palette); + if (operation && operation->running) { + (void)snprintf(line, sizeof(line), + "Opération asynchrone: %s (ncurses reste réactif)", + lardon3d_tui_ssd_action_name(operation->action)); + draw_text_style(5, 2, columns - 4, line, + LARDON3D_TUI_SEMANTIC_WARNING, palette); + } else if (operation && operation->result_known) { + draw_text(5, 2, columns - 4, operation->reason); + } + if (!telemetry_actionable) { + /* A synthetic ERROR is deliberately visible, but none of its cleared + * booleans are observations. Rendering them as inactive/unmounted + * would turn validation failure into guessed physical state. */ + draw_text_style(7, 2, columns - 4, + "UNKNOWN — télémétrie invalide: identité, lien, swap, scratch, mount et usage.", + LARDON3D_TUI_SEMANTIC_ERROR, palette); + draw_text(9, 2, columns - 4, + "Contrôle F10 désactivé jusqu'à une observation bornée valide."); + return; + } + char link_speed[64]; + if (ssd->connection_speed_known) { + (void)snprintf(link_speed, sizeof(link_speed), "%llu Mb/s", + (unsigned long long)ssd->connection_speed_mbps); + } else { + (void)snprintf(link_speed, sizeof(link_speed), "UNKNOWN"); + } + (void)snprintf(line, sizeof(line), + "Modèle: %s | série: %s | lien: %s", + ssd->model_known ? ssd->model : "UNKNOWN", + ssd->serial_known ? ssd->serial : "UNKNOWN", + link_speed); + draw_text_style(7, 2, columns - 4, line, + LARDON3D_TUI_SEMANTIC_SSD, palette); + (void)snprintf(line, sizeof(line), "Identité stable Drive: %s", + ssd->drive_identity[0] ? ssd->drive_identity : "UNKNOWN"); + draw_text(8, 2, columns - 4, line); + (void)snprintf(line, sizeof(line), + "Paire exacte: %s | swap UUID %s | scratch UUID %s", + ssd->pairing_valid ? "VALID" : "INVALID/UNKNOWN", + ssd->swap_uuid[0] ? ssd->swap_uuid : "UNKNOWN", + ssd->scratch_uuid[0] ? ssd->scratch_uuid : "UNKNOWN"); + draw_text(9, 2, columns - 4, line); + if (viewport == LARDON3D_TUI_VIEWPORT_FULL) { + char swap_total[64] = "UNKNOWN"; + char swap_used[64] = "UNKNOWN"; + if (ssd->swap_total_known) { + format_bytes(ssd->swap_total_bytes, swap_total); + } + if (ssd->swap_used_known) { + format_bytes(ssd->swap_used_bytes, swap_used); + } + (void)snprintf(line, sizeof(line), + "Swap: %s | total %s | utilisé %s", + ssd->swap_active ? "ACTIVE" : "INACTIVE", + swap_total, swap_used); + draw_text(10, 2, columns - 4, line); + char scratch_total[64] = "UNKNOWN"; + char scratch_free[64] = "UNKNOWN"; + if (ssd->scratch_total_known) { + format_bytes(ssd->scratch_total_bytes, scratch_total); + } + if (ssd->scratch_free_known) { + format_bytes(ssd->scratch_free_bytes, scratch_free); + } + (void)snprintf(line, sizeof(line), + "Scratch: %s | mount %s | total/free %s/%s | leases %zu/%zu", + ssd->scratch_mounted ? "MOUNTED" : "UNMOUNTED", + ssd->scratch_mount_path[0] ? ssd->scratch_mount_path : "UNKNOWN", + scratch_total, scratch_free, + ssd->scratch_lease_count, ssd->scratch_lease_capacity); + draw_text(11, 2, columns - 4, line); + (void)snprintf(line, sizeof(line), + "Drain demandé: %s | raison: %s", + ssd->drain_requested ? "oui" : "non", + ssd->reason[0] ? ssd->reason : "UNKNOWN"); + draw_text_style(12, 2, columns - 4, line, + ssd->state == LARDON3D_SSD_ERROR + ? LARDON3D_TUI_SEMANTIC_ERROR + : LARDON3D_TUI_SEMANTIC_NORMAL, palette); + draw_text(14, 2, columns - 4, + "F10 agit uniquement sur la paire Drive/UUID validée; jamais de format/repair/poweroff."); + draw_text(15, 2, columns - 4, + "Le swap/SSD reste une sécurité/scratch physique, jamais de la RAM scientifique."); + } else { + (void)snprintf(line, sizeof(line), "Raison: %s", + ssd->reason[0] ? ssd->reason : "UNKNOWN"); + draw_text_style(6, 2, columns - 4, line, + ssd->state == LARDON3D_SSD_ERROR + ? LARDON3D_TUI_SEMANTIC_ERROR + : LARDON3D_TUI_SEMANTIC_NORMAL, palette); + (void)snprintf(line, sizeof(line), + "Swap %s | scratch %s | leases %zu | drain %s", + ssd->swap_active ? "ACTIVE" : "INACTIVE", + ssd->scratch_mounted ? "MOUNTED" : "UNMOUNTED", + ssd->scratch_lease_count, + ssd->drain_requested ? "oui" : "non"); + draw_text(10, 2, columns - 4, line); + } +} + +static const char * +lens_interface_name(Lardon3DOpticalLensInterface interface_kind) +{ + switch (interface_kind) { + case LARDON3D_OPTICAL_LENS_MANUAL: + return "manual/no-EXIF"; + case LARDON3D_OPTICAL_LENS_ELECTRONIC: + return "electronic"; + case LARDON3D_OPTICAL_LENS_INTEGRATED: + return "integrated"; + } + return "UNKNOWN"; +} + +static const char * +optics_pane_name(Lardon3DTuiOpticsPane pane) +{ + switch (pane) { + case LARDON3D_TUI_OPTICS_PANE_BODY: + return "BODY"; + case LARDON3D_TUI_OPTICS_PANE_LENS: + return "LENS"; + case LARDON3D_TUI_OPTICS_PANE_CONFIGURATION: + return "CONFIGURATION"; + case LARDON3D_TUI_OPTICS_PANE_CALIBRATION: + return "CALIBRATION"; + } + return "UNKNOWN"; +} + +static void +draw_optics( + const Lardon3DTuiOpticsSnapshot *optics, + Lardon3DTuiViewport viewport, + int columns, + const Lardon3DTuiPalette *palette +) +{ + if (!optics || !optics->project_bound) { + const char *message = optics && optics->message[0] + ? optics->message + : "Aucun Project DB lié; aucun profil ou assignation n'est deviné."; + draw_text_style(5, 4, columns - 6, message, + optics && optics->message[0] + ? LARDON3D_TUI_SEMANTIC_ERROR + : LARDON3D_TUI_SEMANTIC_WARNING, palette); + if (optics && optics->message[0]) { + draw_text(7, 4, columns - 6, + "R : réessayer explicitement la liaison Project DB."); + } + return; + } + char line[512]; + size_t body = optics->body_count > 0 + ? (optics->selected_body < optics->body_count + ? optics->selected_body : 0) : 0; + size_t lens = optics->lens_count > 0 + ? (optics->selected_lens < optics->lens_count + ? optics->selected_lens : 0) : 0; + size_t configuration = optics->configuration_count > 0 + ? (optics->selected_configuration < optics->configuration_count + ? optics->selected_configuration : 0) : 0; + (void)snprintf(line, sizeof(line), + "Body [%zu/%zu affichés%s]: %s %s — %s", + optics->body_count ? body + 1 : 0, optics->body_count, + optics->bodies_have_next ? ", suite" : "", + optics->body_count ? optics->bodies[body].manufacturer : "UNKNOWN", + optics->body_count ? optics->bodies[body].model : "", + optics->body_count ? optics->bodies[body].name : "aucun"); + draw_text(4, 2, columns - 4, line); + (void)snprintf(line, sizeof(line), + "Lens [%zu/%zu affichés%s]: %s %s — %s (%s)", + optics->lens_count ? lens + 1 : 0, optics->lens_count, + optics->lenses_have_next ? ", suite" : "", + optics->lens_count ? optics->lenses[lens].manufacturer : "UNKNOWN", + optics->lens_count ? optics->lenses[lens].model : "", + optics->lens_count ? optics->lenses[lens].name : "aucun", + optics->lens_count + ? lens_interface_name(optics->lenses[lens].interface_kind) + : "UNKNOWN"); + draw_text_style(5, 2, columns - 4, line, + optics->lens_count && optics->lenses[lens].interface_kind + == LARDON3D_OPTICAL_LENS_MANUAL + ? LARDON3D_TUI_SEMANTIC_HEALTHY + : LARDON3D_TUI_SEMANTIC_NORMAL, palette); + if (optics->configuration_count > 0) { + const Lardon3DOpticalConfiguration *selected = + &optics->configurations[configuration]; + if (selected->has_focal_length) { + (void)snprintf(line, sizeof(line), + "Config [%zu/%zu affichés%s] #%llu body #%llu lens #%llu focal %u µm", + configuration + 1, optics->configuration_count, + optics->configurations_have_next ? ", suite" : "", + (unsigned long long)selected->optical_configuration_id, + (unsigned long long)selected->camera_body_profile_id, + (unsigned long long)selected->lens_profile_id, + selected->focal_length_um); + } else { + (void)snprintf(line, sizeof(line), + "Config [%zu/%zu affichés%s] #%llu body #%llu lens #%llu focal ABSENT", + configuration + 1, optics->configuration_count, + optics->configurations_have_next ? ", suite" : "", + (unsigned long long)selected->optical_configuration_id, + (unsigned long long)selected->camera_body_profile_id, + (unsigned long long)selected->lens_profile_id); + } + } else { + (void)snprintf(line, sizeof(line), "Config: aucune"); + } + draw_text(6, 2, columns - 4, line); + if (optics->calibration_count > 0) { + size_t selected = optics->selected_calibration + < optics->calibration_count + ? optics->selected_calibration : 0; + const Lardon3DOpticalCalibrationProfile *calibration = + &optics->calibrations[selected]; + (void)snprintf(line, sizeof(line), + "Pane %s | calibration [%zu/%zu affichées%s] #%llu %.28s v%u", + optics_pane_name(optics->active_pane), selected + 1, + optics->calibration_count, + optics->calibrations_have_next ? ", suite" : "", + (unsigned long long)calibration->calibration_profile_id, + calibration->name, calibration->profile_version); + } else { + (void)snprintf(line, sizeof(line), + "Pane %s | calibration candidate: aucune", + optics_pane_name(optics->active_pane)); + } + draw_text(7, 2, columns - 4, line); + if (optics->capture_inspected) { + (void)snprintf(line, sizeof(line), "Capture #%llu: %s — %s", + (unsigned long long)optics->capture_id, + lardon3d_tui_optics_status_name(optics->capture_status), + lardon3d_tui_optics_status_explanation(optics->capture_status, + optics->capture_lens_found + && optics->capture_lens.interface_kind + == LARDON3D_OPTICAL_LENS_MANUAL)); + Lardon3DTuiSemantic semantic = optics->capture_status + == LARDON3D_TUI_OPTICS_SELECTED + ? LARDON3D_TUI_SEMANTIC_HEALTHY + : (optics->capture_status == LARDON3D_TUI_OPTICS_CORRUPT + || optics->capture_status == LARDON3D_TUI_OPTICS_INCOMPATIBLE + ? LARDON3D_TUI_SEMANTIC_ERROR + : LARDON3D_TUI_SEMANTIC_WARNING); + draw_text_style(8, 2, columns - 4, line, semantic, palette); + } else { + draw_text_style(8, 2, columns - 4, + "Capture: non inspecté (V); unresolved reste absence d'assignation.", + LARDON3D_TUI_SEMANTIC_DIM, palette); + } + (void)snprintf(line, sizeof(line), + "Calibrations compatibles: %zu | sélection: %s", + optics->calibration_count, + optics->capture_selection_found ? "explicite" : "aucune/ambiguë"); + draw_text(9, 2, columns - 4, line); + if (optics->metadata_lookup_performed) { + (void)snprintf(line, sizeof(line), + "Métadonnées exactes: body %s | lens %s", + optics->metadata_body_found ? "MATCH" : "UNRESOLVED", + optics->metadata_lens_found ? "MATCH" : "UNRESOLVED"); + draw_text(10, 2, columns - 4, line); + } + if (viewport == LARDON3D_TUI_VIEWPORT_FULL) { + draw_text(12, 2, columns - 4, + "B body: manufacturer|model|name L lens: interface|range|min|max|maker|model|name"); + draw_text(13, 2, columns - 4, + "C config focal mm/? V inspect capture A assign capture G task:group"); + draw_text(14, 2, columns - 4, + "K sélection calibration E metadata exact [ première page ] page suivante R retry"); + draw_text_style(16, 2, columns - 4, + "Immutable: modifier = créer une nouvelle version/configuration.", + LARDON3D_TUI_SEMANTIC_WARNING, palette); + } +} + +static void +draw_help( + Lardon3DTuiViewport viewport, + int columns, + const Lardon3DTuiPalette *palette +) +{ + draw_text(4, 2, columns - 4, + "F1 aide, F2 projets, F3 import, F4 viewer futur, F5 tâches, F6 ressources,"); + draw_text(5, 2, columns - 4, + "F7 profils optiques, F10 SSD; ESC accueil; Q quitter."); + draw_text_style(7, 2, columns - 4, + "Vert=healthy, jaune=warning/throttled, rouge=error, cyan=GPU, bleu=CPU, magenta=SSD.", + LARDON3D_TUI_SEMANTIC_HEALTHY, palette); + draw_text(8, 2, columns - 4, + "Sans couleur/peu de paires, les libellés et bold/dim conservent le sens."); + if (viewport == LARDON3D_TUI_VIEWPORT_FULL) { + draw_text(10, 2, columns - 4, + "La TUI observe des snapshots bornés >=1s; aucun worker ne touche ncurses."); + draw_text(11, 2, columns - 4, + "Le Governor choisit CPU/GPU/batch. La TUI ne modifie ni admission ni science."); + draw_text(12, 2, columns - 4, + "Dense (future) reste NOT_APPLICABLE; aucune étape future n'est RUNNING."); + } else { + draw_text(10, 2, columns - 4, + "Governor choisit les ressources; Dense future reste NOT_APPLICABLE."); + } +} + +void +lardon3d_layout_draw_runtime( const Lardon3DAppState *state, const char *input_text, const char *input_label, const Lardon3DImportTaskSnapshot *import_snapshot, - const Lardon3DTaskSnapshot *task_snapshots, - size_t task_count, - const Lardon3DTaskQueueSummary *task_summary, - const Lardon3DResourceAvailability *resource_availability, + const Lardon3DRuntimeSnapshot *runtime, + const Lardon3DTuiSsdAsyncSnapshot *ssd_operation, + const Lardon3DTuiOpticsSnapshot *optics, + size_t selected_task, + const Lardon3DTuiPalette *palette, + Lardon3DTuiInteractionMode interaction_mode, int rows, int columns ) { - const char *title; - const char *content; - const char *footer; - screen_texts(state->screen, &title, &content, &footer); - bool import_running = import_snapshot - && import_snapshot->status == LARDON3D_IMPORT_TASK_RUNNING; - if (import_running) { - footer = "C Annuler l'import Q désactivé"; + (void)erase(); + Lardon3DTuiViewport viewport = lardon3d_tui_viewport_classify(rows, columns); + if (!state || !runtime || viewport == LARDON3D_TUI_VIEWPORT_TOO_SMALL) { + draw_too_small(rows, columns); + (void)refresh(); + return; } + draw_frame(rows, columns); + const char *title = screen_title(state->screen); int title_length = (int)strlen(title); - int content_length = (int)strlen(content); - int journal_row = rows - 7; - const char *project = state->project_loaded - ? state->project_name - : "Aucun projet chargé."; - - draw_text(1, (columns - title_length) / 2, title_length, title); - draw_text(3, 2, columns - 4, "Projet"); - draw_text(4, 4, columns - 6, project); - if (state->project_loaded) { - draw_text(5, 4, columns - 6, state->project_path); + draw_text_style(1, (columns - title_length) / 2, title_length, title, + LARDON3D_TUI_SEMANTIC_NORMAL, palette); + draw_project_line(state, 3, columns); + switch (state->screen) { + case LARDON3D_SCREEN_PROJECTS: + draw_projects(input_text, input_label, columns); + break; + case LARDON3D_SCREEN_IMPORT: + draw_import(state, input_text, input_label, import_snapshot, + rows, columns, palette); + break; + case LARDON3D_SCREEN_TASKS: + draw_tasks(runtime, selected_task, rows, columns, palette); + break; + case LARDON3D_SCREEN_RESOURCES: + draw_resources(&runtime->resources, viewport, columns, palette); + break; + case LARDON3D_SCREEN_OPTICS: + draw_optics(optics, viewport, columns, palette); + if (input_text) { + draw_input_field(input_text, input_label, rows - 7, columns); + } + break; + case LARDON3D_SCREEN_SSD: + draw_ssd(runtime, ssd_operation, viewport, columns, palette); + break; + case LARDON3D_SCREEN_HELP: + draw_help(viewport, columns, palette); + break; + case LARDON3D_SCREEN_VIEWER: + draw_text_style(6, 4, columns - 8, + "Viewer Vulkan: PLANNED, aucun travail scientifique actif.", + LARDON3D_TUI_SEMANTIC_DIM, palette); + break; + case LARDON3D_SCREEN_HOME: + default: + draw_home(runtime, viewport, columns, palette); + break; } - if (state->screen == LARDON3D_SCREEN_PROJECTS) { - draw_project_screen( - input_text, - input_label, - columns - ); - } else if (state->screen == LARDON3D_SCREEN_IMPORT) { - draw_import_screen( - state, - input_text, - input_label, - import_snapshot, - rows, - columns - ); - } else if (state->screen == LARDON3D_SCREEN_TASKS) { - draw_tasks( - task_snapshots, - task_count, - task_summary, - rows, - columns - ); - } else if (state->screen == LARDON3D_SCREEN_RESOURCES) { - draw_resources(resource_availability, columns); - } else { - draw_text( - (3 + journal_row) / 2, - (columns - content_length) / 2, - content_length, - content - ); - } - draw_text(journal_row + 1, 2, columns - 4, "Journal"); - draw_text( - journal_row + 2, - 4, - columns - 6, - import_running ? import_snapshot->message : state->status_message - ); - draw_text(rows - 2, 2, columns - 4, footer); + const char *status = import_snapshot + && import_snapshot->status == LARDON3D_IMPORT_TASK_RUNNING + ? import_snapshot->message + : state->status_message; + draw_text(rows - 3, 2, columns - 4, status); + draw_text(rows - 2, 2, columns - 4, + screen_footer(state->screen, interaction_mode)); + (void)refresh(); } void @@ -540,25 +1202,53 @@ lardon3d_layout_draw( int columns ) { - (void)erase(); - - if (rows < MINIMUM_ROWS || columns < MINIMUM_COLUMNS) { - draw_too_small(rows, columns); - } else { - draw_frame(rows, columns); - draw_content( - state, - input_text, - input_label, - import_snapshot, - task_snapshots, - task_count, - task_summary, - resource_availability, - rows, - columns - ); + Lardon3DRuntimeSnapshot runtime = {0}; + runtime.task_count = task_snapshots + ? (task_count < LARDON3D_TUI_TASK_CAPACITY + ? task_count : LARDON3D_TUI_TASK_CAPACITY) + : 0; + if (task_summary) { + runtime.task_summary = *task_summary; } - - (void)refresh(); + for (size_t index = 0; index < runtime.task_count; ++index) { + const Lardon3DTaskSnapshot *source = &task_snapshots[index]; + Lardon3DTaskObservation *destination = &runtime.tasks[index]; + destination->id = source->id; + destination->progress = source->progress; + destination->state = source->state; + destination->started_at = source->started_at; + destination->finished_at = source->finished_at; + (void)snprintf(destination->name, sizeof(destination->name), "%s", + source->name); + (void)snprintf(destination->message, + sizeof(destination->message), "%s", source->message); + } + if (resource_availability) { + runtime.resources.valid = true; + runtime.resources.cpu_active = resource_availability->cpu_reserved; + runtime.resources.cpu_available = resource_availability->cpu_available; + runtime.resources.ram_reserved_bytes = + resource_availability->memory_reserved_bytes; + runtime.resources.gpu_memory_known = + resource_availability->gpu_memory_known; + runtime.resources.gpu_memory_reserved_bytes = + resource_availability->gpu_memory_reserved_bytes; + runtime.resources.gpu_memory_available_bytes = + resource_availability->gpu_memory_available_bytes; + runtime.resources.gpu_slots_active = + resource_availability->gpu_slots_reserved; + runtime.resources.gpu_slots_available = + resource_availability->gpu_slots_available; + runtime.resources.io_active = + resource_availability->io_slots_reserved; + runtime.resources.io_available = + resource_availability->io_slots_available; + } + Lardon3DTuiPalette palette; + lardon3d_tui_palette_plan(false, 0, &palette); + lardon3d_layout_draw_runtime(state, input_text, input_label, + import_snapshot, &runtime, NULL, NULL, 0, &palette, + input_text ? LARDON3D_TUI_INTERACTION_TEXT_INPUT + : LARDON3D_TUI_INTERACTION_IDLE, + rows, columns); } diff --git a/src/matcher_task.c b/src/matcher_task.c index 45f8239..7041b04 100644 --- a/src/matcher_task.c +++ b/src/matcher_task.c @@ -218,7 +218,8 @@ static bool auto_vulkan_backend_candidate(Lardon3DOrbVulkanBackend *backend) { static bool auto_vulkan_runtime_candidate(const Lardon3DAppState *state) { /* AUTO creation is caller-thread metadata work only. Memory sizing does not * belong here: the Governor owns the exact reconstructed batch/depth, UMA - * charge, current MemAvailable/PSI/swap snapshot, and 3 GiB/2 GiB policy. + * charge, current MemAvailable/PSI/swap snapshot, and the 3 GiB hard reserve + * plus the 3--4 GiB caution policy. * A caller-side maximum-window guess could suppress a safe depth-1 contract * before CPU fallback was even considered. Driver initialization remains * deferred to begin() on Queue's affinity-constrained worker. */ diff --git a/src/opencv_task_thread_control.h b/src/opencv_task_thread_control.h index c1310ef..59f0f03 100644 --- a/src/opencv_task_thread_control.h +++ b/src/opencv_task_thread_control.h @@ -14,7 +14,9 @@ typedef struct { /* OpenCV owns one process-wide CPU pool. Queue's single callback owner makes * the change race-free, but configure may mutate before its verification * fails. Therefore begin attempts rollback on every post-capture failure and - * end restores on every callback result. */ + * end restores on every callback result. restore_required is cleared only + * after a verified rollback: a transient OpenCV failure must remain retryable + * rather than silently abandoning ownership of process-global state. */ static inline bool lardon3d_opencv_task_threads_begin( Lardon3DTask *task, unsigned int validated_maximum, Lardon3DOpenCvTaskThreadControl *control) { @@ -29,8 +31,9 @@ static inline bool lardon3d_opencv_task_threads_begin( control->restore_required = true; if (!lardon3d_feature_opencv_configure_threads(contract.cpu_threads)) { /* Verification failure is after a possibly successful setNumThreads(). */ - (void)lardon3d_feature_opencv_configure_threads(control->previous); - control->restore_required = false; + if (lardon3d_feature_opencv_configure_threads(control->previous)) { + control->restore_required = false; + } return false; } return true; @@ -39,8 +42,11 @@ static inline bool lardon3d_opencv_task_threads_begin( static inline bool lardon3d_opencv_task_threads_end( Lardon3DOpenCvTaskThreadControl *control) { if (!control || !control->restore_required) return false; + if (!lardon3d_feature_opencv_configure_threads(control->previous)) { + return false; + } control->restore_required = false; - return lardon3d_feature_opencv_configure_threads(control->previous); + return true; } #endif diff --git a/src/opencv_task_thread_guard.h b/src/opencv_task_thread_guard.h index d25a7f1..6122ef1 100644 --- a/src/opencv_task_thread_guard.h +++ b/src/opencv_task_thread_guard.h @@ -42,9 +42,12 @@ class Lardon3DOpenCvTaskThreadGuard { return false; try { cv::setNumThreads(previous_ > 0 ? previous_ : 1); - restored_ = true; restore_succeeded_ = cv::getNumThreads() == (previous_ > 0 ? previous_ : 1); + /* Keep ownership live after a verification failure. The explicit Task + * cleanup reports the failure, while the noexcept destructor gets one + * final bounded restoration attempt during stack unwinding. */ + restored_ = restore_succeeded_; return restore_succeeded_; } catch (...) { /* C callback boundary: restoration failure cannot escape as C++. */ diff --git a/src/optical_profiles.c b/src/optical_profiles.c new file mode 100644 index 0000000..231b6f9 --- /dev/null +++ b/src/optical_profiles.c @@ -0,0 +1,2064 @@ +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#include "project_db_internal.h" + +static bool optical_id(uint64_t value) { + return value > 0 && value <= (uint64_t)INT64_MAX; +} + +static bool optical_cursor_id(uint64_t value) { + return value <= (uint64_t)INT64_MAX; +} + +static bool optical_text(const char *value, size_t capacity, bool allow_empty) { + if (!value) + return false; + size_t length = strnlen(value, capacity); + return length < capacity && (allow_empty || length > 0); +} + +static bool optical_copy_text(sqlite3_stmt *statement, int column, + char *output, size_t capacity, + bool allow_empty) { + if (sqlite3_column_type(statement, column) != SQLITE_TEXT) + return false; + int bytes = sqlite3_column_bytes(statement, column); + const unsigned char *text = sqlite3_column_text(statement, column); + if (!text || bytes < 0 || (size_t)bytes >= capacity || + (!allow_empty && bytes == 0) || memchr(text, '\0', (size_t)bytes)) + return false; + memcpy(output, text, (size_t)bytes); + output[bytes] = '\0'; + return true; +} + +static bool optical_column_equals(sqlite3_stmt *statement, int column, + const char *expected) { + if (sqlite3_column_type(statement, column) != SQLITE_TEXT) + return false; + int bytes = sqlite3_column_bytes(statement, column); + size_t expected_size = strlen(expected); + const void *text = sqlite3_column_text(statement, column); + return bytes >= 0 && (size_t)bytes == expected_size && text && + memcmp(text, expected, expected_size) == 0; +} + +static Lardon3DProjectDbResult optical_commit_or_rollback( + Lardon3DProjectDb *database, Lardon3DProjectDbResult result, + const char *commit_context, const char *rollback_context) { + if (result == LARDON3D_PROJECT_DB_OK) + result = execute(database, "COMMIT", commit_context); + if (result != LARDON3D_PROJECT_DB_OK) + (void)execute(database, "ROLLBACK", rollback_context); + return result; +} + +static bool optical_page_arguments(uint64_t after_id, const void *items, + size_t capacity, const size_t *count, + const uint64_t *next_after_id) { + return optical_cursor_id(after_id) && items && capacity > 0 && + capacity <= LARDON3D_OPTICAL_PAGE_MAX && count && next_after_id; +} + +static bool read_camera_body(sqlite3_stmt *statement, + Lardon3DOpticalCameraBodyProfile *output) { + memset(output, 0, sizeof(*output)); + sqlite3_int64 id = sqlite3_column_int64(statement, 0); + if (sqlite3_column_type(statement, 0) != SQLITE_INTEGER || id <= 0 || + !optical_copy_text(statement, 1, output->manufacturer, + sizeof(output->manufacturer), false) || + !optical_copy_text(statement, 2, output->model, sizeof(output->model), + false) || + !optical_copy_text(statement, 3, output->name, sizeof(output->name), + false)) { + memset(output, 0, sizeof(*output)); + return false; + } + output->camera_body_profile_id = (uint64_t)id; + return true; +} + +static Lardon3DProjectDbResult camera_body_load_locked( + Lardon3DProjectDb *database, uint64_t profile_id, + Lardon3DOpticalCameraBodyProfile *output) { + sqlite3_stmt *statement = NULL; + Lardon3DProjectDbResult result = prepare( + database, + "SELECT camera_body_profile_id,manufacturer,model,name FROM " + "camera_body_profiles WHERE camera_body_profile_id=?1", + &statement); + if (result == LARDON3D_PROJECT_DB_OK) { + (void)sqlite3_bind_int64(statement, 1, (sqlite3_int64)profile_id); + int code = sqlite3_step(statement); + if (code == SQLITE_DONE) + result = LARDON3D_PROJECT_DB_NOT_FOUND; + else if (code != SQLITE_ROW) + result = sqlite_result(database, code, "load camera body profile"); + else if (!read_camera_body(statement, output) || + output->camera_body_profile_id != profile_id || + sqlite3_step(statement) != SQLITE_DONE) + result = LARDON3D_PROJECT_DB_CORRUPT; + } + (void)sqlite3_finalize(statement); + if (result != LARDON3D_PROJECT_DB_OK) + memset(output, 0, sizeof(*output)); + return result; +} + +Lardon3DProjectDbResult lardon3d_optical_camera_body_create( + Lardon3DProjectDb *database, const Lardon3DOpticalCameraBodyProfile *input, + Lardon3DOpticalCameraBodyProfile *output) { + if (output) + memset(output, 0, sizeof(*output)); + if (!database || !input || !output || input == output || + input->camera_body_profile_id != 0 || + !optical_text(input->manufacturer, sizeof(input->manufacturer), false) || + !optical_text(input->model, sizeof(input->model), false) || + !optical_text(input->name, sizeof(input->name), false)) + return LARDON3D_PROJECT_DB_INVALID_ARGUMENT; + + (void)pthread_mutex_lock(&database->mutex); + Lardon3DProjectDbResult result = + execute(database, "BEGIN IMMEDIATE", "begin camera body profile"); + sqlite3_stmt *statement = NULL; + if (result == LARDON3D_PROJECT_DB_OK) + result = prepare( + database, + "INSERT OR IGNORE INTO camera_body_profiles(manufacturer,model,name) " + "VALUES(?1,?2,?3)", + &statement); + if (result == LARDON3D_PROJECT_DB_OK) { + (void)sqlite3_bind_text(statement, 1, input->manufacturer, -1, + SQLITE_TRANSIENT); + (void)sqlite3_bind_text(statement, 2, input->model, -1, SQLITE_TRANSIENT); + (void)sqlite3_bind_text(statement, 3, input->name, -1, SQLITE_TRANSIENT); + result = step_done(database, statement, "insert camera body profile"); + statement = NULL; + } + uint64_t profile_id = 0; + if (result == LARDON3D_PROJECT_DB_OK) + result = prepare( + database, + "SELECT camera_body_profile_id FROM camera_body_profiles WHERE " + "manufacturer=?1 AND model=?2 AND name=?3", + &statement); + if (result == LARDON3D_PROJECT_DB_OK) { + (void)sqlite3_bind_text(statement, 1, input->manufacturer, -1, + SQLITE_TRANSIENT); + (void)sqlite3_bind_text(statement, 2, input->model, -1, SQLITE_TRANSIENT); + (void)sqlite3_bind_text(statement, 3, input->name, -1, SQLITE_TRANSIENT); + int code = sqlite3_step(statement); + if (code == SQLITE_DONE) { + result = LARDON3D_PROJECT_DB_CORRUPT; + } else if (code != SQLITE_ROW) { + result = sqlite_result(database, code, "find camera body profile"); + } else if (sqlite3_column_type(statement, 0) != SQLITE_INTEGER || + sqlite3_column_int64(statement, 0) <= 0) { + result = LARDON3D_PROJECT_DB_CORRUPT; + } else { + sqlite3_int64 stored_id = sqlite3_column_int64(statement, 0); + profile_id = (uint64_t)stored_id; + if (sqlite3_step(statement) != SQLITE_DONE) + result = LARDON3D_PROJECT_DB_CORRUPT; + } + } + (void)sqlite3_finalize(statement); + result = optical_commit_or_rollback(database, result, + "commit camera body profile", + "rollback camera body profile"); + if (result == LARDON3D_PROJECT_DB_OK) + result = camera_body_load_locked(database, profile_id, output); + (void)pthread_mutex_unlock(&database->mutex); + return result; +} + +Lardon3DProjectDbResult lardon3d_optical_camera_body_load( + Lardon3DProjectDb *database, uint64_t camera_body_profile_id, + Lardon3DOpticalCameraBodyProfile *output) { + if (output) + memset(output, 0, sizeof(*output)); + if (!database || !optical_id(camera_body_profile_id) || !output) + return LARDON3D_PROJECT_DB_INVALID_ARGUMENT; + (void)pthread_mutex_lock(&database->mutex); + Lardon3DProjectDbResult result = + camera_body_load_locked(database, camera_body_profile_id, output); + (void)pthread_mutex_unlock(&database->mutex); + return result; +} + +Lardon3DProjectDbResult lardon3d_optical_camera_body_list( + Lardon3DProjectDb *database, uint64_t after_profile_id, + Lardon3DOpticalCameraBodyProfile *items, size_t capacity, size_t *count, + uint64_t *next_after_profile_id) { + if (count) + *count = 0; + if (next_after_profile_id) + *next_after_profile_id = after_profile_id; + if (!database || !optical_page_arguments(after_profile_id, items, capacity, + count, next_after_profile_id)) + return LARDON3D_PROJECT_DB_INVALID_ARGUMENT; + memset(items, 0, capacity * sizeof(*items)); + (void)pthread_mutex_lock(&database->mutex); + sqlite3_stmt *statement = NULL; + Lardon3DProjectDbResult result = prepare( + database, + "SELECT camera_body_profile_id,manufacturer,model,name FROM " + "camera_body_profiles WHERE camera_body_profile_id>?1 ORDER BY " + "camera_body_profile_id LIMIT ?2", + &statement); + if (result == LARDON3D_PROJECT_DB_OK) { + (void)sqlite3_bind_int64(statement, 1, (sqlite3_int64)after_profile_id); + (void)sqlite3_bind_int64(statement, 2, (sqlite3_int64)capacity); + int code = SQLITE_DONE; + while (*count < capacity && (code = sqlite3_step(statement)) == SQLITE_ROW) { + if (!read_camera_body(statement, &items[*count])) { + result = LARDON3D_PROJECT_DB_CORRUPT; + break; + } + *next_after_profile_id = items[*count].camera_body_profile_id; + ++*count; + } + if (result == LARDON3D_PROJECT_DB_OK && code != SQLITE_DONE && + *count < capacity) + result = sqlite_result(database, code, "list camera body profiles"); + } + (void)sqlite3_finalize(statement); + (void)pthread_mutex_unlock(&database->mutex); + if (result != LARDON3D_PROJECT_DB_OK) { + memset(items, 0, capacity * sizeof(*items)); + *count = 0; + *next_after_profile_id = after_profile_id; + } + return result; +} + +static bool read_camera_alias(sqlite3_stmt *statement, + Lardon3DOpticalCameraBodyAlias *output) { + memset(output, 0, sizeof(*output)); + sqlite3_int64 alias_id = sqlite3_column_int64(statement, 0); + sqlite3_int64 profile_id = sqlite3_column_int64(statement, 1); + if (sqlite3_column_type(statement, 0) != SQLITE_INTEGER || alias_id <= 0 || + sqlite3_column_type(statement, 1) != SQLITE_INTEGER || profile_id <= 0 || + !optical_copy_text(statement, 2, output->metadata_make, + sizeof(output->metadata_make), false) || + !optical_copy_text(statement, 3, output->metadata_model, + sizeof(output->metadata_model), false)) { + memset(output, 0, sizeof(*output)); + return false; + } + output->alias_id = (uint64_t)alias_id; + output->camera_body_profile_id = (uint64_t)profile_id; + return true; +} + +Lardon3DProjectDbResult lardon3d_optical_camera_body_alias_add( + Lardon3DProjectDb *database, uint64_t camera_body_profile_id, + const char *metadata_make, const char *metadata_model, + Lardon3DOpticalCameraBodyAlias *output) { + if (output) + memset(output, 0, sizeof(*output)); + if (!database || !optical_id(camera_body_profile_id) || + !optical_text(metadata_make, LARDON3D_OPTICAL_TEXT_CAPACITY, false) || + !optical_text(metadata_model, LARDON3D_OPTICAL_TEXT_CAPACITY, false) || + !output) + return LARDON3D_PROJECT_DB_INVALID_ARGUMENT; + (void)pthread_mutex_lock(&database->mutex); + Lardon3DProjectDbResult result = + execute(database, "BEGIN IMMEDIATE", "begin camera body alias"); + Lardon3DOpticalCameraBodyProfile body; + if (result == LARDON3D_PROJECT_DB_OK) + result = camera_body_load_locked(database, camera_body_profile_id, &body); + sqlite3_stmt *statement = NULL; + if (result == LARDON3D_PROJECT_DB_OK) + result = prepare( + database, + "INSERT OR IGNORE INTO camera_body_aliases(camera_body_profile_id," + "metadata_make,metadata_model) VALUES(?1,?2,?3)", + &statement); + if (result == LARDON3D_PROJECT_DB_OK) { + (void)sqlite3_bind_int64(statement, 1, (sqlite3_int64)camera_body_profile_id); + (void)sqlite3_bind_text(statement, 2, metadata_make, -1, SQLITE_TRANSIENT); + (void)sqlite3_bind_text(statement, 3, metadata_model, -1, SQLITE_TRANSIENT); + result = step_done(database, statement, "insert camera body alias"); + statement = NULL; + } + if (result == LARDON3D_PROJECT_DB_OK) + result = prepare( + database, + "SELECT alias_id,camera_body_profile_id,metadata_make,metadata_model " + "FROM camera_body_aliases WHERE metadata_make=?1 AND metadata_model=?2", + &statement); + if (result == LARDON3D_PROJECT_DB_OK) { + (void)sqlite3_bind_text(statement, 1, metadata_make, -1, SQLITE_TRANSIENT); + (void)sqlite3_bind_text(statement, 2, metadata_model, -1, SQLITE_TRANSIENT); + int code = sqlite3_step(statement); + if (code == SQLITE_DONE) + result = LARDON3D_PROJECT_DB_CORRUPT; + else if (code != SQLITE_ROW) + result = sqlite_result(database, code, "find camera body alias"); + else if (!read_camera_alias(statement, output)) + result = LARDON3D_PROJECT_DB_CORRUPT; + else if (output->camera_body_profile_id != camera_body_profile_id) + result = LARDON3D_PROJECT_DB_CONSTRAINT; + else if (sqlite3_step(statement) != SQLITE_DONE) + result = LARDON3D_PROJECT_DB_CORRUPT; + } + (void)sqlite3_finalize(statement); + result = optical_commit_or_rollback(database, result, + "commit camera body alias", + "rollback camera body alias"); + (void)pthread_mutex_unlock(&database->mutex); + if (result != LARDON3D_PROJECT_DB_OK) + memset(output, 0, sizeof(*output)); + return result; +} + +Lardon3DProjectDbResult lardon3d_optical_camera_body_find_exact_alias( + Lardon3DProjectDb *database, const char *metadata_make, + const char *metadata_model, Lardon3DOpticalCameraBodyProfile *output) { + if (output) + memset(output, 0, sizeof(*output)); + if (!database || + !optical_text(metadata_make, LARDON3D_OPTICAL_TEXT_CAPACITY, false) || + !optical_text(metadata_model, LARDON3D_OPTICAL_TEXT_CAPACITY, false) || + !output) + return LARDON3D_PROJECT_DB_INVALID_ARGUMENT; + (void)pthread_mutex_lock(&database->mutex); + sqlite3_stmt *statement = NULL; + Lardon3DProjectDbResult result = prepare( + database, + "SELECT b.camera_body_profile_id,b.manufacturer,b.model,b.name,a.alias_id " + "FROM camera_body_aliases a LEFT JOIN camera_body_profiles b ON " + "b.camera_body_profile_id=a.camera_body_profile_id WHERE " + "a.metadata_make=?1 AND a.metadata_model=?2", + &statement); + if (result == LARDON3D_PROJECT_DB_OK) { + (void)sqlite3_bind_text(statement, 1, metadata_make, -1, SQLITE_TRANSIENT); + (void)sqlite3_bind_text(statement, 2, metadata_model, -1, SQLITE_TRANSIENT); + int code = sqlite3_step(statement); + if (code == SQLITE_DONE) + result = LARDON3D_PROJECT_DB_NOT_FOUND; + else if (code != SQLITE_ROW) + result = sqlite_result(database, code, "find exact camera body alias"); + else if (!read_camera_body(statement, output) || + sqlite3_column_type(statement, 4) != SQLITE_INTEGER || + sqlite3_column_int64(statement, 4) <= 0 || + sqlite3_step(statement) != SQLITE_DONE) + result = LARDON3D_PROJECT_DB_CORRUPT; + } + (void)sqlite3_finalize(statement); + (void)pthread_mutex_unlock(&database->mutex); + if (result != LARDON3D_PROJECT_DB_OK) + memset(output, 0, sizeof(*output)); + return result; +} + +Lardon3DProjectDbResult lardon3d_optical_camera_body_alias_list( + Lardon3DProjectDb *database, uint64_t camera_body_profile_id, + uint64_t after_alias_id, Lardon3DOpticalCameraBodyAlias *items, + size_t capacity, size_t *count, uint64_t *next_after_alias_id) { + if (count) + *count = 0; + if (next_after_alias_id) + *next_after_alias_id = after_alias_id; + if (!database || !optical_id(camera_body_profile_id) || + !optical_page_arguments(after_alias_id, items, capacity, count, + next_after_alias_id)) + return LARDON3D_PROJECT_DB_INVALID_ARGUMENT; + memset(items, 0, capacity * sizeof(*items)); + (void)pthread_mutex_lock(&database->mutex); + Lardon3DOpticalCameraBodyProfile body; + Lardon3DProjectDbResult result = + camera_body_load_locked(database, camera_body_profile_id, &body); + sqlite3_stmt *statement = NULL; + if (result == LARDON3D_PROJECT_DB_OK) + result = prepare( + database, + "SELECT alias_id,camera_body_profile_id,metadata_make,metadata_model " + "FROM camera_body_aliases WHERE camera_body_profile_id=?1 AND " + "alias_id>?2 ORDER BY alias_id LIMIT ?3", + &statement); + if (result == LARDON3D_PROJECT_DB_OK) { + (void)sqlite3_bind_int64(statement, 1, (sqlite3_int64)camera_body_profile_id); + (void)sqlite3_bind_int64(statement, 2, (sqlite3_int64)after_alias_id); + (void)sqlite3_bind_int64(statement, 3, (sqlite3_int64)capacity); + int code = SQLITE_DONE; + while (*count < capacity && (code = sqlite3_step(statement)) == SQLITE_ROW) { + if (!read_camera_alias(statement, &items[*count]) || + items[*count].camera_body_profile_id != camera_body_profile_id) { + result = LARDON3D_PROJECT_DB_CORRUPT; + break; + } + *next_after_alias_id = items[*count].alias_id; + ++*count; + } + if (result == LARDON3D_PROJECT_DB_OK && code != SQLITE_DONE && + *count < capacity) + result = sqlite_result(database, code, "list camera body aliases"); + } + (void)sqlite3_finalize(statement); + (void)pthread_mutex_unlock(&database->mutex); + if (result != LARDON3D_PROJECT_DB_OK) { + memset(items, 0, capacity * sizeof(*items)); + *count = 0; + *next_after_alias_id = after_alias_id; + } + return result; +} + +static bool lens_values_valid(Lardon3DOpticalLensInterface interface_kind, + Lardon3DOpticalFocalRangeKind range_kind, + uint32_t minimum_focal_um, + uint32_t maximum_focal_um) { + if (interface_kind < LARDON3D_OPTICAL_LENS_MANUAL || + interface_kind > LARDON3D_OPTICAL_LENS_INTEGRATED) + return false; + if (range_kind == LARDON3D_OPTICAL_FOCAL_RANGE_UNKNOWN) + return minimum_focal_um == 0 && maximum_focal_um == 0; + if (range_kind == LARDON3D_OPTICAL_FOCAL_RANGE_PRIME) + return minimum_focal_um > 0 && minimum_focal_um == maximum_focal_um; + if (range_kind == LARDON3D_OPTICAL_FOCAL_RANGE_ZOOM) + return minimum_focal_um > 0 && minimum_focal_um < maximum_focal_um; + return false; +} + +static bool read_lens(sqlite3_stmt *statement, + Lardon3DOpticalLensProfile *output) { + memset(output, 0, sizeof(*output)); + sqlite3_int64 id = sqlite3_column_int64(statement, 0); + sqlite3_int64 interface_kind = sqlite3_column_int64(statement, 4); + sqlite3_int64 range_kind = sqlite3_column_int64(statement, 5); + sqlite3_int64 minimum_focal = sqlite3_column_int64(statement, 6); + sqlite3_int64 maximum_focal = sqlite3_column_int64(statement, 7); + if (sqlite3_column_type(statement, 0) != SQLITE_INTEGER || id <= 0 || + !optical_copy_text(statement, 1, output->manufacturer, + sizeof(output->manufacturer), true) || + !optical_copy_text(statement, 2, output->model, sizeof(output->model), + true) || + !optical_copy_text(statement, 3, output->name, sizeof(output->name), + false) || + sqlite3_column_type(statement, 4) != SQLITE_INTEGER || + sqlite3_column_type(statement, 5) != SQLITE_INTEGER || + sqlite3_column_type(statement, 6) != SQLITE_INTEGER || + sqlite3_column_type(statement, 7) != SQLITE_INTEGER || + interface_kind < 1 || interface_kind > 3 || range_kind < 1 || + range_kind > 3 || minimum_focal < 0 || minimum_focal > UINT32_MAX || + maximum_focal < 0 || maximum_focal > UINT32_MAX || + !lens_values_valid((Lardon3DOpticalLensInterface)interface_kind, + (Lardon3DOpticalFocalRangeKind)range_kind, + (uint32_t)minimum_focal, + (uint32_t)maximum_focal)) { + memset(output, 0, sizeof(*output)); + return false; + } + output->lens_profile_id = (uint64_t)id; + output->interface_kind = (Lardon3DOpticalLensInterface)interface_kind; + output->focal_range_kind = (Lardon3DOpticalFocalRangeKind)range_kind; + output->minimum_focal_um = (uint32_t)minimum_focal; + output->maximum_focal_um = (uint32_t)maximum_focal; + return true; +} + +static Lardon3DProjectDbResult lens_load_locked( + Lardon3DProjectDb *database, uint64_t profile_id, + Lardon3DOpticalLensProfile *output) { + sqlite3_stmt *statement = NULL; + Lardon3DProjectDbResult result = prepare( + database, + "SELECT lens_profile_id,manufacturer,model,name,interface_kind," + "focal_range_kind,minimum_focal_um,maximum_focal_um FROM lens_profiles " + "WHERE lens_profile_id=?1", + &statement); + if (result == LARDON3D_PROJECT_DB_OK) { + (void)sqlite3_bind_int64(statement, 1, (sqlite3_int64)profile_id); + int code = sqlite3_step(statement); + if (code == SQLITE_DONE) + result = LARDON3D_PROJECT_DB_NOT_FOUND; + else if (code != SQLITE_ROW) + result = sqlite_result(database, code, "load lens profile"); + else if (!read_lens(statement, output) || + output->lens_profile_id != profile_id || + sqlite3_step(statement) != SQLITE_DONE) + result = LARDON3D_PROJECT_DB_CORRUPT; + } + (void)sqlite3_finalize(statement); + if (result != LARDON3D_PROJECT_DB_OK) + memset(output, 0, sizeof(*output)); + return result; +} + +Lardon3DProjectDbResult lardon3d_optical_lens_create( + Lardon3DProjectDb *database, const Lardon3DOpticalLensProfile *input, + Lardon3DOpticalLensProfile *output) { + if (output) + memset(output, 0, sizeof(*output)); + if (!database || !input || !output || input == output || + input->lens_profile_id != 0 || + !optical_text(input->manufacturer, sizeof(input->manufacturer), true) || + !optical_text(input->model, sizeof(input->model), true) || + !optical_text(input->name, sizeof(input->name), false) || + !lens_values_valid(input->interface_kind, input->focal_range_kind, + input->minimum_focal_um, input->maximum_focal_um)) + return LARDON3D_PROJECT_DB_INVALID_ARGUMENT; + (void)pthread_mutex_lock(&database->mutex); + Lardon3DProjectDbResult result = + execute(database, "BEGIN IMMEDIATE", "begin lens profile"); + sqlite3_stmt *statement = NULL; + if (result == LARDON3D_PROJECT_DB_OK) + result = prepare( + database, + "INSERT OR IGNORE INTO lens_profiles(manufacturer,model,name," + "interface_kind,focal_range_kind,minimum_focal_um,maximum_focal_um) " + "VALUES(?1,?2,?3,?4,?5,?6,?7)", + &statement); + if (result == LARDON3D_PROJECT_DB_OK) { + (void)sqlite3_bind_text(statement, 1, input->manufacturer, -1, + SQLITE_TRANSIENT); + (void)sqlite3_bind_text(statement, 2, input->model, -1, SQLITE_TRANSIENT); + (void)sqlite3_bind_text(statement, 3, input->name, -1, SQLITE_TRANSIENT); + (void)sqlite3_bind_int64(statement, 4, input->interface_kind); + (void)sqlite3_bind_int64(statement, 5, input->focal_range_kind); + (void)sqlite3_bind_int64(statement, 6, input->minimum_focal_um); + (void)sqlite3_bind_int64(statement, 7, input->maximum_focal_um); + result = step_done(database, statement, "insert lens profile"); + statement = NULL; + } + uint64_t profile_id = 0; + if (result == LARDON3D_PROJECT_DB_OK) + result = prepare( + database, + "SELECT lens_profile_id,interface_kind,focal_range_kind,minimum_focal_um," + "maximum_focal_um FROM lens_profiles WHERE manufacturer=?1 AND model=?2 " + "AND name=?3", + &statement); + if (result == LARDON3D_PROJECT_DB_OK) { + (void)sqlite3_bind_text(statement, 1, input->manufacturer, -1, + SQLITE_TRANSIENT); + (void)sqlite3_bind_text(statement, 2, input->model, -1, SQLITE_TRANSIENT); + (void)sqlite3_bind_text(statement, 3, input->name, -1, SQLITE_TRANSIENT); + int code = sqlite3_step(statement); + if (code == SQLITE_DONE) { + result = LARDON3D_PROJECT_DB_CORRUPT; + } else if (code != SQLITE_ROW) { + result = sqlite_result(database, code, "find lens profile"); + } else if (sqlite3_column_type(statement, 0) != SQLITE_INTEGER || + sqlite3_column_int64(statement, 0) <= 0 || + sqlite3_column_type(statement, 1) != SQLITE_INTEGER || + sqlite3_column_type(statement, 2) != SQLITE_INTEGER || + sqlite3_column_type(statement, 3) != SQLITE_INTEGER || + sqlite3_column_type(statement, 4) != SQLITE_INTEGER) { + result = LARDON3D_PROJECT_DB_CORRUPT; + } else if (sqlite3_column_int64(statement, 1) != input->interface_kind || + sqlite3_column_int64(statement, 2) != input->focal_range_kind || + sqlite3_column_int64(statement, 3) != input->minimum_focal_um || + sqlite3_column_int64(statement, 4) != input->maximum_focal_um) { + result = LARDON3D_PROJECT_DB_CONSTRAINT; + } else { + profile_id = (uint64_t)sqlite3_column_int64(statement, 0); + if (sqlite3_step(statement) != SQLITE_DONE) + result = LARDON3D_PROJECT_DB_CORRUPT; + } + } + (void)sqlite3_finalize(statement); + result = optical_commit_or_rollback(database, result, "commit lens profile", + "rollback lens profile"); + if (result == LARDON3D_PROJECT_DB_OK) + result = lens_load_locked(database, profile_id, output); + (void)pthread_mutex_unlock(&database->mutex); + return result; +} + +Lardon3DProjectDbResult lardon3d_optical_lens_load( + Lardon3DProjectDb *database, uint64_t lens_profile_id, + Lardon3DOpticalLensProfile *output) { + if (output) + memset(output, 0, sizeof(*output)); + if (!database || !optical_id(lens_profile_id) || !output) + return LARDON3D_PROJECT_DB_INVALID_ARGUMENT; + (void)pthread_mutex_lock(&database->mutex); + Lardon3DProjectDbResult result = + lens_load_locked(database, lens_profile_id, output); + (void)pthread_mutex_unlock(&database->mutex); + return result; +} + +Lardon3DProjectDbResult lardon3d_optical_lens_list( + Lardon3DProjectDb *database, uint64_t after_profile_id, + Lardon3DOpticalLensProfile *items, size_t capacity, size_t *count, + uint64_t *next_after_profile_id) { + if (count) + *count = 0; + if (next_after_profile_id) + *next_after_profile_id = after_profile_id; + if (!database || !optical_page_arguments(after_profile_id, items, capacity, + count, next_after_profile_id)) + return LARDON3D_PROJECT_DB_INVALID_ARGUMENT; + memset(items, 0, capacity * sizeof(*items)); + (void)pthread_mutex_lock(&database->mutex); + sqlite3_stmt *statement = NULL; + Lardon3DProjectDbResult result = prepare( + database, + "SELECT lens_profile_id,manufacturer,model,name,interface_kind," + "focal_range_kind,minimum_focal_um,maximum_focal_um FROM lens_profiles " + "WHERE lens_profile_id>?1 ORDER BY lens_profile_id LIMIT ?2", + &statement); + if (result == LARDON3D_PROJECT_DB_OK) { + (void)sqlite3_bind_int64(statement, 1, (sqlite3_int64)after_profile_id); + (void)sqlite3_bind_int64(statement, 2, (sqlite3_int64)capacity); + int code = SQLITE_DONE; + while (*count < capacity && (code = sqlite3_step(statement)) == SQLITE_ROW) { + if (!read_lens(statement, &items[*count])) { + result = LARDON3D_PROJECT_DB_CORRUPT; + break; + } + *next_after_profile_id = items[*count].lens_profile_id; + ++*count; + } + if (result == LARDON3D_PROJECT_DB_OK && code != SQLITE_DONE && + *count < capacity) + result = sqlite_result(database, code, "list lens profiles"); + } + (void)sqlite3_finalize(statement); + (void)pthread_mutex_unlock(&database->mutex); + if (result != LARDON3D_PROJECT_DB_OK) { + memset(items, 0, capacity * sizeof(*items)); + *count = 0; + *next_after_profile_id = after_profile_id; + } + return result; +} + +static bool read_lens_alias(sqlite3_stmt *statement, + Lardon3DOpticalLensAlias *output) { + memset(output, 0, sizeof(*output)); + sqlite3_int64 alias_id = sqlite3_column_int64(statement, 0); + sqlite3_int64 profile_id = sqlite3_column_int64(statement, 1); + if (sqlite3_column_type(statement, 0) != SQLITE_INTEGER || alias_id <= 0 || + sqlite3_column_type(statement, 1) != SQLITE_INTEGER || profile_id <= 0 || + !optical_copy_text(statement, 2, output->metadata_make, + sizeof(output->metadata_make), true) || + !optical_copy_text(statement, 3, output->metadata_model, + sizeof(output->metadata_model), false)) { + memset(output, 0, sizeof(*output)); + return false; + } + output->alias_id = (uint64_t)alias_id; + output->lens_profile_id = (uint64_t)profile_id; + return true; +} + +Lardon3DProjectDbResult lardon3d_optical_lens_alias_add( + Lardon3DProjectDb *database, uint64_t lens_profile_id, + const char *metadata_make, const char *metadata_model, + Lardon3DOpticalLensAlias *output) { + if (output) + memset(output, 0, sizeof(*output)); + if (!database || !optical_id(lens_profile_id) || + !optical_text(metadata_make, LARDON3D_OPTICAL_TEXT_CAPACITY, true) || + !optical_text(metadata_model, LARDON3D_OPTICAL_TEXT_CAPACITY, false) || + !output) + return LARDON3D_PROJECT_DB_INVALID_ARGUMENT; + (void)pthread_mutex_lock(&database->mutex); + Lardon3DProjectDbResult result = + execute(database, "BEGIN IMMEDIATE", "begin lens alias"); + Lardon3DOpticalLensProfile lens; + if (result == LARDON3D_PROJECT_DB_OK) + result = lens_load_locked(database, lens_profile_id, &lens); + sqlite3_stmt *statement = NULL; + if (result == LARDON3D_PROJECT_DB_OK) + result = prepare( + database, + "INSERT OR IGNORE INTO lens_profile_aliases(lens_profile_id," + "metadata_make,metadata_model) VALUES(?1,?2,?3)", + &statement); + if (result == LARDON3D_PROJECT_DB_OK) { + (void)sqlite3_bind_int64(statement, 1, (sqlite3_int64)lens_profile_id); + (void)sqlite3_bind_text(statement, 2, metadata_make, -1, SQLITE_TRANSIENT); + (void)sqlite3_bind_text(statement, 3, metadata_model, -1, SQLITE_TRANSIENT); + result = step_done(database, statement, "insert lens alias"); + statement = NULL; + } + if (result == LARDON3D_PROJECT_DB_OK) + result = prepare( + database, + "SELECT alias_id,lens_profile_id,metadata_make,metadata_model FROM " + "lens_profile_aliases WHERE metadata_make=?1 AND metadata_model=?2", + &statement); + if (result == LARDON3D_PROJECT_DB_OK) { + (void)sqlite3_bind_text(statement, 1, metadata_make, -1, SQLITE_TRANSIENT); + (void)sqlite3_bind_text(statement, 2, metadata_model, -1, SQLITE_TRANSIENT); + int code = sqlite3_step(statement); + if (code == SQLITE_DONE) + result = LARDON3D_PROJECT_DB_CORRUPT; + else if (code != SQLITE_ROW) + result = sqlite_result(database, code, "find lens alias"); + else if (!read_lens_alias(statement, output)) + result = LARDON3D_PROJECT_DB_CORRUPT; + else if (output->lens_profile_id != lens_profile_id) + result = LARDON3D_PROJECT_DB_CONSTRAINT; + else if (sqlite3_step(statement) != SQLITE_DONE) + result = LARDON3D_PROJECT_DB_CORRUPT; + } + (void)sqlite3_finalize(statement); + result = optical_commit_or_rollback(database, result, "commit lens alias", + "rollback lens alias"); + (void)pthread_mutex_unlock(&database->mutex); + if (result != LARDON3D_PROJECT_DB_OK) + memset(output, 0, sizeof(*output)); + return result; +} + +Lardon3DProjectDbResult lardon3d_optical_lens_find_exact_alias( + Lardon3DProjectDb *database, const char *metadata_make, + const char *metadata_model, Lardon3DOpticalLensProfile *output) { + if (output) + memset(output, 0, sizeof(*output)); + if (!database || + !optical_text(metadata_make, LARDON3D_OPTICAL_TEXT_CAPACITY, true) || + !optical_text(metadata_model, LARDON3D_OPTICAL_TEXT_CAPACITY, false) || + !output) + return LARDON3D_PROJECT_DB_INVALID_ARGUMENT; + (void)pthread_mutex_lock(&database->mutex); + sqlite3_stmt *statement = NULL; + Lardon3DProjectDbResult result = prepare( + database, + "SELECT l.lens_profile_id,l.manufacturer,l.model,l.name,l.interface_kind," + "l.focal_range_kind,l.minimum_focal_um,l.maximum_focal_um,a.alias_id FROM " + "lens_profile_aliases a LEFT JOIN lens_profiles l ON l.lens_profile_id=" + "a.lens_profile_id WHERE a.metadata_make=?1 AND a.metadata_model=?2", + &statement); + if (result == LARDON3D_PROJECT_DB_OK) { + (void)sqlite3_bind_text(statement, 1, metadata_make, -1, SQLITE_TRANSIENT); + (void)sqlite3_bind_text(statement, 2, metadata_model, -1, SQLITE_TRANSIENT); + int code = sqlite3_step(statement); + if (code == SQLITE_DONE) + result = LARDON3D_PROJECT_DB_NOT_FOUND; + else if (code != SQLITE_ROW) + result = sqlite_result(database, code, "find exact lens alias"); + else if (!read_lens(statement, output) || + sqlite3_column_type(statement, 8) != SQLITE_INTEGER || + sqlite3_column_int64(statement, 8) <= 0 || + sqlite3_step(statement) != SQLITE_DONE) + result = LARDON3D_PROJECT_DB_CORRUPT; + } + (void)sqlite3_finalize(statement); + (void)pthread_mutex_unlock(&database->mutex); + if (result != LARDON3D_PROJECT_DB_OK) + memset(output, 0, sizeof(*output)); + return result; +} + +Lardon3DProjectDbResult lardon3d_optical_lens_alias_list( + Lardon3DProjectDb *database, uint64_t lens_profile_id, + uint64_t after_alias_id, Lardon3DOpticalLensAlias *items, size_t capacity, + size_t *count, uint64_t *next_after_alias_id) { + if (count) + *count = 0; + if (next_after_alias_id) + *next_after_alias_id = after_alias_id; + if (!database || !optical_id(lens_profile_id) || + !optical_page_arguments(after_alias_id, items, capacity, count, + next_after_alias_id)) + return LARDON3D_PROJECT_DB_INVALID_ARGUMENT; + memset(items, 0, capacity * sizeof(*items)); + (void)pthread_mutex_lock(&database->mutex); + Lardon3DOpticalLensProfile lens; + Lardon3DProjectDbResult result = + lens_load_locked(database, lens_profile_id, &lens); + sqlite3_stmt *statement = NULL; + if (result == LARDON3D_PROJECT_DB_OK) + result = prepare( + database, + "SELECT alias_id,lens_profile_id,metadata_make,metadata_model FROM " + "lens_profile_aliases WHERE lens_profile_id=?1 AND alias_id>?2 ORDER BY " + "alias_id LIMIT ?3", + &statement); + if (result == LARDON3D_PROJECT_DB_OK) { + (void)sqlite3_bind_int64(statement, 1, (sqlite3_int64)lens_profile_id); + (void)sqlite3_bind_int64(statement, 2, (sqlite3_int64)after_alias_id); + (void)sqlite3_bind_int64(statement, 3, (sqlite3_int64)capacity); + int code = SQLITE_DONE; + while (*count < capacity && (code = sqlite3_step(statement)) == SQLITE_ROW) { + if (!read_lens_alias(statement, &items[*count]) || + items[*count].lens_profile_id != lens_profile_id) { + result = LARDON3D_PROJECT_DB_CORRUPT; + break; + } + *next_after_alias_id = items[*count].alias_id; + ++*count; + } + if (result == LARDON3D_PROJECT_DB_OK && code != SQLITE_DONE && + *count < capacity) + result = sqlite_result(database, code, "list lens aliases"); + } + (void)sqlite3_finalize(statement); + (void)pthread_mutex_unlock(&database->mutex); + if (result != LARDON3D_PROJECT_DB_OK) { + memset(items, 0, capacity * sizeof(*items)); + *count = 0; + *next_after_alias_id = after_alias_id; + } + return result; +} + +static bool focal_valid_for_lens(const Lardon3DOpticalLensProfile *lens, + uint32_t focal_length_um) { + if (focal_length_um == 0) + return true; + if (lens->focal_range_kind == LARDON3D_OPTICAL_FOCAL_RANGE_UNKNOWN) + return true; + if (lens->focal_range_kind == LARDON3D_OPTICAL_FOCAL_RANGE_PRIME) + return focal_length_um == lens->minimum_focal_um; + return focal_length_um >= lens->minimum_focal_um && + focal_length_um <= lens->maximum_focal_um; +} + +static bool read_configuration(sqlite3_stmt *statement, + Lardon3DOpticalConfiguration *output) { + memset(output, 0, sizeof(*output)); + sqlite3_int64 configuration_id = sqlite3_column_int64(statement, 0); + sqlite3_int64 body_id = sqlite3_column_int64(statement, 1); + sqlite3_int64 lens_id = sqlite3_column_int64(statement, 2); + sqlite3_int64 focal = sqlite3_column_int64(statement, 3); + sqlite3_int64 joined_body_id = sqlite3_column_int64(statement, 4); + sqlite3_int64 joined_lens_id = sqlite3_column_int64(statement, 5); + sqlite3_int64 interface_kind = sqlite3_column_int64(statement, 6); + sqlite3_int64 range_kind = sqlite3_column_int64(statement, 7); + sqlite3_int64 minimum_focal = sqlite3_column_int64(statement, 8); + sqlite3_int64 maximum_focal = sqlite3_column_int64(statement, 9); + for (int column = 0; column < 10; ++column) + if (sqlite3_column_type(statement, column) != SQLITE_INTEGER) + return false; + if (configuration_id <= 0 || body_id <= 0 || lens_id <= 0 || + joined_body_id != body_id || joined_lens_id != lens_id || focal < 0 || + focal > UINT32_MAX || interface_kind < 1 || interface_kind > 3 || + range_kind < 1 || range_kind > 3 || minimum_focal < 0 || + minimum_focal > UINT32_MAX || maximum_focal < 0 || + maximum_focal > UINT32_MAX) + return false; + Lardon3DOpticalLensProfile lens = { + .interface_kind = (Lardon3DOpticalLensInterface)interface_kind, + .focal_range_kind = (Lardon3DOpticalFocalRangeKind)range_kind, + .minimum_focal_um = (uint32_t)minimum_focal, + .maximum_focal_um = (uint32_t)maximum_focal, + }; + if (!lens_values_valid(lens.interface_kind, lens.focal_range_kind, + lens.minimum_focal_um, lens.maximum_focal_um) || + !focal_valid_for_lens(&lens, (uint32_t)focal)) + return false; + output->optical_configuration_id = (uint64_t)configuration_id; + output->camera_body_profile_id = (uint64_t)body_id; + output->lens_profile_id = (uint64_t)lens_id; + output->has_focal_length = focal != 0; + output->focal_length_um = (uint32_t)focal; + return true; +} + +static const char configuration_select[] = + "SELECT o.optical_configuration_id,o.camera_body_profile_id," + "o.lens_profile_id,o.focal_length_um,b.camera_body_profile_id," + "l.lens_profile_id,l.interface_kind,l.focal_range_kind," + "l.minimum_focal_um,l.maximum_focal_um FROM optical_configurations o " + "LEFT JOIN camera_body_profiles b ON b.camera_body_profile_id=" + "o.camera_body_profile_id LEFT JOIN lens_profiles l ON l.lens_profile_id=" + "o.lens_profile_id "; + +static Lardon3DProjectDbResult configuration_dependencies_locked( + Lardon3DProjectDb *database, + const Lardon3DOpticalConfiguration *configuration) { + Lardon3DOpticalCameraBodyProfile body = {0}; + Lardon3DOpticalLensProfile lens = {0}; + Lardon3DProjectDbResult result = camera_body_load_locked( + database, configuration->camera_body_profile_id, &body); + if (result == LARDON3D_PROJECT_DB_OK) + result = lens_load_locked(database, configuration->lens_profile_id, &lens); + /* Foreign keys prevent ordinary orphaning, but loaders must still reject a + database altered with constraint enforcement disabled. A surviving ID is + not enough when the referenced typed profile itself is malformed. */ + if (result == LARDON3D_PROJECT_DB_NOT_FOUND) + result = LARDON3D_PROJECT_DB_CORRUPT; + return result; +} + +static Lardon3DProjectDbResult configuration_load_locked( + Lardon3DProjectDb *database, uint64_t configuration_id, + Lardon3DOpticalConfiguration *output) { + memset(output, 0, sizeof(*output)); + sqlite3_stmt *statement = NULL; + char query[1024]; + int length = snprintf(query, sizeof(query), "%s WHERE o.optical_configuration_id=?1", + configuration_select); + if (length < 0 || (size_t)length >= sizeof(query)) + return LARDON3D_PROJECT_DB_IO_ERROR; + Lardon3DProjectDbResult result = prepare(database, query, &statement); + if (result == LARDON3D_PROJECT_DB_OK) { + (void)sqlite3_bind_int64(statement, 1, (sqlite3_int64)configuration_id); + int code = sqlite3_step(statement); + if (code == SQLITE_DONE) + result = LARDON3D_PROJECT_DB_NOT_FOUND; + else if (code != SQLITE_ROW) + result = sqlite_result(database, code, "load optical configuration"); + else if (!read_configuration(statement, output) || + output->optical_configuration_id != configuration_id || + sqlite3_step(statement) != SQLITE_DONE) + result = LARDON3D_PROJECT_DB_CORRUPT; + } + (void)sqlite3_finalize(statement); + if (result == LARDON3D_PROJECT_DB_OK) + result = configuration_dependencies_locked(database, output); + if (result != LARDON3D_PROJECT_DB_OK) + memset(output, 0, sizeof(*output)); + return result; +} + +Lardon3DProjectDbResult lardon3d_optical_configuration_create( + Lardon3DProjectDb *database, const Lardon3DOpticalConfiguration *input, + Lardon3DOpticalConfiguration *output) { + if (output) + memset(output, 0, sizeof(*output)); + if (!database || !input || !output || input == output || + input->optical_configuration_id != 0 || + !optical_id(input->camera_body_profile_id) || + !optical_id(input->lens_profile_id) || + (input->has_focal_length && input->focal_length_um == 0) || + (!input->has_focal_length && input->focal_length_um != 0)) + return LARDON3D_PROJECT_DB_INVALID_ARGUMENT; + (void)pthread_mutex_lock(&database->mutex); + Lardon3DProjectDbResult result = + execute(database, "BEGIN IMMEDIATE", "begin optical configuration"); + Lardon3DOpticalCameraBodyProfile body = {0}; + Lardon3DOpticalLensProfile lens = {0}; + if (result == LARDON3D_PROJECT_DB_OK) + result = camera_body_load_locked(database, input->camera_body_profile_id, + &body); + if (result == LARDON3D_PROJECT_DB_OK) + result = lens_load_locked(database, input->lens_profile_id, &lens); + if (result == LARDON3D_PROJECT_DB_OK && + !focal_valid_for_lens(&lens, input->focal_length_um)) + result = LARDON3D_PROJECT_DB_CONSTRAINT; + sqlite3_stmt *statement = NULL; + if (result == LARDON3D_PROJECT_DB_OK) + result = prepare( + database, + "INSERT OR IGNORE INTO optical_configurations(camera_body_profile_id," + "lens_profile_id,focal_length_um) VALUES(?1,?2,?3)", + &statement); + if (result == LARDON3D_PROJECT_DB_OK) { + (void)sqlite3_bind_int64(statement, 1, + (sqlite3_int64)input->camera_body_profile_id); + (void)sqlite3_bind_int64(statement, 2, + (sqlite3_int64)input->lens_profile_id); + (void)sqlite3_bind_int64(statement, 3, input->focal_length_um); + result = step_done(database, statement, "insert optical configuration"); + statement = NULL; + } + uint64_t configuration_id = 0; + if (result == LARDON3D_PROJECT_DB_OK) + result = prepare( + database, + "SELECT optical_configuration_id FROM optical_configurations WHERE " + "camera_body_profile_id=?1 AND lens_profile_id=?2 AND focal_length_um=?3", + &statement); + if (result == LARDON3D_PROJECT_DB_OK) { + (void)sqlite3_bind_int64(statement, 1, + (sqlite3_int64)input->camera_body_profile_id); + (void)sqlite3_bind_int64(statement, 2, + (sqlite3_int64)input->lens_profile_id); + (void)sqlite3_bind_int64(statement, 3, input->focal_length_um); + int code = sqlite3_step(statement); + if (code == SQLITE_DONE) { + result = LARDON3D_PROJECT_DB_CORRUPT; + } else if (code != SQLITE_ROW) { + result = sqlite_result(database, code, "find optical configuration"); + } else if (sqlite3_column_type(statement, 0) != SQLITE_INTEGER || + sqlite3_column_int64(statement, 0) <= 0) { + result = LARDON3D_PROJECT_DB_CORRUPT; + } else { + configuration_id = (uint64_t)sqlite3_column_int64(statement, 0); + if (sqlite3_step(statement) != SQLITE_DONE) + result = LARDON3D_PROJECT_DB_CORRUPT; + } + } + (void)sqlite3_finalize(statement); + result = optical_commit_or_rollback(database, result, + "commit optical configuration", + "rollback optical configuration"); + if (result == LARDON3D_PROJECT_DB_OK) + result = configuration_load_locked(database, configuration_id, output); + (void)pthread_mutex_unlock(&database->mutex); + return result; +} + +Lardon3DProjectDbResult lardon3d_optical_configuration_load( + Lardon3DProjectDb *database, uint64_t optical_configuration_id, + Lardon3DOpticalConfiguration *output) { + if (output) + memset(output, 0, sizeof(*output)); + if (!database || !optical_id(optical_configuration_id) || !output) + return LARDON3D_PROJECT_DB_INVALID_ARGUMENT; + (void)pthread_mutex_lock(&database->mutex); + Lardon3DProjectDbResult result = configuration_load_locked( + database, optical_configuration_id, output); + (void)pthread_mutex_unlock(&database->mutex); + return result; +} + +Lardon3DProjectDbResult lardon3d_optical_configuration_list( + Lardon3DProjectDb *database, uint64_t after_configuration_id, + Lardon3DOpticalConfiguration *items, size_t capacity, size_t *count, + uint64_t *next_after_configuration_id) { + if (count) + *count = 0; + if (next_after_configuration_id) + *next_after_configuration_id = after_configuration_id; + if (!database || + !optical_page_arguments(after_configuration_id, items, capacity, count, + next_after_configuration_id)) + return LARDON3D_PROJECT_DB_INVALID_ARGUMENT; + memset(items, 0, capacity * sizeof(*items)); + (void)pthread_mutex_lock(&database->mutex); + sqlite3_stmt *statement = NULL; + char query[1024]; + int length = snprintf(query, sizeof(query), + "%s WHERE o.optical_configuration_id>?1 ORDER BY " + "o.optical_configuration_id LIMIT ?2", + configuration_select); + Lardon3DProjectDbResult result = + length < 0 || (size_t)length >= sizeof(query) + ? LARDON3D_PROJECT_DB_IO_ERROR + : prepare(database, query, &statement); + if (result == LARDON3D_PROJECT_DB_OK) { + (void)sqlite3_bind_int64(statement, 1, + (sqlite3_int64)after_configuration_id); + (void)sqlite3_bind_int64(statement, 2, (sqlite3_int64)capacity); + int code = SQLITE_DONE; + while (*count < capacity && (code = sqlite3_step(statement)) == SQLITE_ROW) { + if (!read_configuration(statement, &items[*count])) { + result = LARDON3D_PROJECT_DB_CORRUPT; + break; + } + result = configuration_dependencies_locked(database, &items[*count]); + if (result != LARDON3D_PROJECT_DB_OK) + break; + *next_after_configuration_id = + items[*count].optical_configuration_id; + ++*count; + } + if (result == LARDON3D_PROJECT_DB_OK && code != SQLITE_DONE && + *count < capacity) + result = sqlite_result(database, code, "list optical configurations"); + } + (void)sqlite3_finalize(statement); + (void)pthread_mutex_unlock(&database->mutex); + if (result != LARDON3D_PROJECT_DB_OK) { + memset(items, 0, capacity * sizeof(*items)); + *count = 0; + *next_after_configuration_id = after_configuration_id; + } + return result; +} + +static Lardon3DProjectDbResult validate_campaign_for_optics_locked( + Lardon3DProjectDb *database, uint64_t task_id, uint32_t group_id, + sqlite3_int64 *cursor, sqlite3_int64 *mapping_count) { + sqlite3_stmt *statement = NULL; + Lardon3DProjectDbResult result = prepare( + database, + "SELECT c.next_group_id,c.group_count,t.task_kind,t.task_kind_version," + "c.scanset_id,s.scanset_id," + "(SELECT COUNT(*) FROM acquisition_campaign_captures m WHERE " + "m.task_id=c.task_id)," + "(SELECT COUNT(CASE WHEN typeof(m.task_id)!='integer' OR " + "typeof(m.group_id)!='integer' OR m.group_id<1 OR " + "m.group_id>c.next_group_id OR m.group_id>c.group_count OR " + "typeof(m.capture_id)!='integer' OR m.capture_id<=0 OR p.capture_id IS " + "NULL OR typeof(p.scanset_id)!='integer' OR p.scanset_id<=0 OR " + "p.scanset_id!=c.scanset_id THEN 1 END) FROM " + "acquisition_campaign_captures m LEFT JOIN captures p ON " + "p.capture_id=m.capture_id WHERE m.task_id=c.task_id)," + "(SELECT MIN(m.group_id) FROM acquisition_campaign_captures m WHERE " + "m.task_id=c.task_id),(SELECT MAX(m.group_id) FROM " + "acquisition_campaign_captures m WHERE m.task_id=c.task_id),c.request FROM " + "acquisition_campaign_tasks c LEFT JOIN tasks t ON t.task_id=c.task_id " + "LEFT JOIN scansets s ON s.scanset_id=c.scanset_id WHERE c.task_id=?1", + &statement); + if (result == LARDON3D_PROJECT_DB_OK) { + (void)sqlite3_bind_int64(statement, 1, (sqlite3_int64)task_id); + int code = sqlite3_step(statement); + if (code == SQLITE_DONE) + result = LARDON3D_PROJECT_DB_NOT_FOUND; + else if (code != SQLITE_ROW) + result = sqlite_result(database, code, "load campaign for optics"); + else { + sqlite3_int64 local_cursor = sqlite3_column_int64(statement, 0); + sqlite3_int64 group_count = sqlite3_column_int64(statement, 1); + sqlite3_int64 task_version = sqlite3_column_int64(statement, 3); + sqlite3_int64 scanset_id = sqlite3_column_int64(statement, 4); + sqlite3_int64 joined_scanset_id = sqlite3_column_int64(statement, 5); + sqlite3_int64 local_mapping_count = sqlite3_column_int64(statement, 6); + sqlite3_int64 invalid_mapping_count = sqlite3_column_int64(statement, 7); + sqlite3_int64 minimum_group_id = sqlite3_column_int64(statement, 8); + sqlite3_int64 maximum_group_id = sqlite3_column_int64(statement, 9); + int minimum_type = sqlite3_column_type(statement, 8); + int maximum_type = sqlite3_column_type(statement, 9); + int request_bytes = sqlite3_column_bytes(statement, 10); + /* INVARIANT: optical assignment may observe only the same exact + one-based retained prefix as campaign recovery. Count/min/max prove + contiguity under the mapping primary key; the invalid aggregate also + rejects ahead rows, bad storage classes and cross-ScanSet Captures. + The immutable request is validated too: optics must never make a + campaign mutable/visible when canonical recovery rejects its payload. */ + bool empty_prefix = local_cursor == 0 && local_mapping_count == 0 && + minimum_type == SQLITE_NULL && + maximum_type == SQLITE_NULL; + bool complete_prefix = + local_cursor > 0 && local_mapping_count == local_cursor && + minimum_type == SQLITE_INTEGER && maximum_type == SQLITE_INTEGER && + minimum_group_id == 1 && maximum_group_id == local_cursor; + if (sqlite3_column_type(statement, 0) != SQLITE_INTEGER || + sqlite3_column_type(statement, 1) != SQLITE_INTEGER || + sqlite3_column_type(statement, 3) != SQLITE_INTEGER || + sqlite3_column_type(statement, 4) != SQLITE_INTEGER || + sqlite3_column_type(statement, 5) != SQLITE_INTEGER || + sqlite3_column_type(statement, 6) != SQLITE_INTEGER || + sqlite3_column_type(statement, 7) != SQLITE_INTEGER || + sqlite3_column_type(statement, 10) != SQLITE_BLOB || + local_cursor < 0 || group_count < 1 || group_count > 4096 || + local_cursor > group_count || group_id == 0 || group_id > group_count || + scanset_id <= 0 || joined_scanset_id != scanset_id || + local_mapping_count < 0 || local_mapping_count > 4096 || + invalid_mapping_count != 0 || (!empty_prefix && !complete_prefix) || + request_bytes <= 0 || + (size_t)request_bytes > + LARDON3D_ACQUISITION_CAMPAIGN_TASK_REQUEST_MAX_BYTES || + sqlite3_column_blob(statement, 10) == NULL || + !optical_column_equals(statement, 2, + LARDON3D_ACQUISITION_CAMPAIGN_TASK_KIND) || + task_version != LARDON3D_ACQUISITION_CAMPAIGN_TASK_KIND_VERSION || + sqlite3_step(statement) != SQLITE_DONE) { + result = LARDON3D_PROJECT_DB_CORRUPT; + } else { + *cursor = local_cursor; + *mapping_count = local_mapping_count; + } + } + } + (void)sqlite3_finalize(statement); + return result; +} + +Lardon3DProjectDbResult lardon3d_optical_campaign_group_assign( + Lardon3DProjectDb *database, uint64_t campaign_task_id, uint32_t group_id, + uint64_t optical_configuration_id) { + if (!database || !optical_id(campaign_task_id) || group_id == 0 || + group_id > 4096 || !optical_id(optical_configuration_id)) + return LARDON3D_PROJECT_DB_INVALID_ARGUMENT; + (void)pthread_mutex_lock(&database->mutex); + Lardon3DProjectDbResult result = + execute(database, "BEGIN IMMEDIATE", "begin campaign group optics"); + Lardon3DOpticalConfiguration configuration; + if (result == LARDON3D_PROJECT_DB_OK) + result = configuration_load_locked(database, optical_configuration_id, + &configuration); + sqlite3_int64 cursor = 0; + sqlite3_int64 mapping_count = 0; + if (result == LARDON3D_PROJECT_DB_OK) + result = validate_campaign_for_optics_locked( + database, campaign_task_id, group_id, &cursor, &mapping_count); + + sqlite3_stmt *statement = NULL; + bool assignment_absent = false; + if (result == LARDON3D_PROJECT_DB_OK) + result = prepare( + database, + "SELECT optical_configuration_id FROM acquisition_campaign_group_optics " + "WHERE task_id=?1 AND group_id=?2", + &statement); + if (result == LARDON3D_PROJECT_DB_OK) { + (void)sqlite3_bind_int64(statement, 1, (sqlite3_int64)campaign_task_id); + (void)sqlite3_bind_int64(statement, 2, group_id); + int code = sqlite3_step(statement); + if (code == SQLITE_DONE) { + assignment_absent = true; + } else if (code != SQLITE_ROW) { + result = sqlite_result(database, code, "load campaign group optics"); + } else if (sqlite3_column_type(statement, 0) != SQLITE_INTEGER || + sqlite3_column_int64(statement, 0) <= 0) { + result = LARDON3D_PROJECT_DB_CORRUPT; + } else if ((uint64_t)sqlite3_column_int64(statement, 0) != + optical_configuration_id) { + result = LARDON3D_PROJECT_DB_CONSTRAINT; + } else if (sqlite3_step(statement) != SQLITE_DONE) { + result = LARDON3D_PROJECT_DB_CORRUPT; + } + } + (void)sqlite3_finalize(statement); + statement = NULL; + + if (result == LARDON3D_PROJECT_DB_OK && assignment_absent && cursor == 0 && + mapping_count != 0) + result = LARDON3D_PROJECT_DB_CORRUPT; + if (result == LARDON3D_PROJECT_DB_OK && assignment_absent && cursor != 0) + result = LARDON3D_PROJECT_DB_CONSTRAINT; + if (result == LARDON3D_PROJECT_DB_OK && assignment_absent) + result = prepare( + database, + "INSERT INTO acquisition_campaign_group_optics(task_id,group_id," + "optical_configuration_id) VALUES(?1,?2,?3)", + &statement); + if (result == LARDON3D_PROJECT_DB_OK && assignment_absent) { + (void)sqlite3_bind_int64(statement, 1, (sqlite3_int64)campaign_task_id); + (void)sqlite3_bind_int64(statement, 2, group_id); + (void)sqlite3_bind_int64(statement, 3, + (sqlite3_int64)optical_configuration_id); + result = step_done(database, statement, "insert campaign group optics"); + statement = NULL; + } + (void)sqlite3_finalize(statement); + result = optical_commit_or_rollback(database, result, + "commit campaign group optics", + "rollback campaign group optics"); + (void)pthread_mutex_unlock(&database->mutex); + return result; +} + +Lardon3DProjectDbResult lardon3d_optical_campaign_group_load( + Lardon3DProjectDb *database, uint64_t campaign_task_id, uint32_t group_id, + Lardon3DOpticalCampaignGroupAssignment *output) { + if (output) + memset(output, 0, sizeof(*output)); + if (!database || !optical_id(campaign_task_id) || group_id == 0 || + group_id > 4096 || !output) + return LARDON3D_PROJECT_DB_INVALID_ARGUMENT; + (void)pthread_mutex_lock(&database->mutex); + sqlite3_stmt *statement = NULL; + Lardon3DProjectDbResult result = prepare( + database, + "SELECT g.task_id,g.group_id,g.optical_configuration_id,c.group_count," + "t.task_kind,t.task_kind_version,o.optical_configuration_id FROM " + "acquisition_campaign_group_optics g LEFT JOIN acquisition_campaign_tasks c " + "ON c.task_id=g.task_id LEFT JOIN tasks t ON t.task_id=g.task_id LEFT JOIN " + "optical_configurations o ON o.optical_configuration_id=" + "g.optical_configuration_id WHERE g.task_id=?1 AND g.group_id=?2", + &statement); + if (result == LARDON3D_PROJECT_DB_OK) { + (void)sqlite3_bind_int64(statement, 1, (sqlite3_int64)campaign_task_id); + (void)sqlite3_bind_int64(statement, 2, group_id); + int code = sqlite3_step(statement); + if (code == SQLITE_DONE) + result = LARDON3D_PROJECT_DB_NOT_FOUND; + else if (code != SQLITE_ROW) + result = sqlite_result(database, code, "load campaign group assignment"); + else { + sqlite3_int64 task = sqlite3_column_int64(statement, 0); + sqlite3_int64 group = sqlite3_column_int64(statement, 1); + sqlite3_int64 configuration = sqlite3_column_int64(statement, 2); + sqlite3_int64 group_count = sqlite3_column_int64(statement, 3); + sqlite3_int64 version = sqlite3_column_int64(statement, 5); + sqlite3_int64 joined_configuration = sqlite3_column_int64(statement, 6); + if (sqlite3_column_type(statement, 0) != SQLITE_INTEGER || + sqlite3_column_type(statement, 1) != SQLITE_INTEGER || + sqlite3_column_type(statement, 2) != SQLITE_INTEGER || + sqlite3_column_type(statement, 3) != SQLITE_INTEGER || + sqlite3_column_type(statement, 5) != SQLITE_INTEGER || + sqlite3_column_type(statement, 6) != SQLITE_INTEGER || + task != (sqlite3_int64)campaign_task_id || + group != (sqlite3_int64)group_id || configuration <= 0 || + group_count < 1 || group_count > 4096 || group > group_count || + joined_configuration != configuration || + !optical_column_equals(statement, 4, + LARDON3D_ACQUISITION_CAMPAIGN_TASK_KIND) || + version != LARDON3D_ACQUISITION_CAMPAIGN_TASK_KIND_VERSION || + sqlite3_step(statement) != SQLITE_DONE) { + result = LARDON3D_PROJECT_DB_CORRUPT; + } else { + output->campaign_task_id = (uint64_t)task; + output->group_id = (uint32_t)group; + output->optical_configuration_id = (uint64_t)configuration; + } + } + } + (void)sqlite3_finalize(statement); + if (result == LARDON3D_PROJECT_DB_OK) { + sqlite3_int64 cursor = 0; + sqlite3_int64 mapping_count = 0; + Lardon3DProjectDbResult dependency = validate_campaign_for_optics_locked( + database, campaign_task_id, group_id, &cursor, &mapping_count); + if (dependency != LARDON3D_PROJECT_DB_OK) + result = dependency == LARDON3D_PROJECT_DB_NOT_FOUND + ? LARDON3D_PROJECT_DB_CORRUPT + : dependency; + } + if (result == LARDON3D_PROJECT_DB_OK) { + Lardon3DOpticalConfiguration configuration = {0}; + Lardon3DProjectDbResult dependency = configuration_load_locked( + database, output->optical_configuration_id, &configuration); + if (dependency != LARDON3D_PROJECT_DB_OK) + result = dependency == LARDON3D_PROJECT_DB_NOT_FOUND + ? LARDON3D_PROJECT_DB_CORRUPT + : dependency; + } + (void)pthread_mutex_unlock(&database->mutex); + if (result != LARDON3D_PROJECT_DB_OK) + memset(output, 0, sizeof(*output)); + return result; +} + +static Lardon3DProjectDbResult capture_assignment_load_locked( + Lardon3DProjectDb *database, uint64_t capture_id, + Lardon3DOpticalCaptureAssignment *output) { + sqlite3_stmt *statement = NULL; + Lardon3DProjectDbResult result = prepare( + database, + "SELECT a.capture_id,a.optical_configuration_id,a.assignment_provenance," + "a.campaign_task_id,a.campaign_group_id,c.capture_id," + "o.optical_configuration_id,g.optical_configuration_id,m.capture_id," + "ac.group_count,t.task_kind,t.task_kind_version,c.scanset_id," + "cs.scanset_id FROM " + "capture_optical_configurations a LEFT JOIN captures c ON c.capture_id=" + "a.capture_id LEFT JOIN optical_configurations o ON o.optical_configuration_id=" + "a.optical_configuration_id LEFT JOIN acquisition_campaign_group_optics g ON " + "g.task_id=a.campaign_task_id AND g.group_id=a.campaign_group_id LEFT JOIN " + "acquisition_campaign_captures m ON m.task_id=a.campaign_task_id AND " + "m.group_id=a.campaign_group_id LEFT JOIN acquisition_campaign_tasks ac ON " + "ac.task_id=a.campaign_task_id LEFT JOIN tasks t ON t.task_id=" + "a.campaign_task_id LEFT JOIN scansets cs ON cs.scanset_id=c.scanset_id " + "WHERE a.capture_id=?1", + &statement); + if (result == LARDON3D_PROJECT_DB_OK) { + (void)sqlite3_bind_int64(statement, 1, (sqlite3_int64)capture_id); + int code = sqlite3_step(statement); + if (code == SQLITE_DONE) + result = LARDON3D_PROJECT_DB_NOT_FOUND; + else if (code != SQLITE_ROW) + result = sqlite_result(database, code, "load Capture optical assignment"); + else { + sqlite3_int64 stored_capture = sqlite3_column_int64(statement, 0); + sqlite3_int64 configuration = sqlite3_column_int64(statement, 1); + sqlite3_int64 provenance = sqlite3_column_int64(statement, 2); + sqlite3_int64 campaign_task = sqlite3_column_int64(statement, 3); + sqlite3_int64 campaign_group = sqlite3_column_int64(statement, 4); + sqlite3_int64 joined_capture = sqlite3_column_int64(statement, 5); + sqlite3_int64 joined_configuration = sqlite3_column_int64(statement, 6); + /* A surviving capture_id alone is insufficient: an orphaned or + dynamically mistyped ScanSet relation is durable corruption, not a + valid optical owner. No path/metadata fallback may repair it. */ + sqlite3_int64 capture_scanset = sqlite3_column_int64(statement, 12); + sqlite3_int64 joined_scanset = sqlite3_column_int64(statement, 13); + if (sqlite3_column_type(statement, 0) != SQLITE_INTEGER || + sqlite3_column_type(statement, 1) != SQLITE_INTEGER || + sqlite3_column_type(statement, 2) != SQLITE_INTEGER || + sqlite3_column_type(statement, 5) != SQLITE_INTEGER || + sqlite3_column_type(statement, 6) != SQLITE_INTEGER || + sqlite3_column_type(statement, 12) != SQLITE_INTEGER || + sqlite3_column_type(statement, 13) != SQLITE_INTEGER || + stored_capture != (sqlite3_int64)capture_id || stored_capture <= 0 || + configuration <= 0 || joined_capture != stored_capture || + joined_configuration != configuration || capture_scanset <= 0 || + joined_scanset != capture_scanset || + (provenance != LARDON3D_OPTICAL_ASSIGNMENT_CAMPAIGN && + provenance != LARDON3D_OPTICAL_ASSIGNMENT_CALLER_EXPLICIT)) { + result = LARDON3D_PROJECT_DB_CORRUPT; + } else if (provenance == LARDON3D_OPTICAL_ASSIGNMENT_CAMPAIGN) { + sqlite3_int64 group_count = sqlite3_column_int64(statement, 9); + sqlite3_int64 task_version = sqlite3_column_int64(statement, 11); + if (sqlite3_column_type(statement, 3) != SQLITE_INTEGER || + sqlite3_column_type(statement, 4) != SQLITE_INTEGER || + sqlite3_column_type(statement, 7) != SQLITE_INTEGER || + sqlite3_column_type(statement, 8) != SQLITE_INTEGER || + sqlite3_column_type(statement, 9) != SQLITE_INTEGER || + sqlite3_column_type(statement, 11) != SQLITE_INTEGER || + campaign_task <= 0 || campaign_group <= 0 || campaign_group > 4096 || + group_count < 1 || group_count > 4096 || campaign_group > group_count || + sqlite3_column_int64(statement, 7) != configuration || + sqlite3_column_int64(statement, 8) != stored_capture || + !optical_column_equals(statement, 10, + LARDON3D_ACQUISITION_CAMPAIGN_TASK_KIND) || + task_version != LARDON3D_ACQUISITION_CAMPAIGN_TASK_KIND_VERSION) + result = LARDON3D_PROJECT_DB_CORRUPT; + } else if (sqlite3_column_type(statement, 3) != SQLITE_NULL || + sqlite3_column_type(statement, 4) != SQLITE_NULL || + sqlite3_column_type(statement, 7) != SQLITE_NULL || + sqlite3_column_type(statement, 8) != SQLITE_NULL || + sqlite3_column_type(statement, 9) != SQLITE_NULL || + sqlite3_column_type(statement, 10) != SQLITE_NULL || + sqlite3_column_type(statement, 11) != SQLITE_NULL) { + result = LARDON3D_PROJECT_DB_CORRUPT; + } + if (result == LARDON3D_PROJECT_DB_OK && + sqlite3_step(statement) != SQLITE_DONE) + result = LARDON3D_PROJECT_DB_CORRUPT; + if (result == LARDON3D_PROJECT_DB_OK) { + output->capture_id = (uint64_t)stored_capture; + output->optical_configuration_id = (uint64_t)configuration; + output->provenance = (Lardon3DOpticalAssignmentProvenance)provenance; + output->has_campaign_origin = + provenance == LARDON3D_OPTICAL_ASSIGNMENT_CAMPAIGN; + output->campaign_task_id = + output->has_campaign_origin ? (uint64_t)campaign_task : 0; + output->campaign_group_id = + output->has_campaign_origin ? (uint32_t)campaign_group : 0; + } + } + } + (void)sqlite3_finalize(statement); + if (result == LARDON3D_PROJECT_DB_OK && output->has_campaign_origin) { + sqlite3_int64 cursor = 0; + sqlite3_int64 mapping_count = 0; + Lardon3DProjectDbResult dependency = validate_campaign_for_optics_locked( + database, output->campaign_task_id, output->campaign_group_id, &cursor, + &mapping_count); + if (dependency != LARDON3D_PROJECT_DB_OK) + result = dependency == LARDON3D_PROJECT_DB_NOT_FOUND + ? LARDON3D_PROJECT_DB_CORRUPT + : dependency; + } + if (result == LARDON3D_PROJECT_DB_OK) { + Lardon3DOpticalConfiguration configuration = {0}; + Lardon3DProjectDbResult dependency = configuration_load_locked( + database, output->optical_configuration_id, &configuration); + if (dependency != LARDON3D_PROJECT_DB_OK) + result = dependency == LARDON3D_PROJECT_DB_NOT_FOUND + ? LARDON3D_PROJECT_DB_CORRUPT + : dependency; + } + if (result != LARDON3D_PROJECT_DB_OK) + memset(output, 0, sizeof(*output)); + return result; +} + +Lardon3DProjectDbResult lardon3d_optical_capture_assign_explicit( + Lardon3DProjectDb *database, uint64_t capture_id, + uint64_t optical_configuration_id) { + if (!database || !optical_id(capture_id) || + !optical_id(optical_configuration_id)) + return LARDON3D_PROJECT_DB_INVALID_ARGUMENT; + (void)pthread_mutex_lock(&database->mutex); + Lardon3DProjectDbResult result = + execute(database, "BEGIN IMMEDIATE", "begin explicit Capture optics"); + Lardon3DOpticalConfiguration configuration; + if (result == LARDON3D_PROJECT_DB_OK) + result = configuration_load_locked(database, optical_configuration_id, + &configuration); + sqlite3_stmt *statement = NULL; + if (result == LARDON3D_PROJECT_DB_OK) + result = prepare( + database, + "SELECT c.capture_id,c.scanset_id,s.scanset_id FROM captures c LEFT " + "JOIN scansets s ON s.scanset_id=c.scanset_id WHERE c.capture_id=?1", + &statement); + if (result == LARDON3D_PROJECT_DB_OK) { + (void)sqlite3_bind_int64(statement, 1, (sqlite3_int64)capture_id); + int code = sqlite3_step(statement); + if (code == SQLITE_DONE) + result = LARDON3D_PROJECT_DB_NOT_FOUND; + else if (code != SQLITE_ROW) + result = sqlite_result(database, code, "load Capture for explicit optics"); + else if (sqlite3_column_type(statement, 0) != SQLITE_INTEGER || + sqlite3_column_type(statement, 1) != SQLITE_INTEGER || + sqlite3_column_type(statement, 2) != SQLITE_INTEGER || + sqlite3_column_int64(statement, 0) != (sqlite3_int64)capture_id || + sqlite3_column_int64(statement, 1) <= 0 || + sqlite3_column_int64(statement, 2) != + sqlite3_column_int64(statement, 1) || + sqlite3_step(statement) != SQLITE_DONE) + result = LARDON3D_PROJECT_DB_CORRUPT; + } + (void)sqlite3_finalize(statement); + statement = NULL; + + Lardon3DOpticalCaptureAssignment existing = {0}; + if (result == LARDON3D_PROJECT_DB_OK) { + Lardon3DProjectDbResult load = + capture_assignment_load_locked(database, capture_id, &existing); + if (load == LARDON3D_PROJECT_DB_OK) { + result = existing.provenance == + LARDON3D_OPTICAL_ASSIGNMENT_CALLER_EXPLICIT && + existing.optical_configuration_id == optical_configuration_id + ? LARDON3D_PROJECT_DB_OK + : LARDON3D_PROJECT_DB_CONSTRAINT; + } else if (load == LARDON3D_PROJECT_DB_NOT_FOUND) { + result = prepare( + database, + "INSERT INTO capture_optical_configurations(" + "capture_id,optical_configuration_id,assignment_provenance," + "campaign_task_id,campaign_group_id) VALUES(?1,?2,2,NULL,NULL)", + &statement); + if (result == LARDON3D_PROJECT_DB_OK) { + (void)sqlite3_bind_int64(statement, 1, (sqlite3_int64)capture_id); + (void)sqlite3_bind_int64(statement, 2, + (sqlite3_int64)optical_configuration_id); + result = step_done(database, statement, "insert explicit Capture optics"); + statement = NULL; + } + } else { + result = load; + } + } + (void)sqlite3_finalize(statement); + result = optical_commit_or_rollback(database, result, + "commit explicit Capture optics", + "rollback explicit Capture optics"); + (void)pthread_mutex_unlock(&database->mutex); + return result; +} + +Lardon3DProjectDbResult lardon3d_optical_capture_assignment_load( + Lardon3DProjectDb *database, uint64_t capture_id, + Lardon3DOpticalCaptureAssignment *output) { + if (output) + memset(output, 0, sizeof(*output)); + if (!database || !optical_id(capture_id) || !output) + return LARDON3D_PROJECT_DB_INVALID_ARGUMENT; + (void)pthread_mutex_lock(&database->mutex); + Lardon3DProjectDbResult result = + capture_assignment_load_locked(database, capture_id, output); + (void)pthread_mutex_unlock(&database->mutex); + return result; +} + +static bool read_calibration_profile( + sqlite3_stmt *statement, Lardon3DOpticalCalibrationProfile *output) { + memset(output, 0, sizeof(*output)); + sqlite3_int64 profile_id = sqlite3_column_int64(statement, 0); + sqlite3_int64 configuration_id = sqlite3_column_int64(statement, 1); + sqlite3_int64 calibration_id = sqlite3_column_int64(statement, 2); + sqlite3_int64 profile_version = sqlite3_column_int64(statement, 4); + sqlite3_int64 applicability = sqlite3_column_int64(statement, 6); + sqlite3_int64 created_at = sqlite3_column_int64(statement, 7); + sqlite3_int64 joined_configuration = sqlite3_column_int64(statement, 8); + sqlite3_int64 joined_calibration = sqlite3_column_int64(statement, 9); + if (sqlite3_column_type(statement, 0) != SQLITE_INTEGER || profile_id <= 0 || + sqlite3_column_type(statement, 1) != SQLITE_INTEGER || + configuration_id <= 0 || + sqlite3_column_type(statement, 2) != SQLITE_INTEGER || calibration_id <= 0 || + !optical_copy_text(statement, 3, output->name, sizeof(output->name), + false) || + sqlite3_column_type(statement, 4) != SQLITE_INTEGER || + profile_version <= 0 || profile_version > UINT32_MAX || + !optical_copy_text(statement, 5, output->provenance, + sizeof(output->provenance), false) || + sqlite3_column_type(statement, 6) != SQLITE_INTEGER || + applicability != LARDON3D_OPTICAL_CALIBRATION_EXACT_CONFIGURATION || + sqlite3_column_type(statement, 7) != SQLITE_INTEGER || created_at < 0 || + sqlite3_column_type(statement, 8) != SQLITE_INTEGER || + joined_configuration != configuration_id || + sqlite3_column_type(statement, 9) != SQLITE_INTEGER || + joined_calibration != calibration_id) { + memset(output, 0, sizeof(*output)); + return false; + } + output->calibration_profile_id = (uint64_t)profile_id; + output->optical_configuration_id = (uint64_t)configuration_id; + output->sparse_calibration_id = (uint64_t)calibration_id; + output->profile_version = (uint32_t)profile_version; + output->applicability = + (Lardon3DOpticalCalibrationApplicability)applicability; + output->created_at = created_at; + return true; +} + +static const char calibration_profile_select[] = + "SELECT p.calibration_profile_id,p.optical_configuration_id," + "p.sparse_calibration_id,p.name,p.profile_version,p.provenance," + "p.applicability,p.created_at,o.optical_configuration_id,s.calibration_id " + "FROM optical_calibration_profiles p LEFT JOIN optical_configurations o ON " + "o.optical_configuration_id=p.optical_configuration_id LEFT JOIN " + "sparse_calibrations s ON s.calibration_id=p.sparse_calibration_id "; + +static Lardon3DProjectDbResult calibration_profile_load_locked( + Lardon3DProjectDb *database, uint64_t profile_id, + Lardon3DOpticalCalibrationProfile *output) { + memset(output, 0, sizeof(*output)); + sqlite3_stmt *statement = NULL; + char query[1024]; + int length = snprintf(query, sizeof(query), + "%s WHERE p.calibration_profile_id=?1", + calibration_profile_select); + if (length < 0 || (size_t)length >= sizeof(query)) + return LARDON3D_PROJECT_DB_IO_ERROR; + Lardon3DProjectDbResult result = prepare(database, query, &statement); + if (result == LARDON3D_PROJECT_DB_OK) { + (void)sqlite3_bind_int64(statement, 1, (sqlite3_int64)profile_id); + int code = sqlite3_step(statement); + if (code == SQLITE_DONE) + result = LARDON3D_PROJECT_DB_NOT_FOUND; + else if (code != SQLITE_ROW) + result = sqlite_result(database, code, "load optical calibration profile"); + else if (!read_calibration_profile(statement, output) || + output->calibration_profile_id != profile_id || + sqlite3_step(statement) != SQLITE_DONE) + result = LARDON3D_PROJECT_DB_CORRUPT; + } + (void)sqlite3_finalize(statement); + if (result != LARDON3D_PROJECT_DB_OK) + memset(output, 0, sizeof(*output)); + return result; +} + +static Lardon3DProjectDbResult calibration_profile_find_natural_key_locked( + Lardon3DProjectDb *database, + const Lardon3DOpticalCalibrationProfile *input, uint64_t *profile_id) { + *profile_id = 0; + sqlite3_stmt *statement = NULL; + char query[1200]; + int length = snprintf( + query, sizeof(query), + "%s WHERE p.optical_configuration_id=?1 AND p.name=?2 AND " + "p.profile_version=?3", + calibration_profile_select); + Lardon3DProjectDbResult result = + length < 0 || (size_t)length >= sizeof(query) + ? LARDON3D_PROJECT_DB_IO_ERROR + : prepare(database, query, &statement); + if (result == LARDON3D_PROJECT_DB_OK) { + (void)sqlite3_bind_int64(statement, 1, + (sqlite3_int64)input->optical_configuration_id); + (void)sqlite3_bind_text(statement, 2, input->name, -1, SQLITE_TRANSIENT); + (void)sqlite3_bind_int64(statement, 3, input->profile_version); + int code = sqlite3_step(statement); + if (code == SQLITE_DONE) { + result = LARDON3D_PROJECT_DB_NOT_FOUND; + } else if (code != SQLITE_ROW) { + result = sqlite_result(database, code, "find optical calibration profile"); + } else { + Lardon3DOpticalCalibrationProfile stored; + if (!read_calibration_profile(statement, &stored) || + stored.optical_configuration_id != input->optical_configuration_id || + strcmp(stored.name, input->name) != 0 || + stored.profile_version != input->profile_version || + sqlite3_step(statement) != SQLITE_DONE) { + result = LARDON3D_PROJECT_DB_CORRUPT; + } else { + *profile_id = stored.calibration_profile_id; + } + } + } + (void)sqlite3_finalize(statement); + if (result != LARDON3D_PROJECT_DB_OK) + *profile_id = 0; + return result; +} + +static Lardon3DProjectDbResult calibration_profile_reconcile_existing( + Lardon3DProjectDb *database, + const Lardon3DOpticalCalibrationProfile *input, uint64_t profile_id, + Lardon3DOpticalCalibrationProfile *output) { + Lardon3DOpticalCalibrationProfile stored; + Lardon3DProjectDbResult result = + lardon3d_optical_calibration_profile_load(database, profile_id, &stored); + if (result == LARDON3D_PROJECT_DB_NOT_FOUND) + return LARDON3D_PROJECT_DB_CORRUPT; + if (result != LARDON3D_PROJECT_DB_OK) + return result; + + /* CONTRACT: durable state wins result classification. Validate the complete + stored profile and its immutable sparse-calibration dependency before + treating different caller properties as a normal identity conflict. */ + if (stored.optical_configuration_id != input->optical_configuration_id || + stored.sparse_calibration_id != input->sparse_calibration_id || + strcmp(stored.name, input->name) != 0 || + stored.profile_version != input->profile_version || + strcmp(stored.provenance, input->provenance) != 0 || + stored.applicability != input->applicability || + stored.created_at != input->created_at) + return LARDON3D_PROJECT_DB_CONSTRAINT; + *output = stored; + return LARDON3D_PROJECT_DB_OK; +} + +Lardon3DProjectDbResult lardon3d_optical_calibration_profile_create( + Lardon3DProjectDb *database, + const Lardon3DOpticalCalibrationProfile *input, + Lardon3DOpticalCalibrationProfile *output) { + if (output) + memset(output, 0, sizeof(*output)); + if (!database || !input || !output || input == output || + input->calibration_profile_id != 0 || + !optical_id(input->optical_configuration_id) || + !optical_id(input->sparse_calibration_id) || + !optical_text(input->name, sizeof(input->name), false) || + input->profile_version == 0 || + !optical_text(input->provenance, sizeof(input->provenance), false) || + input->applicability != + LARDON3D_OPTICAL_CALIBRATION_EXACT_CONFIGURATION || + input->created_at < 0) + return LARDON3D_PROJECT_DB_INVALID_ARGUMENT; + + /* WHY: reconcile the durable natural identity before validating a possibly + different caller dependency. Otherwise an orphaned stored profile could be + disguised as NOT_FOUND (exact retry) or CONSTRAINT (property conflict). */ + uint64_t profile_id = 0; + (void)pthread_mutex_lock(&database->mutex); + Lardon3DProjectDbResult result = calibration_profile_find_natural_key_locked( + database, input, &profile_id); + (void)pthread_mutex_unlock(&database->mutex); + if (result == LARDON3D_PROJECT_DB_OK) + return calibration_profile_reconcile_existing(database, input, profile_id, + output); + if (result != LARDON3D_PROJECT_DB_NOT_FOUND) + return result; + + /* With no durable natural-key row, the caller's v22 sparse object is + validated through its canonical loader before binding. This module stores + only context/provenance and never duplicates its numerical identity. */ + Lardon3DSparseCalibration sparse_calibration; + result = lardon3d_sparse_calibration_load( + database, input->sparse_calibration_id, &sparse_calibration); + if (result != LARDON3D_PROJECT_DB_OK) + return result; + + (void)pthread_mutex_lock(&database->mutex); + result = execute(database, "BEGIN IMMEDIATE", "begin optical calibration profile"); + Lardon3DOpticalConfiguration configuration; + if (result == LARDON3D_PROJECT_DB_OK) + result = configuration_load_locked(database, input->optical_configuration_id, + &configuration); + sqlite3_stmt *statement = NULL; + if (result == LARDON3D_PROJECT_DB_OK) + result = prepare( + database, + "INSERT OR IGNORE INTO optical_calibration_profiles(" + "optical_configuration_id,sparse_calibration_id,name,profile_version," + "provenance,applicability,created_at) VALUES(?1,?2,?3,?4,?5,?6,?7)", + &statement); + if (result == LARDON3D_PROJECT_DB_OK) { + (void)sqlite3_bind_int64(statement, 1, + (sqlite3_int64)input->optical_configuration_id); + (void)sqlite3_bind_int64(statement, 2, + (sqlite3_int64)input->sparse_calibration_id); + (void)sqlite3_bind_text(statement, 3, input->name, -1, SQLITE_TRANSIENT); + (void)sqlite3_bind_int64(statement, 4, input->profile_version); + (void)sqlite3_bind_text(statement, 5, input->provenance, -1, + SQLITE_TRANSIENT); + (void)sqlite3_bind_int64(statement, 6, input->applicability); + (void)sqlite3_bind_int64(statement, 7, input->created_at); + result = step_done(database, statement, "insert optical calibration profile"); + statement = NULL; + } + if (result == LARDON3D_PROJECT_DB_OK) + result = calibration_profile_find_natural_key_locked(database, input, + &profile_id); + (void)sqlite3_finalize(statement); + result = optical_commit_or_rollback(database, result, + "commit optical calibration profile", + "rollback optical calibration profile"); + (void)pthread_mutex_unlock(&database->mutex); + if (result != LARDON3D_PROJECT_DB_OK) + return result; + return calibration_profile_reconcile_existing(database, input, profile_id, + output); +} + +Lardon3DProjectDbResult lardon3d_optical_calibration_profile_load( + Lardon3DProjectDb *database, uint64_t calibration_profile_id, + Lardon3DOpticalCalibrationProfile *output) { + if (output) + memset(output, 0, sizeof(*output)); + if (!database || !optical_id(calibration_profile_id) || !output) + return LARDON3D_PROJECT_DB_INVALID_ARGUMENT; + (void)pthread_mutex_lock(&database->mutex); + Lardon3DProjectDbResult result = calibration_profile_load_locked( + database, calibration_profile_id, output); + (void)pthread_mutex_unlock(&database->mutex); + if (result == LARDON3D_PROJECT_DB_OK) { + Lardon3DOpticalConfiguration configuration; + Lardon3DSparseCalibration calibration; + Lardon3DProjectDbResult configuration_result = + lardon3d_optical_configuration_load( + database, output->optical_configuration_id, &configuration); + Lardon3DProjectDbResult calibration_result = + configuration_result == LARDON3D_PROJECT_DB_OK + ? lardon3d_sparse_calibration_load( + database, output->sparse_calibration_id, &calibration) + : configuration_result; + if (calibration_result != LARDON3D_PROJECT_DB_OK) { + result = calibration_result == LARDON3D_PROJECT_DB_NOT_FOUND + ? LARDON3D_PROJECT_DB_CORRUPT + : calibration_result; + memset(output, 0, sizeof(*output)); + } + } + return result; +} + +Lardon3DProjectDbResult lardon3d_optical_calibration_profile_list_compatible( + Lardon3DProjectDb *database, uint64_t optical_configuration_id, + uint64_t after_profile_id, Lardon3DOpticalCalibrationProfile *items, + size_t capacity, size_t *count, uint64_t *next_after_profile_id) { + if (count) + *count = 0; + if (next_after_profile_id) + *next_after_profile_id = after_profile_id; + if (!database || !optical_id(optical_configuration_id) || + !optical_page_arguments(after_profile_id, items, capacity, count, + next_after_profile_id)) + return LARDON3D_PROJECT_DB_INVALID_ARGUMENT; + memset(items, 0, capacity * sizeof(*items)); + (void)pthread_mutex_lock(&database->mutex); + Lardon3DOpticalConfiguration configuration; + Lardon3DProjectDbResult result = configuration_load_locked( + database, optical_configuration_id, &configuration); + sqlite3_stmt *statement = NULL; + char query[1200]; + int length = snprintf( + query, sizeof(query), + "%s WHERE p.optical_configuration_id=?1 AND p.calibration_profile_id>?2 " + "ORDER BY p.calibration_profile_id LIMIT ?3", + calibration_profile_select); + if (result == LARDON3D_PROJECT_DB_OK) + result = length < 0 || (size_t)length >= sizeof(query) + ? LARDON3D_PROJECT_DB_IO_ERROR + : prepare(database, query, &statement); + if (result == LARDON3D_PROJECT_DB_OK) { + (void)sqlite3_bind_int64(statement, 1, + (sqlite3_int64)optical_configuration_id); + (void)sqlite3_bind_int64(statement, 2, (sqlite3_int64)after_profile_id); + (void)sqlite3_bind_int64(statement, 3, (sqlite3_int64)capacity); + int code = SQLITE_DONE; + while (*count < capacity && (code = sqlite3_step(statement)) == SQLITE_ROW) { + if (!read_calibration_profile(statement, &items[*count]) || + items[*count].optical_configuration_id != optical_configuration_id) { + result = LARDON3D_PROJECT_DB_CORRUPT; + break; + } + *next_after_profile_id = items[*count].calibration_profile_id; + ++*count; + } + if (result == LARDON3D_PROJECT_DB_OK && code != SQLITE_DONE && + *count < capacity) + result = sqlite_result(database, code, + "list compatible optical calibrations"); + } + (void)sqlite3_finalize(statement); + (void)pthread_mutex_unlock(&database->mutex); + if (result == LARDON3D_PROJECT_DB_OK) { + for (size_t index = 0; index < *count; ++index) { + Lardon3DSparseCalibration calibration; + Lardon3DProjectDbResult dependency = lardon3d_sparse_calibration_load( + database, items[index].sparse_calibration_id, &calibration); + if (dependency != LARDON3D_PROJECT_DB_OK) { + result = dependency == LARDON3D_PROJECT_DB_NOT_FOUND + ? LARDON3D_PROJECT_DB_CORRUPT + : dependency; + break; + } + } + } + if (result != LARDON3D_PROJECT_DB_OK) { + memset(items, 0, capacity * sizeof(*items)); + *count = 0; + *next_after_profile_id = after_profile_id; + } + return result; +} + +Lardon3DProjectDbResult lardon3d_optical_capture_calibration_select( + Lardon3DProjectDb *database, uint64_t capture_id, + uint64_t calibration_profile_id) { + if (!database || !optical_id(capture_id) || + !optical_id(calibration_profile_id)) + return LARDON3D_PROJECT_DB_INVALID_ARGUMENT; + Lardon3DOpticalCalibrationProfile validated_profile; + Lardon3DProjectDbResult result = lardon3d_optical_calibration_profile_load( + database, calibration_profile_id, &validated_profile); + if (result != LARDON3D_PROJECT_DB_OK) + return result; + (void)pthread_mutex_lock(&database->mutex); + result = execute( + database, "BEGIN IMMEDIATE", "begin Capture calibration selection"); + Lardon3DOpticalCaptureAssignment assignment = {0}; + Lardon3DOpticalCalibrationProfile profile = {0}; + if (result == LARDON3D_PROJECT_DB_OK) { + result = capture_assignment_load_locked(database, capture_id, &assignment); + if (result == LARDON3D_PROJECT_DB_NOT_FOUND) { + sqlite3_stmt *capture_statement = NULL; + result = prepare( + database, + "SELECT c.capture_id,c.scanset_id,s.scanset_id FROM captures c LEFT " + "JOIN scansets s ON s.scanset_id=c.scanset_id WHERE c.capture_id=?1", + &capture_statement); + if (result == LARDON3D_PROJECT_DB_OK) { + (void)sqlite3_bind_int64(capture_statement, 1, + (sqlite3_int64)capture_id); + int code = sqlite3_step(capture_statement); + if (code == SQLITE_DONE) + result = LARDON3D_PROJECT_DB_NOT_FOUND; + else if (code != SQLITE_ROW) + result = sqlite_result(database, code, + "load Capture calibration owner"); + else if (sqlite3_column_type(capture_statement, 0) != SQLITE_INTEGER || + sqlite3_column_type(capture_statement, 1) != SQLITE_INTEGER || + sqlite3_column_type(capture_statement, 2) != SQLITE_INTEGER || + sqlite3_column_int64(capture_statement, 0) != + (sqlite3_int64)capture_id || + sqlite3_column_int64(capture_statement, 1) <= 0 || + sqlite3_column_int64(capture_statement, 2) != + sqlite3_column_int64(capture_statement, 1) || + sqlite3_step(capture_statement) != SQLITE_DONE) + result = LARDON3D_PROJECT_DB_CORRUPT; + else + result = LARDON3D_PROJECT_DB_CONSTRAINT; + } + (void)sqlite3_finalize(capture_statement); + } + } + if (result == LARDON3D_PROJECT_DB_OK) + result = calibration_profile_load_locked(database, calibration_profile_id, + &profile); + if (result == LARDON3D_PROJECT_DB_OK && + (profile.optical_configuration_id != + validated_profile.optical_configuration_id || + profile.sparse_calibration_id != validated_profile.sparse_calibration_id)) + result = LARDON3D_PROJECT_DB_CORRUPT; + if (result == LARDON3D_PROJECT_DB_OK && + assignment.optical_configuration_id != profile.optical_configuration_id) + result = LARDON3D_PROJECT_DB_CONSTRAINT; + + sqlite3_stmt *statement = NULL; + bool selection_absent = false; + if (result == LARDON3D_PROJECT_DB_OK) + result = prepare( + database, + "SELECT calibration_profile_id,optical_configuration_id FROM " + "capture_calibration_selections WHERE capture_id=?1", + &statement); + if (result == LARDON3D_PROJECT_DB_OK) { + (void)sqlite3_bind_int64(statement, 1, (sqlite3_int64)capture_id); + int code = sqlite3_step(statement); + if (code == SQLITE_DONE) { + selection_absent = true; + } else if (code != SQLITE_ROW) { + result = sqlite_result(database, code, "load Capture calibration selection"); + } else if (sqlite3_column_type(statement, 0) != SQLITE_INTEGER || + sqlite3_column_type(statement, 1) != SQLITE_INTEGER || + sqlite3_column_int64(statement, 0) <= 0 || + sqlite3_column_int64(statement, 1) <= 0) { + result = LARDON3D_PROJECT_DB_CORRUPT; + } else if ((uint64_t)sqlite3_column_int64(statement, 0) != + calibration_profile_id || + (uint64_t)sqlite3_column_int64(statement, 1) != + assignment.optical_configuration_id) { + result = LARDON3D_PROJECT_DB_CONSTRAINT; + } else if (sqlite3_step(statement) != SQLITE_DONE) { + result = LARDON3D_PROJECT_DB_CORRUPT; + } + } + (void)sqlite3_finalize(statement); + statement = NULL; + if (result == LARDON3D_PROJECT_DB_OK && selection_absent) + result = prepare( + database, + "INSERT INTO capture_calibration_selections(capture_id," + "calibration_profile_id,optical_configuration_id) VALUES(?1,?2,?3)", + &statement); + if (result == LARDON3D_PROJECT_DB_OK && selection_absent) { + (void)sqlite3_bind_int64(statement, 1, (sqlite3_int64)capture_id); + (void)sqlite3_bind_int64(statement, 2, + (sqlite3_int64)calibration_profile_id); + (void)sqlite3_bind_int64(statement, 3, + (sqlite3_int64)assignment.optical_configuration_id); + result = step_done(database, statement, "insert Capture calibration selection"); + statement = NULL; + } + (void)sqlite3_finalize(statement); + result = optical_commit_or_rollback( + database, result, "commit Capture calibration selection", + "rollback Capture calibration selection"); + (void)pthread_mutex_unlock(&database->mutex); + return result; +} + +Lardon3DProjectDbResult +lardon3d_optical_capture_calibration_selection_load( + Lardon3DProjectDb *database, uint64_t capture_id, + Lardon3DOpticalCaptureCalibrationSelection *output) { + if (output) + memset(output, 0, sizeof(*output)); + if (!database || !optical_id(capture_id) || !output) + return LARDON3D_PROJECT_DB_INVALID_ARGUMENT; + (void)pthread_mutex_lock(&database->mutex); + sqlite3_stmt *statement = NULL; + Lardon3DProjectDbResult result = prepare( + database, + "SELECT x.capture_id,x.calibration_profile_id,x.optical_configuration_id," + "a.capture_id,a.optical_configuration_id,p.calibration_profile_id," + "p.optical_configuration_id,p.sparse_calibration_id,s.calibration_id FROM " + "capture_calibration_selections x LEFT JOIN capture_optical_configurations a " + "ON a.capture_id=x.capture_id LEFT JOIN optical_calibration_profiles p ON " + "p.calibration_profile_id=x.calibration_profile_id LEFT JOIN " + "sparse_calibrations s ON s.calibration_id=p.sparse_calibration_id WHERE " + "x.capture_id=?1", + &statement); + if (result == LARDON3D_PROJECT_DB_OK) { + (void)sqlite3_bind_int64(statement, 1, (sqlite3_int64)capture_id); + int code = sqlite3_step(statement); + if (code == SQLITE_DONE) + result = LARDON3D_PROJECT_DB_NOT_FOUND; + else if (code != SQLITE_ROW) + result = sqlite_result(database, code, + "load Capture calibration selection"); + else { + sqlite3_int64 stored_capture = sqlite3_column_int64(statement, 0); + sqlite3_int64 profile_id = sqlite3_column_int64(statement, 1); + sqlite3_int64 configuration_id = sqlite3_column_int64(statement, 2); + sqlite3_int64 calibration_id = sqlite3_column_int64(statement, 7); + bool types_valid = true; + for (int column = 0; column < 9; ++column) + if (sqlite3_column_type(statement, column) != SQLITE_INTEGER) + types_valid = false; + if (!types_valid || stored_capture != (sqlite3_int64)capture_id || + stored_capture <= 0 || profile_id <= 0 || configuration_id <= 0 || + calibration_id <= 0 || sqlite3_column_int64(statement, 3) != stored_capture || + sqlite3_column_int64(statement, 4) != configuration_id || + sqlite3_column_int64(statement, 5) != profile_id || + sqlite3_column_int64(statement, 6) != configuration_id || + sqlite3_column_int64(statement, 8) != calibration_id || + sqlite3_step(statement) != SQLITE_DONE) { + result = LARDON3D_PROJECT_DB_CORRUPT; + } else { + output->capture_id = (uint64_t)stored_capture; + output->calibration_profile_id = (uint64_t)profile_id; + output->optical_configuration_id = (uint64_t)configuration_id; + output->sparse_calibration_id = (uint64_t)calibration_id; + } + } + } + (void)sqlite3_finalize(statement); + (void)pthread_mutex_unlock(&database->mutex); + if (result == LARDON3D_PROJECT_DB_OK) { + Lardon3DOpticalCaptureAssignment assignment = {0}; + Lardon3DOpticalCalibrationProfile profile = {0}; + Lardon3DProjectDbResult assignment_result = + lardon3d_optical_capture_assignment_load(database, capture_id, + &assignment); + Lardon3DProjectDbResult profile_result = + assignment_result == LARDON3D_PROJECT_DB_OK + ? lardon3d_optical_calibration_profile_load( + database, output->calibration_profile_id, &profile) + : assignment_result; + if (profile_result != LARDON3D_PROJECT_DB_OK || + assignment.optical_configuration_id != output->optical_configuration_id || + profile.optical_configuration_id != output->optical_configuration_id || + profile.sparse_calibration_id != output->sparse_calibration_id) { + result = profile_result == LARDON3D_PROJECT_DB_NOT_FOUND + ? LARDON3D_PROJECT_DB_CORRUPT + : profile_result; + if (result == LARDON3D_PROJECT_DB_OK) + result = LARDON3D_PROJECT_DB_CORRUPT; + } + } + if (result != LARDON3D_PROJECT_DB_OK) + memset(output, 0, sizeof(*output)); + return result; +} diff --git a/src/photo_quality_task.cpp b/src/photo_quality_task.cpp index 57054ef..dba9ea8 100644 --- a/src/photo_quality_task.cpp +++ b/src/photo_quality_task.cpp @@ -67,6 +67,11 @@ bool run_impl(Lardon3DTask *task, void *value) { if (lardon3d_project_db_load_photo_quality_task(context->db, lardon3d_task_id(task), context->encoded.data(), context->encoded.size(), &persisted) != LARDON3D_PROJECT_DB_OK) return lardon3d_task_fail(task, "Triage photo durable introuvable."); + if (persisted.next_group_id == 0 || + !lardon3d_task_set_durable_progress( + task, persisted.next_group_id - 1u, persisted.group_count, + "Préfixe durable de triage observé.")) + return lardon3d_task_fail(task, "Curseur durable de triage invalide."); for (uint32_t group_id = persisted.next_group_id; group_id <= context->plan.group_count; ++group_id) { @@ -103,9 +108,11 @@ bool run_impl(Lardon3DTask *task, void *value) { if (lardon3d_project_db_record_photo_quality_result(context->db, &result, next_group_id) != LARDON3D_PROJECT_DB_OK) return lardon3d_task_fail(task, "Publication du résultat de triage impossible."); - unsigned progress = static_cast((uint64_t(group_id) * 100u) / - context->plan.group_count); - if (!lardon3d_task_set_progress(task, progress, "Groupe photo analysé.") || + /* The typed result/cursor transaction is already committed. Publish the + * exact count only now, so a live snapshot cannot lead durable science. */ + if (!lardon3d_task_set_durable_progress( + task, group_id, context->plan.group_count, + "Groupe photo analysé.") || !checkpoint(context, task, next_group_id)) return lardon3d_task_fail(task, "Checkpoint du triage photo impossible."); if (group_id < context->plan.group_count) { diff --git a/src/project.c b/src/project.c index 291e6d0..ea3116a 100644 --- a/src/project.c +++ b/src/project.c @@ -453,7 +453,11 @@ static bool read_project_ini(Lardon3DAppState *state, const char *path, ProjectM } } if (valid) { - (void)snprintf(metadata->stable_id, sizeof(metadata->stable_id), "%s", line + 10); + /* The v2 project identity is exactly 128 lowercase hexadecimal bits. Copying the + * validated width makes the no-truncation contract explicit to both readers and + * compiler diagnostics; project identity must never be accepted by prefix. */ + memcpy(metadata->stable_id, line + 10, id_length); + metadata->stable_id[id_length] = '\0'; } } stable_id_found = true; diff --git a/src/project_db.c b/src/project_db.c index 443267c..e3f4846 100644 --- a/src/project_db.c +++ b/src/project_db.c @@ -11,6 +11,9 @@ #include #include +#include +#include +#include #include #include #include "project_db_internal.h" @@ -708,6 +711,146 @@ static const char schema_selected_execution_v22[] = "capture_source_assets(capture_id,asset_id)," "FOREIGN KEY(capture_id,image_id) REFERENCES capture_images(capture_id,image_id));"; +/* PERSISTENCE CONTRACT v23: optical IDs are positive signed SQLite INTEGERs; + text must fit the matching 128/256-byte public capacities (loaders enforce + byte length because SQLite length() counts characters); focal identity is + INTEGER micrometres with zero meaning absent; enum values are the fixed codes + declared by optical_profiles.h. The migration is additive and creates no + rows, so v22 Capture and sparse-calibration identity is never reinterpreted + from names, paths, EXIF, hashes, or dimensions. */ +static const char schema_optical_profiles_v23[] = + "CREATE TABLE camera_body_profiles(" + "camera_body_profile_id INTEGER PRIMARY KEY AUTOINCREMENT " + "CHECK(camera_body_profile_id>0)," + "manufacturer TEXT NOT NULL CHECK(typeof(manufacturer)='text' AND " + "length(manufacturer)>0 AND length(manufacturer)<128)," + "model TEXT NOT NULL CHECK(typeof(model)='text' AND length(model)>0 AND " + "length(model)<128)," + "name TEXT NOT NULL CHECK(typeof(name)='text' AND length(name)>0 AND " + "length(name)<128),UNIQUE(manufacturer,model,name));" + "CREATE TABLE camera_body_aliases(" + "alias_id INTEGER PRIMARY KEY AUTOINCREMENT CHECK(alias_id>0)," + "camera_body_profile_id INTEGER NOT NULL REFERENCES camera_body_profiles(" + "camera_body_profile_id) ON DELETE CASCADE," + "metadata_make TEXT NOT NULL COLLATE BINARY CHECK(typeof(metadata_make)='text' " + "AND length(metadata_make)>0 AND length(metadata_make)<128)," + "metadata_model TEXT NOT NULL COLLATE BINARY CHECK(typeof(metadata_model)='text' " + "AND length(metadata_model)>0 AND length(metadata_model)<128)," + "UNIQUE(metadata_make,metadata_model));" + "CREATE INDEX camera_body_aliases_profile_idx ON " + "camera_body_aliases(camera_body_profile_id,alias_id);" + "CREATE TABLE lens_profiles(" + "lens_profile_id INTEGER PRIMARY KEY AUTOINCREMENT CHECK(lens_profile_id>0)," + "manufacturer TEXT NOT NULL CHECK(typeof(manufacturer)='text' AND " + "length(manufacturer)<128)," + "model TEXT NOT NULL CHECK(typeof(model)='text' AND length(model)<128)," + "name TEXT NOT NULL CHECK(typeof(name)='text' AND length(name)>0 AND " + "length(name)<128)," + "interface_kind INTEGER NOT NULL CHECK(typeof(interface_kind)='integer' AND " + "interface_kind BETWEEN 1 AND 3)," + "focal_range_kind INTEGER NOT NULL CHECK(typeof(focal_range_kind)='integer' AND " + "focal_range_kind BETWEEN 1 AND 3)," + "minimum_focal_um INTEGER NOT NULL CHECK(typeof(minimum_focal_um)='integer' AND " + "minimum_focal_um>=0 AND minimum_focal_um<=4294967295)," + "maximum_focal_um INTEGER NOT NULL CHECK(typeof(maximum_focal_um)='integer' AND " + "maximum_focal_um>=0 AND maximum_focal_um<=4294967295)," + "CHECK((focal_range_kind=1 AND minimum_focal_um=0 AND maximum_focal_um=0) OR " + "(focal_range_kind=2 AND minimum_focal_um>0 AND " + "minimum_focal_um=maximum_focal_um) OR " + "(focal_range_kind=3 AND minimum_focal_um>0 AND " + "minimum_focal_um0)," + "lens_profile_id INTEGER NOT NULL REFERENCES lens_profiles(lens_profile_id) " + "ON DELETE CASCADE," + "metadata_make TEXT NOT NULL COLLATE BINARY CHECK(typeof(metadata_make)='text' " + "AND length(metadata_make)<128)," + "metadata_model TEXT NOT NULL COLLATE BINARY CHECK(typeof(metadata_model)='text' " + "AND length(metadata_model)>0 AND length(metadata_model)<128)," + "UNIQUE(metadata_make,metadata_model));" + "CREATE INDEX lens_profile_aliases_profile_idx ON " + "lens_profile_aliases(lens_profile_id,alias_id);" + "CREATE TABLE optical_configurations(" + "optical_configuration_id INTEGER PRIMARY KEY AUTOINCREMENT " + "CHECK(optical_configuration_id>0)," + "camera_body_profile_id INTEGER NOT NULL REFERENCES camera_body_profiles(" + "camera_body_profile_id)," + "lens_profile_id INTEGER NOT NULL REFERENCES lens_profiles(lens_profile_id)," + /* Zero means that the optional exact focal context is absent. Positive + values are deterministic integer micrometres, never locale/REAL data. */ + "focal_length_um INTEGER NOT NULL CHECK(typeof(focal_length_um)='integer' AND " + "focal_length_um>=0 AND focal_length_um<=4294967295)," + "UNIQUE(camera_body_profile_id,lens_profile_id,focal_length_um));" + "CREATE INDEX optical_configurations_body_idx ON " + "optical_configurations(camera_body_profile_id,optical_configuration_id);" + "CREATE INDEX optical_configurations_lens_idx ON " + "optical_configurations(lens_profile_id,optical_configuration_id);"; + +static const char schema_optical_assignments_v23[] = + "CREATE TABLE acquisition_campaign_group_optics(" + "task_id INTEGER NOT NULL REFERENCES acquisition_campaign_tasks(task_id) " + "ON DELETE CASCADE," + "group_id INTEGER NOT NULL CHECK(typeof(group_id)='integer' AND group_id>0 " + "AND group_id<=4096)," + "optical_configuration_id INTEGER NOT NULL REFERENCES optical_configurations(" + "optical_configuration_id),PRIMARY KEY(task_id,group_id)," + "UNIQUE(task_id,group_id,optical_configuration_id));" + /* These composite keys let SQLite enforce that campaign provenance on a + Capture assignment names the exact durable group mapping/configuration. */ + "CREATE UNIQUE INDEX acquisition_campaign_capture_identity_v23 ON " + "acquisition_campaign_captures(task_id,group_id,capture_id);" + "CREATE TABLE capture_optical_configurations(" + "capture_id INTEGER PRIMARY KEY REFERENCES captures(capture_id) ON DELETE CASCADE," + "optical_configuration_id INTEGER NOT NULL REFERENCES optical_configurations(" + "optical_configuration_id)," + "assignment_provenance INTEGER NOT NULL CHECK(typeof(assignment_provenance)=" + "'integer' AND assignment_provenance IN(1,2))," + "campaign_task_id INTEGER,campaign_group_id INTEGER," + "CHECK((assignment_provenance=1 AND typeof(campaign_task_id)='integer' AND " + "campaign_task_id>0 AND typeof(campaign_group_id)='integer' AND " + "campaign_group_id>0 AND campaign_group_id<=4096) OR " + "(assignment_provenance=2 AND campaign_task_id IS NULL AND " + "campaign_group_id IS NULL))," + "UNIQUE(capture_id,optical_configuration_id)," + "FOREIGN KEY(campaign_task_id,campaign_group_id,optical_configuration_id) " + "REFERENCES acquisition_campaign_group_optics(task_id,group_id," + "optical_configuration_id)," + "FOREIGN KEY(campaign_task_id,campaign_group_id,capture_id) REFERENCES " + "acquisition_campaign_captures(task_id,group_id,capture_id));" + "CREATE INDEX capture_optical_configurations_config_idx ON " + "capture_optical_configurations(optical_configuration_id,capture_id);"; + +static const char schema_optical_calibrations_v23[] = + "CREATE TABLE optical_calibration_profiles(" + "calibration_profile_id INTEGER PRIMARY KEY AUTOINCREMENT " + "CHECK(calibration_profile_id>0)," + "optical_configuration_id INTEGER NOT NULL REFERENCES optical_configurations(" + "optical_configuration_id)," + "sparse_calibration_id INTEGER NOT NULL REFERENCES sparse_calibrations(" + "calibration_id)," + "name TEXT NOT NULL CHECK(typeof(name)='text' AND length(name)>0 AND " + "length(name)<128)," + "profile_version INTEGER NOT NULL CHECK(typeof(profile_version)='integer' AND " + "profile_version>0 AND profile_version<=4294967295)," + "provenance TEXT NOT NULL CHECK(typeof(provenance)='text' AND " + "length(provenance)>0 AND length(provenance)<256)," + "applicability INTEGER NOT NULL CHECK(typeof(applicability)='integer' AND " + "applicability=1)," + "created_at INTEGER NOT NULL CHECK(typeof(created_at)='integer' AND created_at>=0)," + "UNIQUE(optical_configuration_id,name,profile_version)," + "UNIQUE(calibration_profile_id,optical_configuration_id));" + "CREATE INDEX optical_calibration_profiles_config_idx ON " + "optical_calibration_profiles(optical_configuration_id,calibration_profile_id);" + "CREATE INDEX optical_calibration_profiles_sparse_idx ON " + "optical_calibration_profiles(sparse_calibration_id,calibration_profile_id);" + "CREATE TABLE capture_calibration_selections(" + "capture_id INTEGER PRIMARY KEY,calibration_profile_id INTEGER NOT NULL," + "optical_configuration_id INTEGER NOT NULL," + "FOREIGN KEY(capture_id,optical_configuration_id) REFERENCES " + "capture_optical_configurations(capture_id,optical_configuration_id)," + "FOREIGN KEY(calibration_profile_id,optical_configuration_id) REFERENCES " + "optical_calibration_profiles(calibration_profile_id,optical_configuration_id));"; + static void copy_error(char destination[LARDON3D_PROJECT_DB_ERROR_CAPACITY], const char *text) { if (destination) { (void)snprintf(destination, LARDON3D_PROJECT_DB_ERROR_CAPACITY, "%s", text ? text : ""); @@ -800,7 +943,7 @@ static Lardon3DProjectDbResult migrate(Lardon3DProjectDb *database, unsigned int from_version != 8 && from_version != 9 && from_version != 10 && from_version != 11 && from_version != 12 && from_version != 13 && from_version != 14 && from_version != 15 && from_version != 16 && from_version != 17 && from_version != 18 && - from_version != 19 && from_version != 20 && from_version != 21) { + from_version != 19 && from_version != 20 && from_version != 21 && from_version != 22) { return LARDON3D_PROJECT_DB_CORRUPT; } Lardon3DProjectDbResult result = execute(database, "BEGIN IMMEDIATE", "begin migration"); @@ -1238,6 +1381,39 @@ static Lardon3DProjectDbResult migrate(Lardon3DProjectDb *database, unsigned int "finish schema v22 migration"); } } + if (result == LARDON3D_PROJECT_DB_OK && from_version < 23) { + /* CONTRACT: v22 projects enter v23 with no optical rows. Historical names, + paths, metadata and sparse calibration dimensions are not identity and + therefore cannot be used for a migration backfill. */ + result = execute(database, schema_optical_profiles_v23, + "migrate optical profiles v22 to v23"); + if (result == LARDON3D_PROJECT_DB_OK) { + result = execute(database, schema_optical_assignments_v23, + "migrate optical assignments v22 to v23"); + } + if (result == LARDON3D_PROJECT_DB_OK) { + result = execute(database, schema_optical_calibrations_v23, + "migrate optical calibrations v22 to v23"); + } +#ifdef LARDON3D_PROJECT_DB_TESTING + if (result == LARDON3D_PROJECT_DB_OK && + getenv("LARDON3D_TEST_PROJECT_DB_FAIL_MIGRATION_V23")) { + result = execute(database, "INSERT INTO missing_v23_test_table VALUES(1)", + "forced migration v23 failure"); + } +#endif + if (result == LARDON3D_PROJECT_DB_OK) { + result = execute(database, + "UPDATE metadata SET value=23 WHERE key='schema_version' AND value=22", + "finish schema v23 migration"); + /* The schema marker is the durable publication point. A successful SQL + statement that matched no canonical v22 row must roll the whole + additive migration back rather than leave unversioned v23 objects. */ + if (result == LARDON3D_PROJECT_DB_OK && + sqlite3_changes(database->connection) != 1) + result = LARDON3D_PROJECT_DB_CORRUPT; + } + } if (result == LARDON3D_PROJECT_DB_OK) { result = execute(database, "COMMIT", "commit migration"); } @@ -1381,12 +1557,21 @@ Lardon3DProjectDbResult lardon3d_project_db_open(const char *path, Lardon3DProje "photo_quality_triage_tasks", "photo_quality_triage_results", "selected_executions", - "selected_execution_items"}; + "selected_execution_items", + "camera_body_profiles", + "camera_body_aliases", + "lens_profiles", + "lens_profile_aliases", + "optical_configurations", + "acquisition_campaign_group_optics", + "capture_optical_configurations", + "optical_calibration_profiles", + "capture_calibration_selections"}; for (size_t index = 0; index < sizeof(required) / sizeof(required[0]) && result == LARDON3D_PROJECT_DB_OK; ++index) { if (!table_exists(database->connection, required[index])) { - copy_error(database->error, "Schéma v1 incomplet."); + copy_error(database->error, "Schéma Project DB courant incomplet."); result = LARDON3D_PROJECT_DB_CORRUPT; } } @@ -1498,6 +1683,18 @@ static bool copy_column(sqlite3_stmt *statement, int column, char *destination, return true; } +static bool column_text_equals(sqlite3_stmt *statement, int column, + const char *expected) { + if (!statement || !expected || + sqlite3_column_type(statement, column) != SQLITE_TEXT) + return false; + const unsigned char *text = sqlite3_column_text(statement, column); + int bytes = sqlite3_column_bytes(statement, column); + size_t expected_bytes = strlen(expected); + return text && bytes >= 0 && (size_t)bytes == expected_bytes && + memcmp(text, expected, expected_bytes) == 0; +} + Lardon3DProjectDbResult lardon3d_project_db_set_project(Lardon3DProjectDb *database, const Lardon3DProjectDbProject *project) { if (!database || !project || @@ -1696,17 +1893,26 @@ record_task_internal(Lardon3DProjectDb *database, const Lardon3DTaskDurableSnaps !valid_task_id(sparse_sfm->track_set_id) || !valid_task_id(sparse_sfm->calibration_scope_id) || sparse_sfm->sfm_kind != 1 || sparse_sfm->sfm_version != 1 || !valid_sparse_sfm_parameters(&sparse_sfm->parameters))) || - (campaign && (!valid_task_id(campaign->task_id) || campaign->task_id != snapshot->id || - !valid_task_id(campaign->scanset_id) || campaign->group_count == 0 || - campaign->group_count > 4096 || - campaign->next_group_id > campaign->group_count || !campaign->request || - campaign->request_size == 0 || campaign->request_size > INT_MAX)) || + (campaign && + (!task_kind || + strcmp(task_kind, LARDON3D_ACQUISITION_CAMPAIGN_TASK_KIND) != 0 || + task_kind_version != LARDON3D_ACQUISITION_CAMPAIGN_TASK_KIND_VERSION || + !valid_task_id(campaign->task_id) || campaign->task_id != snapshot->id || + !valid_task_id(campaign->scanset_id) || campaign->group_count == 0 || + campaign->group_count > 4096 || + campaign->next_group_id > campaign->group_count || !campaign->request || + campaign->request_size == 0 || + campaign->request_size > + LARDON3D_ACQUISITION_CAMPAIGN_TASK_REQUEST_MAX_BYTES)) || (photo_quality && - (!valid_task_id(photo_quality->task_id) || photo_quality->task_id != snapshot->id || + (!task_kind || strcmp(task_kind, LARDON3D_PHOTO_QUALITY_TASK_KIND) != 0 || + task_kind_version != LARDON3D_PHOTO_QUALITY_TASK_KIND_VERSION || + !valid_task_id(photo_quality->task_id) || photo_quality->task_id != snapshot->id || !valid_task_id(photo_quality->scanset_id) || photo_quality->group_count == 0 || photo_quality->group_count > 4096 || photo_quality->next_group_id == 0 || photo_quality->next_group_id > photo_quality->group_count + 1u || !photo_quality->request || - photo_quality->request_size == 0 || photo_quality->request_size > INT_MAX)) || + photo_quality->request_size == 0 || + photo_quality->request_size > LARDON3D_PHOTO_QUALITY_TASK_REQUEST_MAX_BYTES)) || (raw_development && (!valid_task_id(raw_development->task_id) || raw_development->task_id != snapshot->id || @@ -2465,7 +2671,9 @@ Lardon3DProjectDbResult lardon3d_project_db_record_acquisition_campaign_task( if (!database || !snapshot || !parameters || parameters->task_id != snapshot->id || !valid_task_id(parameters->scanset_id) || parameters->group_count == 0 || parameters->group_count > 4096 || parameters->next_group_id > parameters->group_count || - !parameters->request || parameters->request_size == 0 || parameters->request_size > INT_MAX) { + !parameters->request || parameters->request_size == 0 || + parameters->request_size > + LARDON3D_ACQUISITION_CAMPAIGN_TASK_REQUEST_MAX_BYTES) { return LARDON3D_PROJECT_DB_INVALID_ARGUMENT; } /* The generic Task/checkpoint and typed request persist atomically: recovery @@ -2475,34 +2683,121 @@ Lardon3DProjectDbResult lardon3d_project_db_record_acquisition_campaign_task( NULL, updated_at); } +static Lardon3DProjectDbResult validate_acquisition_campaign_prefix_locked( + Lardon3DProjectDb *database, uint64_t task_id, sqlite3_int64 scanset_id, + sqlite3_int64 next_group_id, sqlite3_int64 group_count) { + sqlite3_stmt *statement = NULL; + Lardon3DProjectDbResult result = prepare( + database, + "SELECT COUNT(*)," + "COUNT(CASE WHEN typeof(m.task_id)!='integer' OR " + "typeof(m.group_id)!='integer' OR m.group_id<1 OR m.group_id>?2 OR " + "m.group_id>?3 OR typeof(m.capture_id)!='integer' OR m.capture_id<=0 OR " + "c.capture_id IS NULL OR typeof(c.scanset_id)!='integer' OR " + "c.scanset_id<=0 OR c.scanset_id!=?4 THEN 1 END)," + "MIN(m.group_id),MAX(m.group_id) " + "FROM acquisition_campaign_captures m LEFT JOIN captures c " + "ON c.capture_id=m.capture_id WHERE m.task_id=?1", + &statement); + if (result == LARDON3D_PROJECT_DB_OK) { + (void)sqlite3_bind_int64(statement, 1, (sqlite3_int64)task_id); + (void)sqlite3_bind_int64(statement, 2, next_group_id); + (void)sqlite3_bind_int64(statement, 3, group_count); + (void)sqlite3_bind_int64(statement, 4, scanset_id); + int code = sqlite3_step(statement); + if (code != SQLITE_ROW) { + result = sqlite_result(database, code, "validate campaign prefix"); + } else { + int count_type = sqlite3_column_type(statement, 0); + int invalid_type = sqlite3_column_type(statement, 1); + int minimum_type = sqlite3_column_type(statement, 2); + int maximum_type = sqlite3_column_type(statement, 3); + sqlite3_int64 count = sqlite3_column_int64(statement, 0); + sqlite3_int64 invalid = sqlite3_column_int64(statement, 1); + sqlite3_int64 minimum = sqlite3_column_int64(statement, 2); + sqlite3_int64 maximum = sqlite3_column_int64(statement, 3); + /* CONTRACT: the cursor is exactly the number of durable mappings. With + * the v20 primary key, count/min/max plus strict INTEGER storage proves + * the complete one-based prefix 1..next and excludes every ahead row. + * Capture joins additionally preserve the campaign ScanSet boundary. */ + bool empty = next_group_id == 0 && count == 0 && + minimum_type == SQLITE_NULL && maximum_type == SQLITE_NULL; + bool complete = next_group_id > 0 && count == next_group_id && + minimum_type == SQLITE_INTEGER && + maximum_type == SQLITE_INTEGER && minimum == 1 && + maximum == next_group_id; + if (count_type != SQLITE_INTEGER || invalid_type != SQLITE_INTEGER || + count < 0 || count > 4096 || invalid != 0 || (!empty && !complete)) { + copy_error(database->error, + "Préfixe durable de campagne incohérent."); + result = LARDON3D_PROJECT_DB_CORRUPT; + } + } + } + (void)sqlite3_finalize(statement); + return result; +} + Lardon3DProjectDbResult lardon3d_project_db_load_acquisition_campaign_task( Lardon3DProjectDb *database, uint64_t task_id, unsigned char *request, size_t request_capacity, Lardon3DProjectDbAcquisitionCampaignTask *parameters) { - if (!database || !valid_task_id(task_id) || !request || request_capacity == 0 || !parameters) + bool size_probe = !request && request_capacity == 0; + if (!database || !valid_task_id(task_id) || !parameters || + (!size_probe && (!request || request_capacity == 0))) return LARDON3D_PROJECT_DB_INVALID_ARGUMENT; memset(parameters, 0, sizeof(*parameters)); (void)pthread_mutex_lock(&database->mutex); sqlite3_stmt *statement = NULL; Lardon3DProjectDbResult result = prepare(database, - "SELECT scanset_id,next_group_id,group_count,request FROM acquisition_campaign_tasks " - "WHERE task_id=?1", &statement); + "SELECT c.scanset_id,c.next_group_id,c.group_count,c.request,t.task_kind," + "t.task_kind_version,s.scanset_id FROM acquisition_campaign_tasks c " + "LEFT JOIN tasks t ON t.task_id=c.task_id LEFT JOIN scansets s " + "ON s.scanset_id=c.scanset_id WHERE c.task_id=?1", &statement); if (result == LARDON3D_PROJECT_DB_OK) { - sqlite3_bind_int64(statement, 1, (sqlite3_int64)task_id); + sqlite3_bind_int64(statement, 1, (sqlite3_int64)task_id); int code = sqlite3_step(statement); if (code == SQLITE_DONE) result = LARDON3D_PROJECT_DB_NOT_FOUND; else if (code != SQLITE_ROW) result = sqlite_result(database, code, "load campaign task"); else { + sqlite3_int64 scanset = sqlite3_column_int64(statement, 0); + sqlite3_int64 next = sqlite3_column_int64(statement, 1); + sqlite3_int64 count = sqlite3_column_int64(statement, 2); int bytes = sqlite3_column_bytes(statement, 3); const void *blob = sqlite3_column_blob(statement, 3); - if (bytes <= 0 || (size_t)bytes > request_capacity || !blob) { + sqlite3_int64 kind_version = sqlite3_column_int64(statement, 5); + sqlite3_int64 joined_scanset = sqlite3_column_int64(statement, 6); + bool valid_types = sqlite3_column_type(statement, 0) == SQLITE_INTEGER && + sqlite3_column_type(statement, 1) == SQLITE_INTEGER && + sqlite3_column_type(statement, 2) == SQLITE_INTEGER && + sqlite3_column_type(statement, 3) == SQLITE_BLOB && + sqlite3_column_type(statement, 5) == SQLITE_INTEGER && + sqlite3_column_type(statement, 6) == SQLITE_INTEGER; + /* SQLite integers are signed and dynamically typed. Validate the + * zero-based next-work cursor and generic/typed dispatch identity before + * narrowing; request bytes remain immutable durable business input. */ + if (!valid_types || scanset <= 0 || joined_scanset != scanset || next < 0 || + count < 1 || count > 4096 || + next > count || bytes <= 0 || + (size_t)bytes > LARDON3D_ACQUISITION_CAMPAIGN_TASK_REQUEST_MAX_BYTES || + !blob || + !column_text_equals(statement, 4, + LARDON3D_ACQUISITION_CAMPAIGN_TASK_KIND) || + kind_version != LARDON3D_ACQUISITION_CAMPAIGN_TASK_KIND_VERSION) { + result = LARDON3D_PROJECT_DB_CORRUPT; + } else if ((result = validate_acquisition_campaign_prefix_locked( + database, task_id, scanset, next, count)) != + LARDON3D_PROJECT_DB_OK) { + /* The helper supplies CORRUPT and a durable-prefix diagnostic. */ + } else if (!size_probe && (size_t)bytes > request_capacity) { result = LARDON3D_PROJECT_DB_CONSTRAINT; } else { parameters->task_id = task_id; - parameters->scanset_id = (uint64_t)sqlite3_column_int64(statement, 0); - parameters->next_group_id = (uint32_t)sqlite3_column_int64(statement, 1); - parameters->group_count = (uint32_t)sqlite3_column_int64(statement, 2); - memcpy(request, blob, (size_t)bytes); - parameters->request = request; + parameters->scanset_id = (uint64_t)scanset; + parameters->next_group_id = (uint32_t)next; + parameters->group_count = (uint32_t)count; + if (!size_probe) + memcpy(request, blob, (size_t)bytes); + parameters->request = size_probe ? NULL : request; parameters->request_size = (size_t)bytes; } } @@ -2512,29 +2807,374 @@ Lardon3DProjectDbResult lardon3d_project_db_load_acquisition_campaign_task( return result; } +static bool project_db_optical_text_column(sqlite3_stmt *statement, int column, + size_t capacity, + bool allow_empty) { + if (sqlite3_column_type(statement, column) != SQLITE_TEXT) + return false; + int bytes = sqlite3_column_bytes(statement, column); + const unsigned char *text = sqlite3_column_text(statement, column); + return bytes >= 0 && (size_t)bytes < capacity && text && + (allow_empty || bytes > 0) && !memchr(text, '\0', (size_t)bytes); +} + +static Lardon3DProjectDbResult validate_retained_optical_configuration_locked( + Lardon3DProjectDb *database, sqlite3_int64 configuration_id) { + sqlite3_stmt *statement = NULL; + Lardon3DProjectDbResult result = prepare( + database, + "SELECT o.optical_configuration_id,o.camera_body_profile_id," + "o.lens_profile_id,o.focal_length_um,b.camera_body_profile_id," + "b.manufacturer,b.model,b.name,l.lens_profile_id,l.manufacturer,l.model," + "l.name,l.interface_kind,l.focal_range_kind,l.minimum_focal_um," + "l.maximum_focal_um FROM optical_configurations o LEFT JOIN " + "camera_body_profiles b ON b.camera_body_profile_id=" + "o.camera_body_profile_id LEFT JOIN lens_profiles l ON " + "l.lens_profile_id=o.lens_profile_id WHERE " + "o.optical_configuration_id=?1", + &statement); + if (result == LARDON3D_PROJECT_DB_OK) { + (void)sqlite3_bind_int64(statement, 1, configuration_id); + int code = sqlite3_step(statement); + if (code != SQLITE_ROW) { + result = code == SQLITE_DONE + ? LARDON3D_PROJECT_DB_CORRUPT + : sqlite_result(database, code, + "validate retained optical configuration"); + } else { + sqlite3_int64 stored_configuration = sqlite3_column_int64(statement, 0); + sqlite3_int64 body_id = sqlite3_column_int64(statement, 1); + sqlite3_int64 lens_id = sqlite3_column_int64(statement, 2); + sqlite3_int64 focal_um = sqlite3_column_int64(statement, 3); + sqlite3_int64 joined_body_id = sqlite3_column_int64(statement, 4); + sqlite3_int64 joined_lens_id = sqlite3_column_int64(statement, 8); + sqlite3_int64 interface_kind = sqlite3_column_int64(statement, 12); + sqlite3_int64 range_kind = sqlite3_column_int64(statement, 13); + sqlite3_int64 minimum_focal_um = sqlite3_column_int64(statement, 14); + sqlite3_int64 maximum_focal_um = sqlite3_column_int64(statement, 15); + bool integer_types = true; + const int integer_columns[] = {0, 1, 2, 3, 4, 8, 12, 13, 14, 15}; + for (size_t index = 0; + index < sizeof(integer_columns) / sizeof(integer_columns[0]); + ++index) { + if (sqlite3_column_type(statement, integer_columns[index]) != + SQLITE_INTEGER) + integer_types = false; + } + bool range_valid = + (range_kind == LARDON3D_OPTICAL_FOCAL_RANGE_UNKNOWN && + minimum_focal_um == 0 && maximum_focal_um == 0) || + (range_kind == LARDON3D_OPTICAL_FOCAL_RANGE_PRIME && + minimum_focal_um > 0 && minimum_focal_um == maximum_focal_um) || + (range_kind == LARDON3D_OPTICAL_FOCAL_RANGE_ZOOM && + minimum_focal_um > 0 && minimum_focal_um < maximum_focal_um); + bool focal_valid = + focal_um == 0 || + range_kind == LARDON3D_OPTICAL_FOCAL_RANGE_UNKNOWN || + (range_kind == LARDON3D_OPTICAL_FOCAL_RANGE_PRIME && + focal_um == minimum_focal_um) || + (range_kind == LARDON3D_OPTICAL_FOCAL_RANGE_ZOOM && + focal_um >= minimum_focal_um && focal_um <= maximum_focal_um); + /* INVARIANT: zero is the only absent focal encoding. A retained mapping + may publish a configuration only after the complete typed body/lens + graph and the focal-vs-lens relation have been proven valid. */ + if (!integer_types || stored_configuration <= 0 || + stored_configuration != configuration_id || body_id <= 0 || + lens_id <= 0 || joined_body_id != body_id || + joined_lens_id != lens_id || focal_um < 0 || focal_um > UINT32_MAX || + !project_db_optical_text_column( + statement, 5, LARDON3D_OPTICAL_TEXT_CAPACITY, false) || + !project_db_optical_text_column( + statement, 6, LARDON3D_OPTICAL_TEXT_CAPACITY, false) || + !project_db_optical_text_column( + statement, 7, LARDON3D_OPTICAL_TEXT_CAPACITY, false) || + !project_db_optical_text_column( + statement, 9, LARDON3D_OPTICAL_TEXT_CAPACITY, true) || + !project_db_optical_text_column( + statement, 10, LARDON3D_OPTICAL_TEXT_CAPACITY, true) || + !project_db_optical_text_column( + statement, 11, LARDON3D_OPTICAL_TEXT_CAPACITY, false) || + interface_kind < LARDON3D_OPTICAL_LENS_MANUAL || + interface_kind > LARDON3D_OPTICAL_LENS_INTEGRATED || + minimum_focal_um < 0 || minimum_focal_um > UINT32_MAX || + maximum_focal_um < 0 || maximum_focal_um > UINT32_MAX || + !range_valid || !focal_valid || + sqlite3_step(statement) != SQLITE_DONE) { + result = LARDON3D_PROJECT_DB_CORRUPT; + } + } + } + (void)sqlite3_finalize(statement); + return result; +} + +static Lardon3DProjectDbResult retain_campaign_optics_locked( + Lardon3DProjectDb *database, uint64_t task_id, uint32_t group_id, + uint64_t capture_id, bool new_mapping) { + sqlite3_stmt *statement = NULL; + Lardon3DProjectDbResult result = prepare( + database, + "SELECT g.optical_configuration_id,o.optical_configuration_id " + "FROM acquisition_campaign_tasks c LEFT JOIN " + "acquisition_campaign_group_optics g ON g.task_id=c.task_id AND " + "g.group_id=?2 LEFT JOIN optical_configurations o ON " + "o.optical_configuration_id=g.optical_configuration_id WHERE c.task_id=?1", + &statement); + bool group_assigned = false; + sqlite3_int64 configuration_id = 0; + if (result == LARDON3D_PROJECT_DB_OK) { + (void)sqlite3_bind_int64(statement, 1, (sqlite3_int64)task_id); + (void)sqlite3_bind_int64(statement, 2, (sqlite3_int64)group_id); + int code = sqlite3_step(statement); + if (code != SQLITE_ROW) { + result = code == SQLITE_DONE + ? LARDON3D_PROJECT_DB_CORRUPT + : sqlite_result(database, code, "load campaign group optics"); + } else { + int assignment_type = sqlite3_column_type(statement, 0); + int configuration_type = sqlite3_column_type(statement, 1); + configuration_id = sqlite3_column_int64(statement, 0); + sqlite3_int64 joined_configuration_id = sqlite3_column_int64(statement, 1); + bool absent = assignment_type == SQLITE_NULL && + configuration_type == SQLITE_NULL; + group_assigned = !absent; + if (!absent && + (assignment_type != SQLITE_INTEGER || + configuration_type != SQLITE_INTEGER || configuration_id <= 0 || + joined_configuration_id != configuration_id)) { + result = LARDON3D_PROJECT_DB_CORRUPT; + } + if (result == LARDON3D_PROJECT_DB_OK && + sqlite3_step(statement) != SQLITE_DONE) { + result = LARDON3D_PROJECT_DB_CORRUPT; + } + } + } + (void)sqlite3_finalize(statement); + statement = NULL; + if (result == LARDON3D_PROJECT_DB_OK && group_assigned) + result = validate_retained_optical_configuration_locked(database, + configuration_id); + if (result != LARDON3D_PROJECT_DB_OK || !group_assigned) + return result; + + result = prepare( + database, + "SELECT a.capture_id,a.optical_configuration_id,a.assignment_provenance," + "a.campaign_task_id,a.campaign_group_id,o.optical_configuration_id," + "g.task_id,g.group_id,g.optical_configuration_id,m.task_id,m.group_id," + "m.capture_id,c.task_id,c.next_group_id,c.group_count,c.scanset_id," + "c.request,t.task_id,t.task_kind,t.task_kind_version,p.capture_id," + "p.scanset_id,s.scanset_id,ps.scanset_id FROM " + "capture_optical_configurations a LEFT JOIN optical_configurations o ON " + "o.optical_configuration_id=a.optical_configuration_id LEFT JOIN " + "acquisition_campaign_group_optics g ON g.task_id=a.campaign_task_id AND " + "g.group_id=a.campaign_group_id LEFT JOIN acquisition_campaign_captures m " + "ON m.task_id=a.campaign_task_id AND m.group_id=a.campaign_group_id LEFT " + "JOIN acquisition_campaign_tasks c ON c.task_id=a.campaign_task_id LEFT " + "JOIN tasks t ON t.task_id=a.campaign_task_id LEFT JOIN captures p ON " + "p.capture_id=a.capture_id LEFT JOIN scansets s ON s.scanset_id=" + "c.scanset_id LEFT JOIN scansets ps ON ps.scanset_id=p.scanset_id WHERE " + "a.capture_id=?1", + &statement); + bool assignment_absent = false; + sqlite3_int64 retained_configuration = 0; + sqlite3_int64 provenance = 0; + sqlite3_int64 campaign_task = 0; + sqlite3_int64 campaign_group = 0; + sqlite3_int64 campaign_cursor = 0; + sqlite3_int64 campaign_group_count = 0; + sqlite3_int64 campaign_scanset = 0; + if (result == LARDON3D_PROJECT_DB_OK) { + (void)sqlite3_bind_int64(statement, 1, (sqlite3_int64)capture_id); + int code = sqlite3_step(statement); + if (code == SQLITE_DONE) { + assignment_absent = true; + } else if (code != SQLITE_ROW) { + result = sqlite_result(database, code, "load retained Capture optics"); + } else { + sqlite3_int64 stored_capture = sqlite3_column_int64(statement, 0); + retained_configuration = sqlite3_column_int64(statement, 1); + provenance = sqlite3_column_int64(statement, 2); + campaign_task = sqlite3_column_int64(statement, 3); + campaign_group = sqlite3_column_int64(statement, 4); + sqlite3_int64 joined_configuration = sqlite3_column_int64(statement, 5); + sqlite3_int64 joined_capture = sqlite3_column_int64(statement, 20); + sqlite3_int64 capture_scanset = sqlite3_column_int64(statement, 21); + sqlite3_int64 joined_capture_scanset = sqlite3_column_int64(statement, 23); + bool base_valid = + sqlite3_column_type(statement, 0) == SQLITE_INTEGER && + stored_capture == (sqlite3_int64)capture_id && stored_capture > 0 && + sqlite3_column_type(statement, 1) == SQLITE_INTEGER && + retained_configuration > 0 && + sqlite3_column_type(statement, 2) == SQLITE_INTEGER && + (provenance == LARDON3D_OPTICAL_ASSIGNMENT_CAMPAIGN || + provenance == LARDON3D_OPTICAL_ASSIGNMENT_CALLER_EXPLICIT) && + sqlite3_column_type(statement, 5) == SQLITE_INTEGER && + joined_configuration == retained_configuration && + sqlite3_column_type(statement, 20) == SQLITE_INTEGER && + joined_capture == stored_capture && + sqlite3_column_type(statement, 21) == SQLITE_INTEGER && + capture_scanset > 0 && + sqlite3_column_type(statement, 23) == SQLITE_INTEGER && + joined_capture_scanset == capture_scanset; + if (!base_valid) { + result = LARDON3D_PROJECT_DB_CORRUPT; + } else if (provenance == LARDON3D_OPTICAL_ASSIGNMENT_CALLER_EXPLICIT) { + if (sqlite3_column_type(statement, 3) != SQLITE_NULL || + sqlite3_column_type(statement, 4) != SQLITE_NULL) + result = LARDON3D_PROJECT_DB_CORRUPT; + } else { + campaign_cursor = sqlite3_column_int64(statement, 13); + campaign_group_count = sqlite3_column_int64(statement, 14); + campaign_scanset = sqlite3_column_int64(statement, 15); + int request_bytes = sqlite3_column_bytes(statement, 16); + bool campaign_valid = + sqlite3_column_type(statement, 3) == SQLITE_INTEGER && + campaign_task > 0 && + sqlite3_column_type(statement, 4) == SQLITE_INTEGER && + campaign_group > 0 && campaign_group <= 4096 && + sqlite3_column_type(statement, 6) == SQLITE_INTEGER && + sqlite3_column_int64(statement, 6) == campaign_task && + sqlite3_column_type(statement, 7) == SQLITE_INTEGER && + sqlite3_column_int64(statement, 7) == campaign_group && + sqlite3_column_type(statement, 8) == SQLITE_INTEGER && + sqlite3_column_int64(statement, 8) == retained_configuration && + sqlite3_column_type(statement, 9) == SQLITE_INTEGER && + sqlite3_column_int64(statement, 9) == campaign_task && + sqlite3_column_type(statement, 10) == SQLITE_INTEGER && + sqlite3_column_int64(statement, 10) == campaign_group && + sqlite3_column_type(statement, 11) == SQLITE_INTEGER && + sqlite3_column_int64(statement, 11) == stored_capture && + sqlite3_column_type(statement, 12) == SQLITE_INTEGER && + sqlite3_column_int64(statement, 12) == campaign_task && + sqlite3_column_type(statement, 13) == SQLITE_INTEGER && + campaign_cursor >= 0 && + sqlite3_column_type(statement, 14) == SQLITE_INTEGER && + campaign_group_count >= 1 && campaign_group_count <= 4096 && + campaign_cursor <= campaign_group_count && + campaign_group <= campaign_cursor && + campaign_group <= campaign_group_count && + sqlite3_column_type(statement, 15) == SQLITE_INTEGER && + campaign_scanset > 0 && campaign_scanset == capture_scanset && + sqlite3_column_type(statement, 16) == SQLITE_BLOB && + request_bytes > 0 && + (size_t)request_bytes <= + LARDON3D_ACQUISITION_CAMPAIGN_TASK_REQUEST_MAX_BYTES && + sqlite3_column_blob(statement, 16) != NULL && + sqlite3_column_type(statement, 17) == SQLITE_INTEGER && + sqlite3_column_int64(statement, 17) == campaign_task && + column_text_equals(statement, 18, + LARDON3D_ACQUISITION_CAMPAIGN_TASK_KIND) && + sqlite3_column_type(statement, 19) == SQLITE_INTEGER && + sqlite3_column_int64(statement, 19) == + LARDON3D_ACQUISITION_CAMPAIGN_TASK_KIND_VERSION && + sqlite3_column_type(statement, 22) == SQLITE_INTEGER && + sqlite3_column_int64(statement, 22) == campaign_scanset; + if (!campaign_valid) + result = LARDON3D_PROJECT_DB_CORRUPT; + } + if (result == LARDON3D_PROJECT_DB_OK && + sqlite3_step(statement) != SQLITE_DONE) + result = LARDON3D_PROJECT_DB_CORRUPT; + } + } + (void)sqlite3_finalize(statement); + statement = NULL; + + if (result == LARDON3D_PROJECT_DB_OK && !assignment_absent) + result = validate_retained_optical_configuration_locked( + database, retained_configuration); + if (result == LARDON3D_PROJECT_DB_OK && !assignment_absent && + provenance == LARDON3D_OPTICAL_ASSIGNMENT_CAMPAIGN) { + result = validate_acquisition_campaign_prefix_locked( + database, (uint64_t)campaign_task, campaign_scanset, campaign_cursor, + campaign_group_count); + } + if (result == LARDON3D_PROJECT_DB_OK && !assignment_absent) { + bool exact = retained_configuration == configuration_id && + provenance == LARDON3D_OPTICAL_ASSIGNMENT_CAMPAIGN && + campaign_task == (sqlite3_int64)task_id && + campaign_group == (sqlite3_int64)group_id; + /* A valid non-exact binding is a caller conflict only for a new mapping. + On retry, the already-durable mapping requires its exact campaign + binding. An exact binding observed before a new mapping is necessarily + an ahead/orphan relation and was rejected above by group<=cursor. */ + if (!exact) + result = new_mapping ? LARDON3D_PROJECT_DB_CONSTRAINT + : LARDON3D_PROJECT_DB_CORRUPT; + } + + if (result == LARDON3D_PROJECT_DB_OK && assignment_absent && !new_mapping) { + /* A committed mapping/cursor with an assigned group must already have the + campaign-derived Capture binding; repairing it here would conceal + durable corruption and weaken the crash-ordering contract. */ + result = LARDON3D_PROJECT_DB_CORRUPT; + } + if (result == LARDON3D_PROJECT_DB_OK && assignment_absent) { + result = prepare( + database, + "INSERT INTO capture_optical_configurations(" + "capture_id,optical_configuration_id,assignment_provenance," + "campaign_task_id,campaign_group_id) VALUES(?1,?2,1,?3,?4)", + &statement); + } + if (result == LARDON3D_PROJECT_DB_OK && assignment_absent) { + (void)sqlite3_bind_int64(statement, 1, (sqlite3_int64)capture_id); + (void)sqlite3_bind_int64(statement, 2, configuration_id); + (void)sqlite3_bind_int64(statement, 3, (sqlite3_int64)task_id); + (void)sqlite3_bind_int64(statement, 4, (sqlite3_int64)group_id); + result = step_done(database, statement, "retain campaign Capture optics"); + statement = NULL; + if (result == LARDON3D_PROJECT_DB_OK && + sqlite3_changes(database->connection) != 1) + result = LARDON3D_PROJECT_DB_CONSTRAINT; + } + (void)sqlite3_finalize(statement); +#ifdef LARDON3D_PROJECT_DB_TESTING + if (result == LARDON3D_PROJECT_DB_OK && assignment_absent && + getenv("LARDON3D_TEST_PROJECT_DB_FAIL_CAMPAIGN_OPTICS_COPY")) { + result = execute(database, "INSERT INTO missing_campaign_optics_table VALUES(1)", + "forced campaign optics retention failure"); + } +#endif + return result; +} + Lardon3DProjectDbResult lardon3d_project_db_retain_acquisition_campaign_capture( Lardon3DProjectDb *database, uint64_t task_id, uint32_t group_id, uint64_t capture_id, uint32_t next_group_id) { - /* S3-E already returned capture_id. This transaction retains the group - mapping and advances the one-based cursor together; generic progress and - checkpoint advance afterwards. An interruption after S3-E returns and - before this retention transaction cannot safely be repaired by guessing - identity from paths, hashes, metadata, or image IDs. */ + /* S3-E already returned capture_id. This transaction retains one-based + group N and advances the zero-based next-work position from N-1 to N; + generic progress and checkpoint advance afterwards. An interruption after + S3-E returns and before this transaction cannot safely be repaired by + guessing identity from paths, hashes, metadata, or image IDs. */ if (!database || !valid_task_id(task_id) || !valid_task_id(capture_id) || - group_id == 0 || next_group_id != group_id) return LARDON3D_PROJECT_DB_INVALID_ARGUMENT; + group_id == 0 || group_id > 4096 || next_group_id != group_id) + return LARDON3D_PROJECT_DB_INVALID_ARGUMENT; (void)pthread_mutex_lock(&database->mutex); Lardon3DProjectDbResult result = execute(database, "BEGIN IMMEDIATE", "begin capture retention"); sqlite3_stmt *statement = NULL; - uint32_t cursor = 0; - uint32_t group_count = 0; - uint64_t retained_capture_id = 0; + sqlite3_int64 cursor = 0; + sqlite3_int64 group_count = 0; + sqlite3_int64 scanset_id = 0; + sqlite3_int64 retained_capture_id = 0; + bool mapping_present = false; + bool insert_mapping = false; + bool advance_cursor = false; + bool requested_capture_missing = false; + bool requested_capture_corrupt = false; + sqlite3_int64 requested_scanset_id = 0; if (result == LARDON3D_PROJECT_DB_OK) { result = prepare(database, - "SELECT t.next_group_id,t.group_count,COALESCE(m.capture_id,0)," - "EXISTS(SELECT 1 FROM captures c WHERE c.capture_id=?3 AND " - "c.scanset_id=t.scanset_id) FROM acquisition_campaign_tasks t LEFT JOIN " - "acquisition_campaign_captures m ON m.task_id=t.task_id AND m.group_id=?2 " - "WHERE t.task_id=?1", &statement); + "SELECT c.next_group_id,c.group_count,m.group_id,m.capture_id," + "c.scanset_id,t.task_kind,t.task_kind_version,p.capture_id,p.scanset_id," + "s.scanset_id,c.request " + "FROM acquisition_campaign_tasks c LEFT JOIN tasks t ON t.task_id=c.task_id " + "LEFT JOIN acquisition_campaign_captures m ON m.task_id=c.task_id " + "AND m.group_id=?2 LEFT JOIN captures p ON p.capture_id=?3 " + "LEFT JOIN scansets s ON s.scanset_id=c.scanset_id " + "WHERE c.task_id=?1", &statement); } if (result == LARDON3D_PROJECT_DB_OK) { sqlite3_bind_int64(statement, 1, (sqlite3_int64)task_id); @@ -2544,31 +3184,108 @@ Lardon3DProjectDbResult lardon3d_project_db_retain_acquisition_campaign_capture( if (code == SQLITE_DONE) result = LARDON3D_PROJECT_DB_NOT_FOUND; else if (code != SQLITE_ROW) result = sqlite_result(database, code, "validate capture retention"); else { - cursor = (uint32_t)sqlite3_column_int64(statement, 0); - group_count = (uint32_t)sqlite3_column_int64(statement, 1); - retained_capture_id = (uint64_t)sqlite3_column_int64(statement, 2); - bool matching_scanset = sqlite3_column_int(statement, 3) == 1; - bool new_mapping = cursor + 1u == group_id && retained_capture_id == 0; - bool exact_retry = cursor == next_group_id && retained_capture_id == capture_id; - if (group_id > group_count || !matching_scanset || (!new_mapping && !exact_retry)) - result = LARDON3D_PROJECT_DB_CONSTRAINT; + int mapping_group_type = sqlite3_column_type(statement, 2); + int mapping_capture_type = sqlite3_column_type(statement, 3); + int requested_capture_type = sqlite3_column_type(statement, 7); + int requested_scanset_type = sqlite3_column_type(statement, 8); + cursor = sqlite3_column_int64(statement, 0); + group_count = sqlite3_column_int64(statement, 1); + sqlite3_int64 retained_group_id = sqlite3_column_int64(statement, 2); + retained_capture_id = sqlite3_column_int64(statement, 3); + scanset_id = sqlite3_column_int64(statement, 4); + sqlite3_int64 kind_version = sqlite3_column_int64(statement, 6); + sqlite3_int64 requested_capture_id = sqlite3_column_int64(statement, 7); + requested_scanset_id = sqlite3_column_int64(statement, 8); + sqlite3_int64 joined_scanset_id = sqlite3_column_int64(statement, 9); + int request_bytes = sqlite3_column_bytes(statement, 10); + bool mapping_absent = mapping_group_type == SQLITE_NULL && + mapping_capture_type == SQLITE_NULL; + mapping_present = mapping_group_type != SQLITE_NULL || + mapping_capture_type != SQLITE_NULL; + /* No persisted value is narrowed before storage class, sign, range and + * generic dispatch identity are established. COALESCE is deliberately + * avoided so an absent mapping cannot erase malformed durable storage. */ + if (sqlite3_column_type(statement, 0) != SQLITE_INTEGER || + sqlite3_column_type(statement, 1) != SQLITE_INTEGER || + sqlite3_column_type(statement, 4) != SQLITE_INTEGER || + sqlite3_column_type(statement, 6) != SQLITE_INTEGER || cursor < 0 || + group_count < 1 || group_count > 4096 || cursor > group_count || + scanset_id <= 0 || sqlite3_column_type(statement, 9) != SQLITE_INTEGER || + joined_scanset_id != scanset_id || + sqlite3_column_type(statement, 10) != SQLITE_BLOB || + request_bytes <= 0 || + (size_t)request_bytes > + LARDON3D_ACQUISITION_CAMPAIGN_TASK_REQUEST_MAX_BYTES || + sqlite3_column_blob(statement, 10) == NULL || + !column_text_equals(statement, 5, + LARDON3D_ACQUISITION_CAMPAIGN_TASK_KIND) || + kind_version != LARDON3D_ACQUISITION_CAMPAIGN_TASK_KIND_VERSION || + (!mapping_absent && + (mapping_group_type != SQLITE_INTEGER || + mapping_capture_type != SQLITE_INTEGER || retained_group_id <= 0 || + retained_group_id != (sqlite3_int64)group_id || + retained_capture_id <= 0))) { + result = LARDON3D_PROJECT_DB_CORRUPT; + } else { + requested_capture_missing = requested_capture_type == SQLITE_NULL && + requested_scanset_type == SQLITE_NULL; + requested_capture_corrupt = + !requested_capture_missing && + (requested_capture_type != SQLITE_INTEGER || + requested_scanset_type != SQLITE_INTEGER || + requested_capture_id <= 0 || requested_scanset_id <= 0 || + requested_capture_id != (sqlite3_int64)capture_id); + } } } sqlite3_finalize(statement); statement = NULL; - if (result == LARDON3D_PROJECT_DB_OK && retained_capture_id == 0) + if (result == LARDON3D_PROJECT_DB_OK) + result = validate_acquisition_campaign_prefix_locked( + database, task_id, scanset_id, cursor, group_count); + if (result == LARDON3D_PROJECT_DB_OK && requested_capture_missing) + result = LARDON3D_PROJECT_DB_CONSTRAINT; + if (result == LARDON3D_PROJECT_DB_OK && requested_capture_corrupt) + result = LARDON3D_PROJECT_DB_CORRUPT; + if (result == LARDON3D_PROJECT_DB_OK && + (requested_scanset_id != scanset_id || + (sqlite3_int64)group_id > group_count)) + result = LARDON3D_PROJECT_DB_CONSTRAINT; + if (result == LARDON3D_PROJECT_DB_OK) { + bool new_mapping = cursor + 1 == (sqlite3_int64)group_id && !mapping_present; + bool exact_retry = cursor == (sqlite3_int64)next_group_id && + mapping_present && + retained_capture_id == (sqlite3_int64)capture_id; + if (!new_mapping && !exact_retry) { + result = LARDON3D_PROJECT_DB_CONSTRAINT; + } else { + insert_mapping = new_mapping; + advance_cursor = new_mapping; + } + } + if (result == LARDON3D_PROJECT_DB_OK && insert_mapping) result = prepare(database, "INSERT INTO acquisition_campaign_captures(task_id,group_id,capture_id) VALUES(?1,?2,?3)", &statement); - if (result == LARDON3D_PROJECT_DB_OK && retained_capture_id == 0) { + if (result == LARDON3D_PROJECT_DB_OK && insert_mapping) { sqlite3_bind_int64(statement, 1, (sqlite3_int64)task_id); sqlite3_bind_int64(statement, 2, group_id); sqlite3_bind_int64(statement, 3, (sqlite3_int64)capture_id); result = step_done(database, statement, "retain campaign capture"); + statement = NULL; if (result == LARDON3D_PROJECT_DB_OK && sqlite3_changes(database->connection) != 1) result = LARDON3D_PROJECT_DB_CONSTRAINT; } - if (result == LARDON3D_PROJECT_DB_OK && cursor + 1u == group_id) { + (void)sqlite3_finalize(statement); + statement = NULL; + if (result == LARDON3D_PROJECT_DB_OK) { + /* INVARIANT: an explicit group configuration becomes Capture provenance + in the same transaction after mapping insertion and before cursor + publication. An unassigned group deliberately produces no row. */ + result = retain_campaign_optics_locked(database, task_id, group_id, + capture_id, insert_mapping); + } + if (result == LARDON3D_PROJECT_DB_OK && advance_cursor) { result = prepare(database, "UPDATE acquisition_campaign_tasks SET next_group_id=?2 WHERE task_id=?1 AND " "next_group_id=?3 AND group_count>=?2", &statement); @@ -2577,10 +3294,12 @@ Lardon3DProjectDbResult lardon3d_project_db_retain_acquisition_campaign_capture( sqlite3_bind_int64(statement, 2, next_group_id); sqlite3_bind_int64(statement, 3, (sqlite3_int64)group_id - 1); result = step_done(database, statement, "advance campaign cursor"); + statement = NULL; if (result == LARDON3D_PROJECT_DB_OK && sqlite3_changes(database->connection) != 1) result = LARDON3D_PROJECT_DB_CONSTRAINT; } } + (void)sqlite3_finalize(statement); if (result == LARDON3D_PROJECT_DB_OK) result = execute(database, "COMMIT", "commit capture retention"); if (result != LARDON3D_PROJECT_DB_OK) @@ -2592,14 +3311,20 @@ Lardon3DProjectDbResult lardon3d_project_db_retain_acquisition_campaign_capture( Lardon3DProjectDbResult lardon3d_project_db_load_acquisition_campaign_capture( Lardon3DProjectDb *database, uint64_t task_id, uint32_t group_id, Lardon3DProjectDbAcquisitionCampaignCapture *capture) { - if (!database || !valid_task_id(task_id) || !capture) + if (!database || !valid_task_id(task_id) || group_id == 0 || group_id > 4096 || + !capture) return LARDON3D_PROJECT_DB_INVALID_ARGUMENT; memset(capture, 0, sizeof(*capture)); (void)pthread_mutex_lock(&database->mutex); sqlite3_stmt *statement = NULL; Lardon3DProjectDbResult result = prepare(database, - "SELECT capture_id FROM acquisition_campaign_captures WHERE task_id=?1 AND group_id=?2", - &statement); + "SELECT m.group_id,m.capture_id,c.group_count,c.next_group_id,c.scanset_id," + "t.task_kind,t.task_kind_version,p.capture_id,p.scanset_id,s.scanset_id " + "FROM acquisition_campaign_tasks c LEFT JOIN acquisition_campaign_captures m " + "ON m.task_id=c.task_id AND m.group_id=?2 LEFT JOIN tasks t ON t.task_id=c.task_id " + "LEFT JOIN captures p ON p.capture_id=m.capture_id " + "LEFT JOIN scansets s ON s.scanset_id=c.scanset_id " + "WHERE c.task_id=?1", &statement); if (result == LARDON3D_PROJECT_DB_OK) { sqlite3_bind_int64(statement, 1, (sqlite3_int64)task_id); sqlite3_bind_int64(statement, 2, group_id); @@ -2607,8 +3332,54 @@ Lardon3DProjectDbResult lardon3d_project_db_load_acquisition_campaign_capture( if (code == SQLITE_DONE) result = LARDON3D_PROJECT_DB_NOT_FOUND; else if (code != SQLITE_ROW) result = sqlite_result(database, code, "load retained capture"); else { - capture->group_id = group_id; - capture->capture_id = (uint64_t)sqlite3_column_int64(statement, 0); + int mapping_group_type = sqlite3_column_type(statement, 0); + int mapping_capture_type = sqlite3_column_type(statement, 1); + sqlite3_int64 persisted_group = sqlite3_column_int64(statement, 0); + sqlite3_int64 capture_id = sqlite3_column_int64(statement, 1); + sqlite3_int64 group_count = sqlite3_column_int64(statement, 2); + sqlite3_int64 next_group_id = sqlite3_column_int64(statement, 3); + sqlite3_int64 scanset_id = sqlite3_column_int64(statement, 4); + sqlite3_int64 kind_version = sqlite3_column_int64(statement, 6); + sqlite3_int64 joined_capture_id = sqlite3_column_int64(statement, 7); + sqlite3_int64 capture_scanset_id = sqlite3_column_int64(statement, 8); + sqlite3_int64 joined_scanset_id = sqlite3_column_int64(statement, 9); + bool mapping_absent = mapping_group_type == SQLITE_NULL && + mapping_capture_type == SQLITE_NULL; + if ((!mapping_absent && + (mapping_group_type != SQLITE_INTEGER || + mapping_capture_type != SQLITE_INTEGER)) || + sqlite3_column_type(statement, 2) != SQLITE_INTEGER || + sqlite3_column_type(statement, 3) != SQLITE_INTEGER || + sqlite3_column_type(statement, 4) != SQLITE_INTEGER || + sqlite3_column_type(statement, 6) != SQLITE_INTEGER || + sqlite3_column_type(statement, 9) != SQLITE_INTEGER || + (!mapping_absent && + (sqlite3_column_type(statement, 7) != SQLITE_INTEGER || + sqlite3_column_type(statement, 8) != SQLITE_INTEGER)) || + group_count < 1 || group_count > 4096 || persisted_group > group_count || + next_group_id < 0 || next_group_id > group_count || + scanset_id <= 0 || joined_scanset_id != scanset_id || + (!mapping_absent && + (persisted_group <= 0 || persisted_group != group_id || + persisted_group > next_group_id || capture_id <= 0 || + joined_capture_id != capture_id || capture_scanset_id <= 0 || + capture_scanset_id != scanset_id)) || + !column_text_equals(statement, 5, + LARDON3D_ACQUISITION_CAMPAIGN_TASK_KIND) || + kind_version != LARDON3D_ACQUISITION_CAMPAIGN_TASK_KIND_VERSION) { + result = LARDON3D_PROJECT_DB_CORRUPT; + } else if ((result = validate_acquisition_campaign_prefix_locked( + database, task_id, scanset_id, next_group_id, + group_count)) != LARDON3D_PROJECT_DB_OK) { + /* Never expose resume_capture_id from an incoherent durable prefix. */ + } else if ((sqlite3_int64)group_id > group_count) { + result = LARDON3D_PROJECT_DB_CONSTRAINT; + } else if (mapping_absent) { + result = LARDON3D_PROJECT_DB_NOT_FOUND; + } else { + capture->group_id = (uint32_t)persisted_group; + capture->capture_id = (uint64_t)capture_id; + } } } sqlite3_finalize(statement); @@ -2640,8 +3411,10 @@ Lardon3DProjectDbResult lardon3d_project_db_load_photo_quality_task( sqlite3_stmt *statement = NULL; Lardon3DProjectDbResult result = prepare( database, - "SELECT scanset_id,next_group_id,group_count,request FROM photo_quality_triage_tasks " - "WHERE task_id=?1", + "SELECT q.scanset_id,q.next_group_id,q.group_count,q.request,t.task_kind," + "t.task_kind_version,s.scanset_id FROM photo_quality_triage_tasks q " + "LEFT JOIN tasks t ON t.task_id=q.task_id LEFT JOIN scansets s " + "ON s.scanset_id=q.scanset_id WHERE q.task_id=?1", &statement); if (result == LARDON3D_PROJECT_DB_OK) { sqlite3_bind_int64(statement, 1, (sqlite3_int64)task_id); @@ -2655,15 +3428,24 @@ Lardon3DProjectDbResult lardon3d_project_db_load_photo_quality_task( sqlite3_int64 scanset = sqlite3_column_int64(statement, 0); sqlite3_int64 next = sqlite3_column_int64(statement, 1); sqlite3_int64 count = sqlite3_column_int64(statement, 2); + sqlite3_int64 kind_version = sqlite3_column_int64(statement, 5); + sqlite3_int64 joined_scanset = sqlite3_column_int64(statement, 6); int bytes = sqlite3_column_bytes(statement, 3); const void *blob = sqlite3_column_blob(statement, 3); /* SQLite exposes signed 64-bit storage. Validate identity, operational bounds, and the one-based cursor relation before narrowing to the C API. - N+1 is the only completed cursor; zero and values beyond N+1 are corrupt. */ - if (!integer_columns || sqlite3_column_type(statement, 3) != SQLITE_BLOB || scanset <= 0 || - next < 1 || count < 1 || count > 4096 || + LEFT JOIN preserves a present typed row so missing generic Task/ScanSet + parents are corruption rather than being misreported as NOT_FOUND. N+1 + is the only completed cursor; zero and values beyond N+1 are corrupt. */ + if (!integer_columns || sqlite3_column_type(statement, 3) != SQLITE_BLOB || + sqlite3_column_type(statement, 5) != SQLITE_INTEGER || + sqlite3_column_type(statement, 6) != SQLITE_INTEGER || scanset <= 0 || + joined_scanset != scanset || next < 1 || count < 1 || count > 4096 || next > count + 1 || bytes <= 0 || - (size_t)bytes > request_capacity || !blob) { + (size_t)bytes > LARDON3D_PHOTO_QUALITY_TASK_REQUEST_MAX_BYTES || + (size_t)bytes > request_capacity || !blob || + !column_text_equals(statement, 4, LARDON3D_PHOTO_QUALITY_TASK_KIND) || + kind_version != LARDON3D_PHOTO_QUALITY_TASK_KIND_VERSION) { result = LARDON3D_PROJECT_DB_CORRUPT; } else { memcpy(request, blob, (size_t)bytes); @@ -3009,7 +3791,10 @@ static bool read_selected_execution_row(sqlite3_stmt *statement, sqlite3_int64 id = sqlite3_column_int64(statement, 0); sqlite3_int64 quality = sqlite3_column_int64(statement, 1); sqlite3_int64 campaign = sqlite3_column_int64(statement, 2); - bool has_scope = sqlite3_column_type(statement, 3) != SQLITE_NULL; + int scope_type = sqlite3_column_type(statement, 3); + bool has_scope = scope_type != SQLITE_NULL; + if (has_scope && scope_type != SQLITE_INTEGER) + return false; sqlite3_int64 scope = has_scope ? sqlite3_column_int64(statement, 3) : 0; sqlite3_int64 stage = sqlite3_column_int64(statement, 4); sqlite3_int64 next = sqlite3_column_int64(statement, 5); @@ -3092,8 +3877,10 @@ Lardon3DProjectDbResult lardon3d_project_db_load_selected_execution_item( if (code == SQLITE_DONE) result = LARDON3D_PROJECT_DB_NOT_FOUND; else if (code != SQLITE_ROW) result = sqlite_result(database, code, "load selected item"); else { - bool has_source_asset = sqlite3_column_type(statement, 5) != SQLITE_NULL; - bool has_image = sqlite3_column_type(statement, 6) != SQLITE_NULL; + int source_asset_type = sqlite3_column_type(statement, 5); + int image_type = sqlite3_column_type(statement, 6); + bool has_source_asset = source_asset_type != SQLITE_NULL; + bool has_image = image_type != SQLITE_NULL; sqlite3_int64 index = sqlite3_column_int64(statement, 0); sqlite3_int64 quality = sqlite3_column_int64(statement, 1); sqlite3_int64 campaign = sqlite3_column_int64(statement, 2); @@ -3107,6 +3894,8 @@ Lardon3DProjectDbResult lardon3d_project_db_load_selected_execution_item( sqlite3_column_type(statement, 2) != SQLITE_INTEGER || sqlite3_column_type(statement, 3) != SQLITE_INTEGER || sqlite3_column_type(statement, 4) != SQLITE_INTEGER || + (has_source_asset && source_asset_type != SQLITE_INTEGER) || + (has_image && image_type != SQLITE_INTEGER) || sqlite3_column_type(statement, 7) != SQLITE_INTEGER || index < 0 || index >= count || count <= 0 || count > 4096 || quality <= 0 || quality > 4096 || campaign <= 0 || campaign > 4096 || capture <= 0 || diff --git a/src/resource_governor.c b/src/resource_governor.c index 2c83236..d88f9e1 100644 --- a/src/resource_governor.c +++ b/src/resource_governor.c @@ -122,6 +122,10 @@ struct Lardon3DResourceGovernor { bool internal_now_known; struct timespec internal_now; bool internal_force_capture_failure; +#if defined(LARDON3D_RESOURCE_GOVERNOR_CAPTURE_TESTING) + bool internal_capture_snapshot_override; + Lardon3DResourceSnapshot internal_capture_snapshot; +#endif /* Host observations are private operational evidence. In particular RSS * is never charged as Task-owned memory and pool utilization never * substitutes for Gate G load/pressure admission. Cumulative inputs are @@ -136,6 +140,22 @@ struct Lardon3DResourceGovernor { uint64_t telemetry_swap_pages_out; Lardon3DResourceReservation *active; Lardon3DResourceReservation *released; + /* The Governor owns orchestration metadata, never the physical controller + * or caller lease storage. The fixed address registry makes wrapper + * release exact and bounds all external-resource bookkeeping. */ + bool external_storage_registered; + Lardon3DSsdController *external_storage_controller; + Lardon3DResourceExternalStorage external_storage; + /* Begin freezes provenance and the last physical count before releasing + * this mutex. Finish may then distinguish its exact saturated completion + * from an arbitrary equal-generation public copy. */ + bool external_storage_operation_active; + bool external_storage_operation_acquire; + Lardon3DSsdScratchLease *external_storage_operation_lease; + size_t external_storage_operation_start_lease_count; + Lardon3DSsdScratchLease *external_storage_leases[ + LARDON3D_SSD_MAX_SCRATCH_LEASES]; + size_t external_storage_lease_count; }; static bool cpu_mask_test( @@ -665,9 +685,10 @@ lardon3d_resource_governor_internal_configure_driver_policy(void) } static void -cpu_policy_fallback( - const Lardon3DHardwareProfile *profile, - const Lardon3DResourcePolicy *policy, +cpu_policy_count_fallback( + unsigned int allowed_cpu_count, + unsigned int reserve_cpu_count, + bool externally_constrained, Lardon3DResourceCpuPolicyDiagnostic *diagnostic, const char *reason ) @@ -677,9 +698,9 @@ cpu_policy_fallback( *diagnostic = (Lardon3DResourceCpuPolicyDiagnostic) { .runtime_thread_policy_active = mesa_cache_disabled, .mesa_shader_cache_disabled = mesa_cache_disabled, - .compute_cpu_count = profile->logical_cpu_count - - policy->system_cpu_reserve, - .reserved_cpu_count = policy->system_cpu_reserve, + .externally_constrained = externally_constrained, + .compute_cpu_count = allowed_cpu_count - reserve_cpu_count, + .reserved_cpu_count = reserve_cpu_count, }; (void)snprintf(diagnostic->reason, sizeof(diagnostic->reason), "%s", reason); @@ -690,6 +711,22 @@ cpu_policy_fallback( : "mesa-disk-cache-safety-policy-not-established"); } +static unsigned int +additional_allowed_cpu_reserve( + const Lardon3DHardwareProfile *profile, + const Lardon3DResourcePolicy *policy, + size_t allowed_cpu_count +) +{ + /* CPUs already removed by an external affinity mask are host capacity the + * application cannot consume. Count them toward the requested reserve so + * the Governor does not subtract the same host allowance twice. */ + unsigned int unavailable = profile->logical_cpu_count + - (unsigned int)allowed_cpu_count; + return policy->system_cpu_reserve > unavailable + ? policy->system_cpu_reserve - unavailable : 0; +} + static bool cpu_topology_input_masks( const Lardon3DResourceCpuTopologyInput *input, @@ -743,7 +780,8 @@ build_cpu_policy( Lardon3DResourceCpuPolicyDiagnostic *diagnostic ) { - cpu_policy_fallback(profile, policy, diagnostic, + cpu_policy_count_fallback(profile->logical_cpu_count, + policy->system_cpu_reserve, false, diagnostic, "fallback-portable-affinity-unavailable"); if (!input || !input->affinity_available) { return; @@ -753,17 +791,19 @@ build_cpu_policy( unsigned int core_ids[LARDON3D_RESOURCE_CPU_MAX] = {0}; if (!cpu_topology_input_masks(input, allowed, package_ids, core_ids) || input->allowed_cpu_count > profile->logical_cpu_count) { - cpu_policy_fallback(profile, policy, diagnostic, + cpu_policy_count_fallback(profile->logical_cpu_count, + policy->system_cpu_reserve, false, diagnostic, "fallback-portable-affinity-invalid"); return; } memcpy(diagnostic->allowed_mask, allowed, sizeof(allowed)); - unsigned int portable_budget = profile->logical_cpu_count - - policy->system_cpu_reserve; - if (input->allowed_cpu_count < profile->logical_cpu_count - && input->allowed_cpu_count <= portable_budget) { + bool externally_constrained = + input->allowed_cpu_count < profile->logical_cpu_count; + unsigned int requested_reserve = additional_allowed_cpu_reserve( + profile, policy, input->allowed_cpu_count); + if (requested_reserve == 0) { diagnostic->affinity_configured = true; - diagnostic->externally_constrained = true; + diagnostic->externally_constrained = externally_constrained; diagnostic->compute_cpu_count = (unsigned int)input->allowed_cpu_count; diagnostic->reserved_cpu_count = 0; memcpy(diagnostic->compute_mask, allowed, sizeof(allowed)); @@ -772,65 +812,115 @@ build_cpu_policy( return; } if (!input->topology_available) { - cpu_policy_fallback(profile, policy, diagnostic, + cpu_policy_count_fallback( + (unsigned int)input->allowed_cpu_count, requested_reserve, + externally_constrained, diagnostic, "fallback-portable-topology-unavailable"); memcpy(diagnostic->allowed_mask, allowed, sizeof(allowed)); return; } + typedef struct { + unsigned int package_id; + unsigned int core_id; + unsigned int cpu_count; + } CpuCoreGroup; + CpuCoreGroup groups[LARDON3D_RESOURCE_CPU_MAX]; + size_t group_count = 0; + for (size_t index = 0; index < input->allowed_cpu_count; ++index) { + unsigned int cpu = input->allowed_cpu_ids[index]; + size_t group = 0; + while (group < group_count + && (groups[group].package_id != package_ids[cpu] + || groups[group].core_id != core_ids[cpu])) { + ++group; + } + if (group == group_count) { + groups[group_count++] = (CpuCoreGroup) { + .package_id = package_ids[cpu], + .core_id = core_ids[cpu], + }; + } + ++groups[group].cpu_count; + } + /* Descending physical identity preserves the established deterministic + * preference when several whole-core subsets have the same minimal size. */ + for (size_t left = 0; left < group_count; ++left) { + for (size_t right = left + 1; right < group_count; ++right) { + if (groups[right].package_id > groups[left].package_id + || (groups[right].package_id == groups[left].package_id + && groups[right].core_id > groups[left].core_id)) { + CpuCoreGroup temporary = groups[left]; + groups[left] = groups[right]; + groups[right] = temporary; + } + } + } + + /* Fixed CPU_MAX subset DP finds the smallest complete-core reserve at or + * above the logical target. It performs bounded O(CPU_MAX^2) work during + * Governor construction/policy changes only; no scheduler or heap state is + * introduced. Parent links reconstruct one deterministic selected subset. */ + bool reachable[LARDON3D_RESOURCE_CPU_MAX + 1] = {false}; + unsigned int parent_sum[LARDON3D_RESOURCE_CPU_MAX + 1] = {0}; + size_t parent_group[LARDON3D_RESOURCE_CPU_MAX + 1] = {0}; + reachable[0] = true; + unsigned int allowed_count = (unsigned int)input->allowed_cpu_count; + for (size_t group = 0; group < group_count; ++group) { + unsigned int amount = groups[group].cpu_count; + for (unsigned int sum = allowed_count - amount + 1; sum-- > 0;) { + unsigned int next = sum + amount; + if (reachable[sum] && !reachable[next]) { + reachable[next] = true; + parent_sum[next] = sum; + parent_group[next] = group; + } + } + } + unsigned int reserved_count = requested_reserve; + while (reserved_count < allowed_count && !reachable[reserved_count]) { + ++reserved_count; + } + if (reserved_count >= allowed_count) { + /* Tiny/asymmetric hosts may have no whole-core subset large enough to + * meet the logical target while leaving compute capacity. Prefer the + * largest complete-core reserve below target over splitting SMT peers. */ + reserved_count = requested_reserve; + while (reserved_count > 0 && !reachable[reserved_count]) { + --reserved_count; + } + } + if (reserved_count == 0) { + cpu_policy_count_fallback(allowed_count, requested_reserve, + externally_constrained, diagnostic, + "fallback-portable-topology-unsplittable"); + memcpy(diagnostic->allowed_mask, allowed, sizeof(allowed)); + return; + } + + bool selected_groups[LARDON3D_RESOURCE_CPU_MAX] = {false}; + for (unsigned int sum = reserved_count; sum != 0; + sum = parent_sum[sum]) { + selected_groups[parent_group[sum]] = true; + } uint64_t compute[LARDON3D_RESOURCE_CPU_MASK_WORDS]; uint64_t reserved[LARDON3D_RESOURCE_CPU_MASK_WORDS] = {0}; memcpy(compute, allowed, sizeof(compute)); - unsigned int compute_count = (unsigned int)input->allowed_cpu_count; - unsigned int reserved_count = 0; - while (reserved_count < policy->system_cpu_reserve) { - bool group_found = false; - unsigned int selected_package = 0; - unsigned int selected_core = 0; - for (size_t index = 0; index < input->allowed_cpu_count; ++index) { - unsigned int cpu = input->allowed_cpu_ids[index]; - if (!cpu_mask_test(compute, cpu)) { - continue; - } - if (!group_found || package_ids[cpu] > selected_package - || (package_ids[cpu] == selected_package - && core_ids[cpu] > selected_core)) { - selected_package = package_ids[cpu]; - selected_core = core_ids[cpu]; - group_found = true; - } - } - unsigned int group_count = 0; - if (group_found) { - for (size_t index = 0; index < input->allowed_cpu_count; ++index) { - unsigned int cpu = input->allowed_cpu_ids[index]; - if (cpu_mask_test(compute, cpu) - && package_ids[cpu] == selected_package - && core_ids[cpu] == selected_core) { - ++group_count; - } - } - } - if (!group_found || group_count == 0 || group_count >= compute_count) { - cpu_policy_fallback(profile, policy, diagnostic, - "fallback-portable-topology-incomplete"); - memcpy(diagnostic->allowed_mask, allowed, sizeof(allowed)); - return; - } - for (size_t index = 0; index < input->allowed_cpu_count; ++index) { - unsigned int cpu = input->allowed_cpu_ids[index]; - if (cpu_mask_test(compute, cpu) - && package_ids[cpu] == selected_package - && core_ids[cpu] == selected_core) { + for (size_t index = 0; index < input->allowed_cpu_count; ++index) { + unsigned int cpu = input->allowed_cpu_ids[index]; + for (size_t group = 0; group < group_count; ++group) { + if (selected_groups[group] + && groups[group].package_id == package_ids[cpu] + && groups[group].core_id == core_ids[cpu]) { compute[cpu / 64] &= ~(UINT64_C(1) << (cpu % 64)); cpu_mask_set(reserved, cpu); + break; } } - compute_count -= group_count; - reserved_count += group_count; } diagnostic->affinity_configured = true; - diagnostic->compute_cpu_count = compute_count; + diagnostic->externally_constrained = externally_constrained; + diagnostic->compute_cpu_count = allowed_count - reserved_count; diagnostic->reserved_cpu_count = reserved_count; memcpy(diagnostic->compute_mask, compute, sizeof(compute)); memcpy(diagnostic->reserved_mask, reserved, sizeof(reserved)); @@ -1532,10 +1622,10 @@ static unsigned int next_trial_cpu(unsigned int current, unsigned int maximum) { if (current >= maximum) return maximum; - unsigned int next = current < 2 ? 2 - : current < 4 ? 4 - : current < 8 ? 8 - : 12; + /* CPU trials are portable capability rungs, not a fingerprint of any + * validation host. Double without overflow, then use a + * non-power-of-two capability/compute maximum as the exact final rung. */ + unsigned int next = current > UINT_MAX / 2 ? maximum : current * 2; return next < maximum ? next : maximum; } @@ -1608,25 +1698,64 @@ open_next_capability_trial_locked( feedback->trial_dimension = LARDON3D_CAPABILITY_TRIAL_NONE; } +typedef struct { + uint64_t admission_floor; + uint64_t caution_floor; + uint64_t hard_floor; + bool caution_is_non_escalating_band; +} Lardon3DMemoryFloors; + +static uint64_t +default_host_memory_reserve(uint64_t total) +{ + const uint64_t normal = UINT64_C(3) * 1024 * 1024 * 1024; + if (total > normal) { + return normal; + } + /* A sub-3-GiB host cannot retain the normal absolute floor and still admit + * any work. Keep a deterministic quarter for the host; clipping avoids a + * zero/invalid reserve on synthetic tiny profiles without underflow. */ + uint64_t degraded = total / 4; + if (degraded == 0 && total > 1) { + degraded = 1; + } + return degraded < total ? degraded : total - 1; +} + +static uint64_t +default_host_memory_caution(uint64_t total, uint64_t admission_floor) +{ + const uint64_t normal = UINT64_C(3) * 1024 * 1024 * 1024; + const uint64_t caution = UINT64_C(4) * 1024 * 1024 * 1024; + if (admission_floor == normal) { + return total < caution ? total : caution; + } + uint64_t degraded = total / 3; + return degraded > admission_floor ? degraded : admission_floor; +} + static void -internal_pressure_floors_locked( +internal_memory_floors_locked( const Lardon3DResourceGovernor *governor, - uint64_t *soft_floor, - uint64_t *hard_floor + Lardon3DMemoryFloors *floors ) { - *soft_floor = governor->policy.system_memory_reserve_bytes; - *hard_floor = governor->policy.emergency_memory_floor_bytes; - const uint64_t gib = 1024ULL * 1024ULL * 1024ULL; - const uint64_t total = governor->profile.memory_total_bytes; - /* Compute Governor v2's validated current-host operating point is scoped - * to the default 16 GiB policy. Generic Gate G defaults remain unchanged - * for every other profile or an explicitly customized policy. */ - if (total >= 15 * gib && total <= 17 * gib - && governor->policy.system_memory_reserve_bytes == total / 4 - && governor->policy.emergency_memory_floor_bytes == total / 8) { - *soft_floor = 3 * gib; - *hard_floor = 2 * gib; + uint64_t default_floor = default_host_memory_reserve( + governor->profile.memory_total_bytes); + *floors = (Lardon3DMemoryFloors) { + .admission_floor = governor->policy.system_memory_reserve_bytes, + .caution_floor = governor->policy.system_memory_reserve_bytes, + .hard_floor = governor->policy.emergency_memory_floor_bytes, + }; + if (governor->policy.system_memory_reserve_bytes == default_floor + && governor->policy.emergency_memory_floor_bytes == default_floor) { + /* Default capacity always subtracts the hard reserve, never the 4 GiB + * caution level. The caution band only abandons aggressive trials and + * reduces current work while MemAvailable remains above the reserve. */ + floors->caution_floor = default_host_memory_caution( + governor->profile.memory_total_bytes, default_floor); + floors->hard_floor = default_floor; + floors->caution_is_non_escalating_band = true; } } @@ -1763,13 +1892,16 @@ lardon3d_resource_policy_default( if (!valid_profile(profile) || !policy) { return false; } - unsigned int cpu_reserve = profile->logical_cpu_count / 4; - if (cpu_reserve == 0 && profile->logical_cpu_count > 2) { - cpu_reserve = 1; - } + /* Four logical CPUs are the normal host reserve. Small systems degrade + * deterministically by retaining one compute CPU; complete topology may + * later adjust this logical target to whole physical-core groups. */ + unsigned int cpu_reserve = profile->logical_cpu_count > 4 + ? 4 : profile->logical_cpu_count - 1; + uint64_t memory_reserve = default_host_memory_reserve( + profile->memory_total_bytes); *policy = (Lardon3DResourcePolicy) { - .system_memory_reserve_bytes = profile->memory_total_bytes / 4, - .emergency_memory_floor_bytes = profile->memory_total_bytes / 8, + .system_memory_reserve_bytes = memory_reserve, + .emergency_memory_floor_bytes = memory_reserve, .gpu_memory_reserve_bytes = profile->gpu_memory_known ? profile->gpu_memory_total_bytes / 8 : 0, @@ -1896,6 +2028,808 @@ lardon3d_resource_governor_set_policy( return accepted; } +bool +lardon3d_resource_governor_get_policy( + Lardon3DResourceGovernor *governor, + Lardon3DResourcePolicy *policy +) +{ + if (!governor || !policy) { + return false; + } + (void)pthread_mutex_lock(&governor->mutex); + *policy = governor->policy; + (void)pthread_mutex_unlock(&governor->mutex); + return true; +} + +static bool +bounded_text_terminated(const char *text, size_t capacity) +{ + return text && memchr(text, '\0', capacity) != NULL; +} + +static bool +bounded_exact_identity(const char *text, size_t capacity) +{ + return bounded_text_terminated(text, capacity) && text[0] != '\0' + && strcmp(text, "UNKNOWN") != 0; +} + +static bool +ssd_snapshot_has_complete_pair(const Lardon3DSsdSnapshot *snapshot) +{ + return snapshot->device_detected && snapshot->swap_detected + && snapshot->scratch_detected && snapshot->pairing_valid + && bounded_exact_identity(snapshot->drive_identity, + sizeof(snapshot->drive_identity)) + && bounded_exact_identity(snapshot->swap_uuid, + sizeof(snapshot->swap_uuid)) + && bounded_exact_identity(snapshot->scratch_uuid, + sizeof(snapshot->scratch_uuid)) + && snapshot->swap_partition_size_known + && snapshot->swap_partition_size_bytes > 0 + && snapshot->scratch_partition_size_known + && snapshot->scratch_partition_size_bytes > 0; +} + +static bool +valid_ssd_controller_snapshot(const Lardon3DSsdSnapshot *snapshot) +{ + unsigned int action_count = snapshot + ? (unsigned int)snapshot->can_enable + + (unsigned int)snapshot->can_disable + + (unsigned int)snapshot->can_cancel_drain + : 0; + if (!snapshot || snapshot->state < LARDON3D_SSD_ABSENT + || snapshot->state > LARDON3D_SSD_ERROR + || !bounded_text_terminated(snapshot->model, sizeof(snapshot->model)) + || !bounded_text_terminated(snapshot->serial, sizeof(snapshot->serial)) + || !bounded_text_terminated(snapshot->drive_identity, + sizeof(snapshot->drive_identity)) + || !bounded_text_terminated(snapshot->swap_uuid, + sizeof(snapshot->swap_uuid)) + || !bounded_text_terminated(snapshot->scratch_uuid, + sizeof(snapshot->scratch_uuid)) + || !bounded_text_terminated(snapshot->swap_device, + sizeof(snapshot->swap_device)) + || !bounded_text_terminated(snapshot->scratch_device, + sizeof(snapshot->scratch_device)) + || !bounded_text_terminated(snapshot->scratch_mount_path, + sizeof(snapshot->scratch_mount_path)) + || !bounded_text_terminated(snapshot->reason, + sizeof(snapshot->reason)) + || (!snapshot->connection_speed_known + && snapshot->connection_speed_mbps != 0) + || snapshot->scratch_lease_capacity + != LARDON3D_SSD_MAX_SCRATCH_LEASES + || snapshot->scratch_lease_count > snapshot->scratch_lease_capacity + || (!snapshot->swap_partition_size_known + && snapshot->swap_partition_size_bytes != 0) + || (snapshot->swap_partition_size_known + && (snapshot->swap_partition_size_bytes == 0 + || !snapshot->swap_detected)) + || (!snapshot->scratch_partition_size_known + && snapshot->scratch_partition_size_bytes != 0) + || (snapshot->scratch_partition_size_known + && (snapshot->scratch_partition_size_bytes == 0 + || !snapshot->scratch_detected)) + || (!snapshot->swap_total_known + && snapshot->swap_total_bytes != 0) + || (!snapshot->swap_used_known + && snapshot->swap_used_bytes != 0) + || (!snapshot->scratch_total_known + && snapshot->scratch_total_bytes != 0) + || (!snapshot->scratch_free_known + && snapshot->scratch_free_bytes != 0) + || (snapshot->swap_used_known + && (!snapshot->swap_total_known + || snapshot->swap_used_bytes > snapshot->swap_total_bytes)) + || (snapshot->scratch_free_known + && (!snapshot->scratch_total_known + || snapshot->scratch_free_bytes + > snapshot->scratch_total_bytes)) + || snapshot->device_detected + != (snapshot->swap_detected || snapshot->scratch_detected) + || (snapshot->swap_active && !snapshot->swap_detected) + || (snapshot->scratch_mounted && !snapshot->scratch_detected) + || (snapshot->swap_total_known && !snapshot->swap_detected) + || (snapshot->scratch_total_known && !snapshot->scratch_detected) + || (snapshot->scratch_mounted + && !bounded_exact_identity(snapshot->scratch_mount_path, + sizeof(snapshot->scratch_mount_path))) + || (!snapshot->scratch_mounted + && bounded_exact_identity(snapshot->scratch_mount_path, + sizeof(snapshot->scratch_mount_path))) + || action_count > 1) { + return false; + } + + const bool complete_pair = ssd_snapshot_has_complete_pair(snapshot); + const bool authority_bearing = snapshot->scratch_allocations_allowed + || action_count != 0; + const bool complete_pair_state = snapshot->state == LARDON3D_SSD_ENABLING + || snapshot->state == LARDON3D_SSD_ENABLED + || snapshot->state == LARDON3D_SSD_IN_USE + || snapshot->state == LARDON3D_SSD_DRAINING + || snapshot->state == LARDON3D_SSD_SAFE_TO_UNPLUG; + if ((snapshot->pairing_valid || authority_bearing || complete_pair_state) + && !complete_pair) { + /* CONTRACT: neither a friendly state nor a capability bit can stand + * in for the controller's complete Drive/UUID/detection/extent proof. + * Incomplete DETECTED and disconnected sticky ERROR remain observable + * only because they carry no authority. */ + return false; + } + + if (snapshot->scratch_allocations_allowed + && (!complete_pair || snapshot->drain_requested + || !snapshot->swap_active || !snapshot->scratch_mounted + || strcmp(snapshot->scratch_mount_path, + LARDON3D_SSD_SCRATCH_MOUNT_PATH) != 0 + || (snapshot->state != LARDON3D_SSD_ENABLED + && snapshot->state != LARDON3D_SSD_IN_USE) + || snapshot->scratch_lease_count + >= snapshot->scratch_lease_capacity)) { + return false; + } + if (snapshot->can_enable + && (!complete_pair + || (snapshot->state != LARDON3D_SSD_DETECTED + && snapshot->state != LARDON3D_SSD_SAFE_TO_UNPLUG) + || snapshot->swap_active || snapshot->scratch_mounted + || snapshot->drain_requested + || snapshot->scratch_lease_count != 0)) { + return false; + } + if (snapshot->can_disable + && (!complete_pair + || (snapshot->state != LARDON3D_SSD_DETECTED + && snapshot->state != LARDON3D_SSD_ENABLED + && snapshot->state != LARDON3D_SSD_IN_USE + && snapshot->state != LARDON3D_SSD_ERROR) + || (snapshot->state == LARDON3D_SSD_DETECTED + && !snapshot->swap_active && !snapshot->scratch_mounted))) { + return false; + } + if (snapshot->can_cancel_drain + && (!complete_pair || !snapshot->drain_requested + || snapshot->state != LARDON3D_SSD_DRAINING)) { + return false; + } + + switch (snapshot->state) { + case LARDON3D_SSD_ABSENT: + /* ABSENT is absence, not a bucket for contradictory stale telemetry. + * Sticky ownership is represented as ERROR with its retained tuple. */ + return !snapshot->device_detected && !snapshot->pairing_valid + && !snapshot->model_known && !snapshot->serial_known + && !snapshot->connection_speed_known + && !snapshot->swap_detected && !snapshot->scratch_detected + && !snapshot->swap_partition_size_known + && !snapshot->scratch_partition_size_known + && !snapshot->swap_active && !snapshot->swap_total_known + && !snapshot->swap_used_known && !snapshot->scratch_mounted + && !snapshot->scratch_total_known + && !snapshot->scratch_free_known + && snapshot->scratch_lease_count == 0 + && !snapshot->drain_requested + && !snapshot->scratch_allocations_allowed + && action_count == 0 + && !bounded_exact_identity(snapshot->drive_identity, + sizeof(snapshot->drive_identity)) + && !bounded_exact_identity(snapshot->swap_uuid, + sizeof(snapshot->swap_uuid)) + && !bounded_exact_identity(snapshot->scratch_uuid, + sizeof(snapshot->scratch_uuid)); + case LARDON3D_SSD_DETECTED: + if (!snapshot->device_detected + || snapshot->scratch_lease_count != 0 + || snapshot->drain_requested + || snapshot->scratch_allocations_allowed + || (snapshot->swap_active && snapshot->scratch_mounted)) { + return false; + } + if (!snapshot->pairing_valid) { + return action_count == 0; + } + return snapshot->swap_active || snapshot->scratch_mounted + ? snapshot->can_disable + : snapshot->can_enable; + case LARDON3D_SSD_ENABLING: + return snapshot->scratch_lease_count == 0 + && !snapshot->drain_requested + && !snapshot->scratch_allocations_allowed + && action_count == 0; + case LARDON3D_SSD_ENABLED: + return snapshot->scratch_lease_count == 0 + && snapshot->swap_active && snapshot->scratch_mounted + && !snapshot->drain_requested && snapshot->can_disable + && !snapshot->can_enable && !snapshot->can_cancel_drain + && strcmp(snapshot->scratch_mount_path, + LARDON3D_SSD_SCRATCH_MOUNT_PATH) == 0; + case LARDON3D_SSD_IN_USE: + return snapshot->scratch_lease_count > 0 + && snapshot->swap_active && snapshot->scratch_mounted + && !snapshot->drain_requested && snapshot->can_disable + && !snapshot->can_enable && !snapshot->can_cancel_drain + && strcmp(snapshot->scratch_mount_path, + LARDON3D_SSD_SCRATCH_MOUNT_PATH) == 0; + case LARDON3D_SSD_DRAINING: + return snapshot->drain_requested + && !snapshot->scratch_allocations_allowed + && snapshot->can_cancel_drain && !snapshot->can_enable + && !snapshot->can_disable; + case LARDON3D_SSD_SAFE_TO_UNPLUG: + return snapshot->scratch_lease_count == 0 + && !snapshot->swap_active && !snapshot->scratch_mounted + && !snapshot->drain_requested + && !snapshot->scratch_allocations_allowed + && snapshot->can_enable && !snapshot->can_disable + && !snapshot->can_cancel_drain; + case LARDON3D_SSD_ERROR: + /* A disconnected sticky hazard may retain the exact tuple and leases + * without current detection. Drain authority is accepted only for the + * controller-published, completely reconnected pair. */ + return !snapshot->scratch_allocations_allowed + && !snapshot->can_enable && !snapshot->can_cancel_drain; + } + return false; +} + +static bool +normalize_external_storage( + const Lardon3DResourceExternalStorage *input, + Lardon3DResourceExternalStorage *output +) +{ + if (output) { + memset(output, 0, sizeof(*output)); + } + if (!input || !output + || input->status < LARDON3D_RESOURCE_EXTERNAL_STORAGE_ABSENT + || input->status > LARDON3D_RESOURCE_EXTERNAL_STORAGE_ERROR + || !bounded_text_terminated(input->stable_identity, + sizeof(input->stable_identity)) + || !bounded_text_terminated(input->reason, sizeof(input->reason)) + || (!input->scratch_total_known && input->scratch_total_bytes != 0) + || (!input->scratch_free_known && input->scratch_free_bytes != 0) + || (input->scratch_free_known + && (!input->scratch_total_known + || input->scratch_free_bytes > input->scratch_total_bytes)) + || (!input->swap_total_known && input->swap_total_bytes != 0) + || (!input->swap_used_known && input->swap_used_bytes != 0) + || (input->swap_used_known + && (!input->swap_total_known + || input->swap_used_bytes > input->swap_total_bytes)) + || input->active_scratch_leases + > LARDON3D_SSD_MAX_SCRATCH_LEASES + || (input->new_scratch_allocations_allowed + && input->status != LARDON3D_RESOURCE_EXTERNAL_STORAGE_AVAILABLE + && input->status != LARDON3D_RESOURCE_EXTERNAL_STORAGE_IN_USE) + || (input->status == LARDON3D_RESOURCE_EXTERNAL_STORAGE_AVAILABLE + && input->active_scratch_leases != 0) + || (input->status == LARDON3D_RESOURCE_EXTERNAL_STORAGE_IN_USE + && input->active_scratch_leases == 0) + || ((input->status == LARDON3D_RESOURCE_EXTERNAL_STORAGE_ABSENT + || input->status + == LARDON3D_RESOURCE_EXTERNAL_STORAGE_DETECTED + || input->status == LARDON3D_RESOURCE_EXTERNAL_STORAGE_SAFE) + && input->active_scratch_leases != 0) + || (input->active_scratch_leases > 0 + && input->status != LARDON3D_RESOURCE_EXTERNAL_STORAGE_IN_USE + && input->status != LARDON3D_RESOURCE_EXTERNAL_STORAGE_DRAINING + && input->status != LARDON3D_RESOURCE_EXTERNAL_STORAGE_ERROR) + || ((input->status == LARDON3D_RESOURCE_EXTERNAL_STORAGE_AVAILABLE + || input->status == LARDON3D_RESOURCE_EXTERNAL_STORAGE_IN_USE + || input->status + == LARDON3D_RESOURCE_EXTERNAL_STORAGE_DRAINING + || input->status == LARDON3D_RESOURCE_EXTERNAL_STORAGE_SAFE) + && !bounded_exact_identity(input->stable_identity, + sizeof(input->stable_identity)))) { + return false; + } + + output->generation = input->generation; + output->status = input->status; + output->new_scratch_allocations_allowed = + input->new_scratch_allocations_allowed; + output->scratch_total_known = input->scratch_total_known; + output->scratch_free_known = input->scratch_free_known; + output->scratch_total_bytes = input->scratch_total_known + ? input->scratch_total_bytes : 0; + output->scratch_free_bytes = input->scratch_free_known + ? input->scratch_free_bytes : 0; + output->swap_total_known = input->swap_total_known; + output->swap_used_known = input->swap_used_known; + output->swap_total_bytes = input->swap_total_known + ? input->swap_total_bytes : 0; + output->swap_used_bytes = input->swap_used_known + ? input->swap_used_bytes : 0; + output->active_scratch_leases = input->active_scratch_leases; + (void)snprintf(output->stable_identity, + sizeof(output->stable_identity), "%s", input->stable_identity); + (void)snprintf(output->reason, sizeof(output->reason), "%s", + input->reason); + return true; +} + +bool +lardon3d_resource_external_storage_from_ssd_snapshot( + const Lardon3DSsdSnapshot *snapshot, + Lardon3DResourceExternalStorage *storage +) +{ + if (storage) { + memset(storage, 0, sizeof(*storage)); + } + if (!storage || !valid_ssd_controller_snapshot(snapshot)) { + return false; + } + Lardon3DResourceExternalStorageStatus status; + switch (snapshot->state) { + case LARDON3D_SSD_ABSENT: + status = LARDON3D_RESOURCE_EXTERNAL_STORAGE_ABSENT; + break; + case LARDON3D_SSD_DETECTED: + case LARDON3D_SSD_ENABLING: + status = LARDON3D_RESOURCE_EXTERNAL_STORAGE_DETECTED; + break; + case LARDON3D_SSD_ENABLED: + status = LARDON3D_RESOURCE_EXTERNAL_STORAGE_AVAILABLE; + break; + case LARDON3D_SSD_IN_USE: + status = LARDON3D_RESOURCE_EXTERNAL_STORAGE_IN_USE; + break; + case LARDON3D_SSD_DRAINING: + status = LARDON3D_RESOURCE_EXTERNAL_STORAGE_DRAINING; + break; + case LARDON3D_SSD_SAFE_TO_UNPLUG: + status = LARDON3D_RESOURCE_EXTERNAL_STORAGE_SAFE; + break; + case LARDON3D_SSD_ERROR: + status = LARDON3D_RESOURCE_EXTERNAL_STORAGE_ERROR; + break; + default: + return false; + } + *storage = (Lardon3DResourceExternalStorage) { + .generation = snapshot->generation, + .status = status, + .new_scratch_allocations_allowed = + snapshot->scratch_allocations_allowed, + .scratch_total_known = snapshot->scratch_total_known, + .scratch_free_known = snapshot->scratch_free_known, + .scratch_total_bytes = snapshot->scratch_total_known + ? snapshot->scratch_total_bytes : 0, + .scratch_free_bytes = snapshot->scratch_free_known + ? snapshot->scratch_free_bytes : 0, + .swap_total_known = snapshot->swap_total_known, + .swap_used_known = snapshot->swap_used_known, + .swap_total_bytes = snapshot->swap_total_known + ? snapshot->swap_total_bytes : 0, + .swap_used_bytes = snapshot->swap_used_known + ? snapshot->swap_used_bytes : 0, + .active_scratch_leases = snapshot->scratch_lease_count, + }; + (void)snprintf(storage->stable_identity, + sizeof(storage->stable_identity), "%s", snapshot->drive_identity); + (void)snprintf(storage->reason, sizeof(storage->reason), "%s", + snapshot->reason); + return true; +} + +static bool +external_storage_material_equal( + const Lardon3DResourceExternalStorage *left, + const Lardon3DResourceExternalStorage *right +) +{ + return left->status == right->status + && left->new_scratch_allocations_allowed + == right->new_scratch_allocations_allowed + && left->scratch_total_known == right->scratch_total_known + && left->scratch_free_known == right->scratch_free_known + && left->scratch_total_bytes == right->scratch_total_bytes + && left->scratch_free_bytes == right->scratch_free_bytes + && left->swap_total_known == right->swap_total_known + && left->swap_used_known == right->swap_used_known + && left->swap_total_bytes == right->swap_total_bytes + && left->swap_used_bytes == right->swap_used_bytes + && left->active_scratch_leases == right->active_scratch_leases + && strcmp(left->stable_identity, right->stable_identity) == 0 + && strcmp(left->reason, right->reason) == 0; +} + +static void +external_storage_notify_locked(Lardon3DResourceGovernor *governor) +{ + if (governor->generation != UINT64_MAX) { + ++governor->generation; + } + (void)pthread_cond_broadcast(&governor->cond); +} + +typedef enum { + EXTERNAL_STORAGE_APPLY_PUBLIC = 0, + EXTERNAL_STORAGE_APPLY_WRAPPER_COMPLETION, +} ExternalStorageApplyProvenance; + +static bool +external_storage_apply_locked( + Lardon3DResourceGovernor *governor, + const Lardon3DResourceExternalStorage *storage, + ExternalStorageApplyProvenance provenance +) +{ + Lardon3DResourceExternalStorage next; + if (!normalize_external_storage(storage, &next) + || (next.status != LARDON3D_RESOURCE_EXTERNAL_STORAGE_ERROR + && next.active_scratch_leases + < governor->external_storage_lease_count)) { + return false; + } + + const Lardon3DResourceExternalStorage *current = + &governor->external_storage; + bool same_material = external_storage_material_equal(current, &next); + const bool exact_saturated_wrapper = provenance + == EXTERNAL_STORAGE_APPLY_WRAPPER_COMPLETION + && current->generation == UINT64_MAX + && next.generation == UINT64_MAX; + const bool exact_wrapper_error = provenance + == EXTERNAL_STORAGE_APPLY_WRAPPER_COMPLETION + && next.generation == current->generation + && next.status == LARDON3D_RESOURCE_EXTERNAL_STORAGE_ERROR; + /* UINT64_MAX is a legal saturated controller watermark: only the exact + * serialized wrapper that already owns operation_active may publish a + * material equal-watermark completion. Public telemetry retains the + * ordinary stale rule and therefore cannot regrant authority at MAX. An + * equal-watermark wrapper ERROR may always revoke authority and reconcile + * its exact address-backed count. */ + if ((next.generation < current->generation + || (next.generation == current->generation + && !exact_saturated_wrapper && !exact_wrapper_error)) + && !same_material) { + if (next.status != LARDON3D_RESOURCE_EXTERNAL_STORAGE_ERROR) { + return false; + } + /* WHY: malformed/stale telemetry may revoke authority immediately, + * but it may not splice stale device metrics or identity into a newer + * exact observation. Preserve the current watermark and evidence. */ + Lardon3DResourceExternalStorage conservative = *current; + conservative.status = LARDON3D_RESOURCE_EXTERNAL_STORAGE_ERROR; + conservative.new_scratch_allocations_allowed = false; + (void)snprintf(conservative.reason, sizeof(conservative.reason), "%s", + next.reason); + next = conservative; + same_material = external_storage_material_equal(current, &next); + } + if (next.status == LARDON3D_RESOURCE_EXTERNAL_STORAGE_ERROR + && next.active_scratch_leases + < governor->external_storage_lease_count) { + next.active_scratch_leases = governor->external_storage_lease_count; + } + if (same_material) { + if (next.generation > current->generation) { + governor->external_storage.generation = next.generation; + } + return true; + } + governor->external_storage = next; + external_storage_notify_locked(governor); + return true; +} + +bool +lardon3d_resource_governor_register_external_storage( + Lardon3DResourceGovernor *governor, + Lardon3DSsdController *controller, + const Lardon3DResourceExternalStorage *storage +) +{ + Lardon3DResourceExternalStorage normalized; + if (!governor || !controller + || !normalize_external_storage(storage, &normalized)) { + return false; + } + (void)pthread_mutex_lock(&governor->mutex); + if (governor->external_storage_registered) { + (void)pthread_mutex_unlock(&governor->mutex); + return false; + } + governor->external_storage_registered = true; + governor->external_storage_controller = controller; + governor->external_storage = normalized; + external_storage_notify_locked(governor); + (void)pthread_mutex_unlock(&governor->mutex); + return true; +} + +bool +lardon3d_resource_governor_update_external_storage( + Lardon3DResourceGovernor *governor, + Lardon3DSsdController *controller, + const Lardon3DResourceExternalStorage *storage +) +{ + Lardon3DResourceExternalStorage normalized; + if (!governor || !controller + || !normalize_external_storage(storage, &normalized)) { + return false; + } + (void)pthread_mutex_lock(&governor->mutex); +#ifdef LARDON3D_RESOURCE_EXTERNAL_STORAGE_TESTING + lardon3d_resource_governor_external_update_engaged_for_test( + governor, controller); +#endif + bool exact_registration = governor->external_storage_registered + && governor->external_storage_controller == controller; + bool deferred_release_snapshot = exact_registration + && governor->external_storage_operation_active + && normalized.active_scratch_leases + < governor->external_storage_lease_count; + /* A controller release becomes visible just before the wrapper removes + * its address under this mutex. Coalesce that narrow observation instead + * of publishing an impossible under-count; finish immediately applies its + * own post-operation cached snapshot. */ + bool updated = exact_registration + && (deferred_release_snapshot + || external_storage_apply_locked(governor, &normalized, + EXTERNAL_STORAGE_APPLY_PUBLIC)); + (void)pthread_mutex_unlock(&governor->mutex); + return updated; +} + +bool +lardon3d_resource_governor_unregister_external_storage( + Lardon3DResourceGovernor *governor, + Lardon3DSsdController *controller +) +{ + if (!governor || !controller) { + return false; + } + (void)pthread_mutex_lock(&governor->mutex); + if (!governor->external_storage_registered + || governor->external_storage_controller != controller + || governor->external_storage_operation_active + || governor->external_storage_lease_count != 0 + || governor->external_storage.active_scratch_leases != 0) { + (void)pthread_mutex_unlock(&governor->mutex); + return false; + } + governor->external_storage_registered = false; + governor->external_storage_controller = NULL; + governor->external_storage = (Lardon3DResourceExternalStorage) {0}; + external_storage_notify_locked(governor); + (void)pthread_mutex_unlock(&governor->mutex); + return true; +} + +bool +lardon3d_resource_governor_get_external_storage( + Lardon3DResourceGovernor *governor, + Lardon3DResourceExternalStorage *storage +) +{ + if (storage) { + memset(storage, 0, sizeof(*storage)); + } + if (!governor || !storage) { + return false; + } + (void)pthread_mutex_lock(&governor->mutex); + bool registered = governor->external_storage_registered; + if (registered) { + *storage = governor->external_storage; + } + (void)pthread_mutex_unlock(&governor->mutex); + return registered; +} + +bool +lardon3d_resource_governor_internal_begin_scratch_operation( + Lardon3DResourceGovernor *governor, + Lardon3DSsdController *controller, + Lardon3DSsdScratchLease *lease, + bool acquire +) +{ + if (!governor || !controller || !lease) { + return false; + } + (void)pthread_mutex_lock(&governor->mutex); + bool permitted = governor->external_storage_registered + && governor->external_storage_controller == controller + && !governor->external_storage_operation_active; + size_t found = LARDON3D_SSD_MAX_SCRATCH_LEASES; + for (size_t index = 0; + index < governor->external_storage_lease_count; ++index) { + if (governor->external_storage_leases[index] == lease) { + found = index; + break; + } + } + if (acquire) { + permitted = permitted + && found == LARDON3D_SSD_MAX_SCRATCH_LEASES + && governor->external_storage_lease_count + < LARDON3D_SSD_MAX_SCRATCH_LEASES + && governor->external_storage.new_scratch_allocations_allowed + && (governor->external_storage.status + == LARDON3D_RESOURCE_EXTERNAL_STORAGE_AVAILABLE + || governor->external_storage.status + == LARDON3D_RESOURCE_EXTERNAL_STORAGE_IN_USE); + } else { + permitted = permitted + && found != LARDON3D_SSD_MAX_SCRATCH_LEASES; + } + if (permitted) { + governor->external_storage_operation_active = true; + governor->external_storage_operation_acquire = acquire; + governor->external_storage_operation_lease = lease; + governor->external_storage_operation_start_lease_count = + governor->external_storage.active_scratch_leases; + } + (void)pthread_mutex_unlock(&governor->mutex); + return permitted; +} + +static void +external_storage_operation_error_locked( + Lardon3DResourceGovernor *governor, + size_t expected_physical_lease_count +) +{ + Lardon3DResourceExternalStorage error = governor->external_storage; + error.status = LARDON3D_RESOURCE_EXTERNAL_STORAGE_ERROR; + error.new_scratch_allocations_allowed = false; + /* The current copied count may already contain an updater's pre/post-call + * view. The frozen start count plus the physical outcome is the only + * serialized evidence that cannot double-apply acquire/release. */ + error.active_scratch_leases = expected_physical_lease_count; + (void)snprintf(error.reason, sizeof(error.reason), "%s", + "SSD lease completed but cached reconciliation was malformed; " + "new scratch allocation is blocked"); + (void)external_storage_apply_locked(governor, &error, + EXTERNAL_STORAGE_APPLY_WRAPPER_COMPLETION); +} + +bool +lardon3d_resource_governor_internal_finish_scratch_operation( + Lardon3DResourceGovernor *governor, + Lardon3DSsdController *controller, + Lardon3DSsdScratchLease *lease, + bool acquire, + bool physical_success, + const Lardon3DResourceExternalStorage *storage +) +{ + if (!governor || !controller || !lease) { + return false; + } + (void)pthread_mutex_lock(&governor->mutex); + if (!governor->external_storage_registered + || governor->external_storage_controller != controller + || !governor->external_storage_operation_active + || governor->external_storage_operation_acquire != acquire + || governor->external_storage_operation_lease != lease) { + (void)pthread_mutex_unlock(&governor->mutex); + return false; + } + + size_t expected_physical_lease_count = + governor->external_storage_operation_start_lease_count; + bool physical_count_valid = true; + if (physical_success && acquire) { + if (expected_physical_lease_count + == LARDON3D_SSD_MAX_SCRATCH_LEASES) { + physical_count_valid = false; + } else { + ++expected_physical_lease_count; + } + } else if (physical_success) { + if (expected_physical_lease_count == 0) { + physical_count_valid = false; + } else { + --expected_physical_lease_count; + } + } + + bool bookkeeping_valid = true; + size_t found = LARDON3D_SSD_MAX_SCRATCH_LEASES; + for (size_t index = 0; + index < governor->external_storage_lease_count; ++index) { + if (governor->external_storage_leases[index] == lease) { + found = index; + break; + } + } + if (physical_success && acquire) { + if (found != LARDON3D_SSD_MAX_SCRATCH_LEASES + || governor->external_storage_lease_count + >= LARDON3D_SSD_MAX_SCRATCH_LEASES) { + bookkeeping_valid = false; + } else { + governor->external_storage_leases[ + governor->external_storage_lease_count++] = lease; + } + } else if (physical_success) { + if (found == LARDON3D_SSD_MAX_SCRATCH_LEASES) { + bookkeeping_valid = false; + } else { + --governor->external_storage_lease_count; + governor->external_storage_leases[found] = + governor->external_storage_leases[ + governor->external_storage_lease_count]; + governor->external_storage_leases[ + governor->external_storage_lease_count] = NULL; + } + } + + Lardon3DResourceExternalStorage normalized; + if (expected_physical_lease_count + < governor->external_storage_lease_count) { + physical_count_valid = false; + expected_physical_lease_count = + governor->external_storage_lease_count; + } + + bool reconciled = bookkeeping_valid && physical_count_valid + && normalize_external_storage(storage, &normalized); + if (reconciled) { + reconciled = normalized.active_scratch_leases + == expected_physical_lease_count + && external_storage_apply_locked(governor, &normalized, + EXTERNAL_STORAGE_APPLY_WRAPPER_COMPLETION); + /* A concurrently published newer controller generation is already the + * stronger exact evidence and need not be overwritten by this copy. */ + if (!reconciled + && normalized.generation < governor->external_storage.generation + && governor->external_storage.active_scratch_leases + >= governor->external_storage_lease_count) { + reconciled = true; + } + } + if (!reconciled) { + external_storage_operation_error_locked(governor, + expected_physical_lease_count); + } + governor->external_storage_operation_active = false; + governor->external_storage_operation_acquire = false; + governor->external_storage_operation_lease = NULL; + governor->external_storage_operation_start_lease_count = 0; + (void)pthread_mutex_unlock(&governor->mutex); + /* A successful physical acquire must be reported to its owner so the + * caller can later release it, even if telemetry had to fail closed. */ + return physical_success && bookkeeping_valid; +} + +const char * +lardon3d_resource_external_storage_status_name( + Lardon3DResourceExternalStorageStatus status +) +{ + switch (status) { + case LARDON3D_RESOURCE_EXTERNAL_STORAGE_ABSENT: + return "ABSENT"; + case LARDON3D_RESOURCE_EXTERNAL_STORAGE_DETECTED: + return "DETECTED"; + case LARDON3D_RESOURCE_EXTERNAL_STORAGE_AVAILABLE: + return "AVAILABLE"; + case LARDON3D_RESOURCE_EXTERNAL_STORAGE_IN_USE: + return "IN_USE"; + case LARDON3D_RESOURCE_EXTERNAL_STORAGE_DRAINING: + return "DRAINING"; + case LARDON3D_RESOURCE_EXTERNAL_STORAGE_SAFE: + return "SAFE"; + case LARDON3D_RESOURCE_EXTERNAL_STORAGE_ERROR: + return "ERROR"; + } + return "UNKNOWN"; +} + static bool availability_locked( const Lardon3DResourceGovernor *governor, @@ -2046,8 +2980,7 @@ evaluate_locked( const Lardon3DResourceEstimate *estimate, Lardon3DResourceDecision *decision, bool update_pressure, - uint64_t soft_memory_floor, - uint64_t hard_memory_floor + const Lardon3DMemoryFloors *floors ) { if (!valid_estimate(estimate)) { @@ -2088,12 +3021,13 @@ evaluate_locked( governor->last_swap_pages_out = snapshot->swap_pages_out; governor->swap_baseline_known = true; } - bool hard_memory_pressure = hard_memory_floor > 0 - && snapshot->memory_available_bytes <= hard_memory_floor; - bool soft_memory_pressure = soft_memory_floor > 0 - && snapshot->memory_available_bytes <= soft_memory_floor; - bool pressure_signal = soft_memory_pressure || psi_pressure - || swap_changed; + bool hard_memory_pressure = floors->hard_floor > 0 + && snapshot->memory_available_bytes <= floors->hard_floor; + bool caution_memory_pressure = floors->caution_floor > 0 + && snapshot->memory_available_bytes <= floors->caution_floor; + bool pressure_signal = psi_pressure || swap_changed + || (caution_memory_pressure + && !floors->caution_is_non_escalating_band); if (hard_memory_pressure) { governor->pressure = LARDON3D_RESOURCE_PRESSURE_RED; @@ -2117,6 +3051,19 @@ evaluate_locked( } else { governor->pressure = LARDON3D_RESOURCE_PRESSURE_YELLOW; } + } else if (caution_memory_pressure) { + /* The default 3..4 GiB band is caution, not exhaustion: hold the + * Governor at YELLOW and reset aggressive growth without promoting + * a stable caution-only host to RED. Capacity below still subtracts + * the hard admission floor, so work that would cross it must WAIT. */ + governor->pressure_streak = 0; + governor->recovery_streak = 0; + governor->slow_start_streak = 0; + governor->slow_start_limit = 1; + governor->slow_start_active = true; + if (governor->pressure != LARDON3D_RESOURCE_PRESSURE_RED) { + governor->pressure = LARDON3D_RESOURCE_PRESSURE_YELLOW; + } } else { governor->pressure_streak = 0; if (governor->pressure == LARDON3D_RESOURCE_PRESSURE_RED) { @@ -2189,7 +3136,7 @@ evaluate_locked( size_t theoretical_batch = batch_capacity( governor->profile.memory_total_bytes - - soft_memory_floor, + - floors->admission_floor, memory_fixed, memory_per_item ); @@ -2245,7 +3192,7 @@ evaluate_locked( if (!availability_locked( governor, snapshot, - soft_memory_floor, + floors->admission_floor, &available )) { set_decision(decision, LARDON3D_RESOURCE_REJECT, 0, 0, 0, 0, "Instantané de ressources invalide."); @@ -2431,14 +3378,15 @@ lardon3d_resource_governor_reserve( free(created); return false; } + Lardon3DMemoryFloors floors; + internal_memory_floors_locked(governor, &floors); if (!evaluate_locked( governor, snapshot, estimate, decision, true, - governor->policy.system_memory_reserve_bytes, - governor->policy.emergency_memory_floor_bytes + &floors )) { (void)pthread_mutex_unlock(&governor->mutex); free(created); @@ -2476,11 +3424,27 @@ lardon3d_resource_governor_reserve_available( (void)pthread_mutex_lock(&governor->mutex); Lardon3DHardwareProfile profile = governor->profile; bool force_capture_failure = governor->internal_force_capture_failure; +#if defined(LARDON3D_RESOURCE_GOVERNOR_CAPTURE_TESTING) + bool override_capture = governor->internal_capture_snapshot_override; + Lardon3DResourceSnapshot override_snapshot = + governor->internal_capture_snapshot; +#endif (void)pthread_mutex_unlock(&governor->mutex); if (force_capture_failure) { return false; } Lardon3DResourceSnapshot snapshot; +#if defined(LARDON3D_RESOURCE_GOVERNOR_CAPTURE_TESTING) + /* TEST CONTRACT: exact-output fixtures must not inherit ambient host load + * or pressure. The template is per-Governor and copied under its mutex; + * only freshness is regenerated at the same boundary as production. */ + if (override_capture) { + snapshot = override_snapshot; + if (clock_gettime(CLOCK_MONOTONIC, &snapshot.captured_at) != 0) { + return false; + } + } else +#endif if (!lardon3d_resource_snapshot_capture(&profile, &snapshot, NULL, 0)) { return false; } @@ -2706,14 +3670,15 @@ lardon3d_resource_governor_decide( (void)pthread_mutex_unlock(&governor->mutex); return false; } + Lardon3DMemoryFloors floors; + internal_memory_floors_locked(governor, &floors); bool success = evaluate_locked( governor, snapshot, &estimate, decision, true, - governor->policy.system_memory_reserve_bytes, - governor->policy.emergency_memory_floor_bytes + &floors ); (void)pthread_mutex_unlock(&governor->mutex); return success; @@ -2777,8 +3742,7 @@ try_capability_locked( const Lardon3DTaskCapability *capability, size_t capability_index, bool update_pressure, - uint64_t soft_floor, - uint64_t hard_floor, + const Lardon3DMemoryFloors *floors, Lardon3DResourceCapabilitySelection *selection ) { @@ -2828,8 +3792,7 @@ try_capability_locked( &operational, &decision, update_pressure, - soft_floor, - hard_floor + floors )) { return false; } @@ -3038,9 +4001,8 @@ lardon3d_resource_governor_internal_reserve_capability( free(created); return false; } - uint64_t soft_floor; - uint64_t hard_floor; - internal_pressure_floors_locked(governor, &soft_floor, &hard_floor); + Lardon3DMemoryFloors floors; + internal_memory_floors_locked(governor, &floors); bool pressure_updated = false; bool saw_wait = false; bool evaluated = false; @@ -3064,8 +4026,7 @@ lardon3d_resource_governor_internal_reserve_capability( capability, index, !pressure_updated, - soft_floor, - hard_floor, + &floors, &candidate )) { (void)pthread_mutex_unlock(&governor->mutex); @@ -3186,6 +4147,11 @@ lardon3d_resource_governor_internal_reserve_capability_available( (void)pthread_mutex_lock(&governor->mutex); Lardon3DHardwareProfile profile = governor->profile; bool force_capture_failure = governor->internal_force_capture_failure; +#if defined(LARDON3D_RESOURCE_GOVERNOR_CAPTURE_TESTING) + bool override_capture = governor->internal_capture_snapshot_override; + Lardon3DResourceSnapshot override_snapshot = + governor->internal_capture_snapshot; +#endif (void)pthread_mutex_unlock(&governor->mutex); if (force_capture_failure) { return false; @@ -3195,6 +4161,17 @@ lardon3d_resource_governor_internal_reserve_capability_available( * snapshot. Its failure leaves private fields unknown only. */ capture_host_telemetry(governor); Lardon3DResourceSnapshot snapshot; +#if defined(LARDON3D_RESOURCE_GOVERNOR_CAPTURE_TESTING) + /* Keep the deterministic seam at the stable Gate G capture boundary. + * Optional host diagnostics above remain observational and never acquire + * admission authority from this test template. */ + if (override_capture) { + snapshot = override_snapshot; + if (clock_gettime(CLOCK_MONOTONIC, &snapshot.captured_at) != 0) { + return false; + } + } else +#endif if (!lardon3d_resource_snapshot_capture(&profile, &snapshot, NULL, 0)) { return false; } @@ -4037,6 +5014,29 @@ lardon3d_resource_governor_internal_force_capture_failure( (void)pthread_mutex_unlock(&governor->mutex); } +#if defined(LARDON3D_RESOURCE_GOVERNOR_CAPTURE_TESTING) +bool +lardon3d_resource_governor_internal_set_capture_snapshot( + Lardon3DResourceGovernor *governor, + const Lardon3DResourceSnapshot *snapshot +) +{ + if (!governor) { + return false; + } + (void)pthread_mutex_lock(&governor->mutex); + if (snapshot && !valid_snapshot(&governor->profile, snapshot)) { + (void)pthread_mutex_unlock(&governor->mutex); + return false; + } + governor->internal_capture_snapshot_override = snapshot != NULL; + governor->internal_capture_snapshot = snapshot + ? *snapshot : (Lardon3DResourceSnapshot) {0}; + (void)pthread_mutex_unlock(&governor->mutex); + return true; +} +#endif + const char * lardon3d_resource_decision_name(Lardon3DResourceDecisionKind kind) { diff --git a/src/resource_governor_external_storage.c b/src/resource_governor_external_storage.c new file mode 100644 index 0000000..2aea9c7 --- /dev/null +++ b/src/resource_governor_external_storage.c @@ -0,0 +1,58 @@ +#include + +#include + +#include "resource_governor_internal.h" + +static bool +scratch_operation( + Lardon3DResourceGovernor *governor, + Lardon3DSsdController *controller, + Lardon3DSsdScratchLease *lease, + bool acquire +) +{ + if (!lardon3d_resource_governor_internal_begin_scratch_operation( + governor, controller, lease, acquire)) { + return false; + } + + /* LOCK ORDER: begin has released the Governor mutex before this physical + * call takes the controller mutex. The controller has no Governor callback. + * A concurrent drain/replacement therefore wins at the controller's exact + * capability check instead of creating a nested-lock cycle. */ + bool physical_success = acquire + ? lardon3d_ssd_controller_acquire_scratch(controller, lease) + : lardon3d_ssd_controller_release_scratch(controller, lease); + + Lardon3DSsdSnapshot snapshot; + Lardon3DResourceExternalStorage storage; + bool storage_valid = lardon3d_ssd_controller_copy_snapshot( + controller, &snapshot) + && lardon3d_resource_external_storage_from_ssd_snapshot( + &snapshot, &storage); + + return lardon3d_resource_governor_internal_finish_scratch_operation( + governor, controller, lease, acquire, physical_success, + storage_valid ? &storage : NULL); +} + +bool +lardon3d_resource_governor_acquire_scratch( + Lardon3DResourceGovernor *governor, + Lardon3DSsdController *controller, + Lardon3DSsdScratchLease *lease +) +{ + return scratch_operation(governor, controller, lease, true); +} + +bool +lardon3d_resource_governor_release_scratch( + Lardon3DResourceGovernor *governor, + Lardon3DSsdController *controller, + Lardon3DSsdScratchLease *lease +) +{ + return scratch_operation(governor, controller, lease, false); +} diff --git a/src/resource_governor_internal.h b/src/resource_governor_internal.h index 73e7a23..b50616f 100644 --- a/src/resource_governor_internal.h +++ b/src/resource_governor_internal.h @@ -235,6 +235,11 @@ typedef struct { bool runtime_thread_policy_active; bool mesa_shader_cache_disabled; bool externally_constrained; + /* Counts describe the effective pool after an external allowed mask and + * the requested host reserve are combined. With complete topology, masks + * contain whole (package_id, core_id) groups and reserved_cpu_count may + * minimally exceed the logical request; count-only fallbacks leave masks + * empty rather than fabricating CPU identities. */ unsigned int compute_cpu_count; unsigned int reserved_cpu_count; uint64_t allowed_mask[LARDON3D_RESOURCE_CPU_MASK_WORDS]; @@ -356,6 +361,36 @@ bool lardon3d_resource_governor_internal_capability_hardware_safe( const Lardon3DTaskCapability *capability ); +/* Private half of the public scratch wrappers. Begin freezes one bounded + * Governor operation, then releases the Governor mutex before the caller + * enters the SSD controller. Finish reconciles the controller snapshot and + * exact caller-owned lease address. SSD code never calls these functions, so + * there is no reverse lock edge. */ +bool lardon3d_resource_governor_internal_begin_scratch_operation( + Lardon3DResourceGovernor *governor, + Lardon3DSsdController *controller, + Lardon3DSsdScratchLease *lease, + bool acquire +); +bool lardon3d_resource_governor_internal_finish_scratch_operation( + Lardon3DResourceGovernor *governor, + Lardon3DSsdController *controller, + Lardon3DSsdScratchLease *lease, + bool acquire, + bool physical_success, + const Lardon3DResourceExternalStorage *storage +); + +#ifdef LARDON3D_RESOURCE_EXTERNAL_STORAGE_TESTING +/* Test definition is called with the Governor mutex held immediately before a + * public external-storage update is applied. It may block on test-owned + * synchronization only and must never call back into the Governor. */ +void lardon3d_resource_governor_external_update_engaged_for_test( + Lardon3DResourceGovernor *governor, + Lardon3DSsdController *controller +); +#endif + /* Governor owns the bounded topology snapshot and compute mask. Queue calls * apply only from its sole heavy-compute worker; callers/main threads must not * be constrained. Failure is observable and leaves Task scientific state @@ -424,6 +459,17 @@ void lardon3d_resource_governor_internal_force_capture_failure( bool force_failure ); +#if defined(LARDON3D_RESOURCE_GOVERNOR_CAPTURE_TESTING) +/* Test-only admission-capture seam. The Governor copies `snapshot`; NULL + * clears the override. Each use receives a fresh monotonic timestamp, while + * every other field remains exactly caller-controlled. This declaration and + * its implementation are absent from production preprocessing and symbols. */ +bool lardon3d_resource_governor_internal_set_capture_snapshot( + Lardon3DResourceGovernor *governor, + const Lardon3DResourceSnapshot *snapshot +); +#endif + #ifdef __cplusplus } #endif diff --git a/src/resource_snapshot.c b/src/resource_snapshot.c index 5cc90d4..906b17f 100644 --- a/src/resource_snapshot.c +++ b/src/resource_snapshot.c @@ -223,16 +223,18 @@ lardon3d_resource_snapshot_capture_gpu_at_root( } } -bool -lardon3d_resource_snapshot_capture( +static bool +capture_resource_snapshot( const Lardon3DHardwareProfile *profile, Lardon3DResourceSnapshot *snapshot, + uint64_t *swap_total_bytes, char *error_message, size_t error_message_size ) { set_error(error_message, error_message_size, ""); - if (!profile || !snapshot || profile->logical_cpu_count == 0 + if (!profile || !snapshot || !swap_total_bytes + || profile->logical_cpu_count == 0 || profile->memory_total_bytes == 0) { set_error(error_message, error_message_size, "Profil matériel invalide."); return false; @@ -246,6 +248,7 @@ lardon3d_resource_snapshot_capture( &snapshot->memory_available_bytes ) || !meminfo_bytes(buffer, "MemFree", &snapshot->memory_free_bytes) + || !meminfo_bytes(buffer, "SwapTotal", swap_total_bytes) || !meminfo_bytes(buffer, "SwapFree", &snapshot->swap_available_bytes) || !capture_load(snapshot) || clock_gettime(CLOCK_MONOTONIC, &snapshot->captured_at) != 0) { @@ -270,3 +273,44 @@ lardon3d_resource_snapshot_capture( ); return true; } + +bool +lardon3d_resource_snapshot_capture( + const Lardon3DHardwareProfile *profile, + Lardon3DResourceSnapshot *snapshot, + char *error_message, + size_t error_message_size +) +{ + uint64_t ignored_swap_total = 0; + /* CONTRACT: this historical entry point writes exactly the frozen + * ResourceSnapshot size; extended telemetry must use observation_capture. + */ + return capture_resource_snapshot(profile, snapshot, &ignored_swap_total, + error_message, error_message_size); +} + +bool +lardon3d_resource_observation_capture( + const Lardon3DHardwareProfile *profile, + Lardon3DResourceObservation *observation, + char *error_message, + size_t error_message_size +) +{ + if (observation) { + *observation = (Lardon3DResourceObservation) {0}; + } + if (!observation) { + set_error(error_message, error_message_size, + "Instantané système invalide."); + return false; + } + if (!capture_resource_snapshot(profile, &observation->snapshot, + &observation->swap_total_bytes, error_message, + error_message_size)) { + return false; + } + observation->swap_total_known = true; + return true; +} diff --git a/src/runtime_observer.c b/src/runtime_observer.c new file mode 100644 index 0000000..10650ff --- /dev/null +++ b/src/runtime_observer.c @@ -0,0 +1,542 @@ +#include +#include +#include +#include +#include + +#include + +#include "runtime_observer_internal.h" + +enum { + RUNTIME_OBSERVER_MINIMUM_INTERVAL_NS = 1000000000ULL, +}; + +_Static_assert( + LARDON3D_TUI_TASK_CAPACITY + >= LARDON3D_TASK_QUEUE_PRODUCTION_CAPACITY + 1 + + LARDON3D_TASK_QUEUE_HISTORY_CAPACITY, + "runtime observation must cover pending + active + terminal history" +); + +typedef struct { + Lardon3DHardwareProfile profile; + Lardon3DTaskQueue *queue; + Lardon3DResourceGovernor *governor; +} ProductionProvider; + +struct Lardon3DRuntimeObserver { + Lardon3DHardwareProfile profile; + Lardon3DRuntimeObserverProvider provider; + uint64_t minimum_refresh_interval_ns; + uint64_t last_attempt_ns; + bool last_attempt_known; + bool cached_project_loaded; + bool cached_project_known; + bool cache_known; + bool swap_baseline_known; + uint64_t swap_pages_in; + uint64_t swap_pages_out; + Lardon3DTuiProgressTracker progress_tracker; + Lardon3DRuntimeSnapshot cache; +}; + +static void +copy_text(char *destination, size_t capacity, const char *source) +{ + if (destination && capacity > 0) { + (void)snprintf(destination, capacity, "%s", source ? source : ""); + } +} + +static bool +production_now(void *context, uint64_t *now_ns) +{ + (void)context; + struct timespec now; + if (!now_ns || clock_gettime(CLOCK_MONOTONIC, &now) != 0 + || now.tv_sec < 0 || now.tv_nsec < 0 || now.tv_nsec >= 1000000000L + || (uint64_t)now.tv_sec > UINT64_MAX / UINT64_C(1000000000)) { + return false; + } + uint64_t seconds = (uint64_t)now.tv_sec * UINT64_C(1000000000); + if (seconds > UINT64_MAX - (uint64_t)now.tv_nsec) { + return false; + } + *now_ns = seconds + (uint64_t)now.tv_nsec; + return true; +} + +static size_t +active_task_index( + const Lardon3DTaskObservation *tasks, + size_t count, + bool *known +) +{ + static const Lardon3DTaskState priority[] = { + TASK_RUNNING, + TASK_PAUSED, + TASK_PENDING, + }; + *known = false; + for (size_t state_index = 0; + state_index < sizeof(priority) / sizeof(priority[0]); + ++state_index) { + for (size_t index = 0; index < count; ++index) { + if (tasks[index].state == priority[state_index]) { + *known = true; + return index; + } + } + } + return 0; +} + +static bool +production_capture( + void *context, + Lardon3DRuntimeObserverSample *sample, + char reason[LARDON3D_TUI_TEXT_CAPACITY] +) +{ + if (!context || !sample || !reason) { + return false; + } + ProductionProvider *provider = context; + *sample = (Lardon3DRuntimeObserverSample) {0}; + reason[0] = '\0'; + if (clock_gettime(CLOCK_REALTIME, &sample->realtime_now) != 0) { + copy_text(reason, LARDON3D_TUI_TEXT_CAPACITY, + "Cannot read realtime clock for elapsed Task time"); + return false; + } + + sample->task_count = lardon3d_task_queue_observe( + provider->queue, sample->tasks, LARDON3D_TUI_TASK_CAPACITY, + &sample->task_summary); + if (sample->task_count > LARDON3D_TUI_TASK_CAPACITY) { + copy_text(reason, LARDON3D_TUI_TEXT_CAPACITY, + "Queue returned an invalid bounded snapshot count"); + return false; + } + + char resource_reason[LARDON3D_TUI_TEXT_CAPACITY] = ""; + sample->resource_valid = lardon3d_resource_observation_capture( + &provider->profile, &sample->resource_observation, + resource_reason, sizeof(resource_reason)) + && lardon3d_resource_governor_availability(provider->governor, + &sample->resource_observation.snapshot, &sample->availability) + && lardon3d_resource_governor_get_policy(provider->governor, + &sample->policy) + && lardon3d_resource_governor_internal_cpu_policy( + provider->governor, &sample->cpu_policy); + sample->pressure = lardon3d_resource_governor_pressure( + provider->governor); + sample->external_storage_registered = + lardon3d_resource_governor_get_external_storage( + provider->governor, &sample->external_storage); + + if (!sample->resource_valid) { + copy_text(reason, LARDON3D_TUI_TEXT_CAPACITY, + resource_reason[0] ? resource_reason + : "Resource observation unavailable"); + } + return true; +} + +static void +production_destroy(void *context) +{ + free(context); +} + +static const Lardon3DRuntimeObserverProviderOps production_ops = { + .monotonic_now_ns = production_now, + .capture = production_capture, + .destroy = production_destroy, +}; + +static void +governor_reason( + const Lardon3DRuntimeObserverSample *sample, + const Lardon3DTuiResourceView *resources, + char reason[LARDON3D_TUI_TEXT_CAPACITY] +) +{ + if (resources->swap_delta_known + && (resources->swap_pages_in_delta > 0 + || resources->swap_pages_out_delta > 0)) { + copy_text(reason, LARDON3D_TUI_TEXT_CAPACITY, + "Active swap delta observed"); + } else if (resources->ram_available_bytes + <= resources->ram_reserve_bytes) { + copy_text(reason, LARDON3D_TUI_TEXT_CAPACITY, + "MemAvailable is at or below the hard host reserve"); + } else if (sample->resource_observation.snapshot.memory_pressure_known + && sample->resource_observation.snapshot.memory_pressure_avg10 > 0.0) { + copy_text(reason, LARDON3D_TUI_TEXT_CAPACITY, + "Memory PSI is active"); + } else if (sample->pressure == LARDON3D_RESOURCE_PRESSURE_GREEN) { + copy_text(reason, LARDON3D_TUI_TEXT_CAPACITY, + "No active pressure signal observed"); + } else { + copy_text(reason, LARDON3D_TUI_TEXT_CAPACITY, + "Governor recovery or conservative slow-start"); + } +} + +static void +build_resources( + Lardon3DRuntimeObserver *observer, + const Lardon3DRuntimeObserverSample *sample, + uint64_t now_ns, + const Lardon3DTaskObservation *active, + Lardon3DTuiResourceView *resources +) +{ + *resources = (Lardon3DTuiResourceView) {0}; + if (sample->external_storage_registered) { + const Lardon3DResourceExternalStorage *external = + &sample->external_storage; + resources->external_storage_registered = true; + resources->external_storage_status = external->status; + resources->scratch_new_allocations_allowed = + external->new_scratch_allocations_allowed; + resources->scratch_known = true; + resources->scratch_total_known = external->scratch_total_known; + resources->scratch_free_known = external->scratch_free_known; + resources->scratch_total_bytes = external->scratch_total_bytes; + resources->scratch_free_bytes = external->scratch_free_bytes; + resources->scratch_leases = external->active_scratch_leases; + resources->external_swap_total_known = external->swap_total_known; + resources->external_swap_used_known = external->swap_used_known; + resources->external_swap_total_bytes = external->swap_total_bytes; + resources->external_swap_used_bytes = external->swap_used_bytes; + copy_text(resources->external_storage_identity, + sizeof(resources->external_storage_identity), + external->stable_identity); + copy_text(resources->external_storage_reason, + sizeof(resources->external_storage_reason), external->reason); + } + if (!sample->resource_valid) { + copy_text(resources->governor_reason, + sizeof(resources->governor_reason), + "Resource snapshot unavailable"); + return; + } + resources->valid = true; + resources->captured_monotonic_ns = now_ns; + resources->governor_pressure = sample->pressure; + resources->cpu_logical_total = observer->profile.logical_cpu_count; + resources->cpu_active = sample->availability.cpu_reserved; + resources->cpu_available = sample->availability.cpu_available; + resources->cpu_admitted_known = active && active->has_execution_contract; + resources->cpu_admitted = resources->cpu_admitted_known + ? active->execution_contract.cpu_threads : 0; + copy_text(resources->cpu_reason, sizeof(resources->cpu_reason), + sample->cpu_policy.reason[0] ? sample->cpu_policy.reason + : "CPU topology policy unavailable"); + + resources->gpu_present = observer->profile.gpu_available; + resources->gpu_uses_shared_memory = + observer->profile.gpu_uses_shared_memory; + resources->gpu_memory_known = sample->availability.gpu_memory_known; + resources->gpu_memory_reserved_bytes = + sample->availability.gpu_memory_reserved_bytes; + resources->gpu_memory_available_bytes = + sample->availability.gpu_memory_available_bytes; + resources->gpu_slots_active = sample->availability.gpu_slots_reserved; + resources->gpu_slots_available = sample->availability.gpu_slots_available; + + resources->ram_total_bytes = observer->profile.memory_total_bytes; + resources->ram_available_bytes = + sample->resource_observation.snapshot.memory_available_bytes; + resources->ram_reserve_bytes = + sample->policy.system_memory_reserve_bytes; + resources->ram_reserved_bytes = sample->availability.memory_reserved_bytes; + resources->swap_total_known = sample->resource_observation.swap_total_known; + resources->swap_total_bytes = sample->resource_observation.swap_total_bytes; + if (sample->resource_observation.swap_total_known + && sample->resource_observation.snapshot.swap_available_bytes + <= sample->resource_observation.swap_total_bytes) { + resources->swap_used_known = true; + resources->swap_used_bytes = sample->resource_observation.swap_total_bytes + - sample->resource_observation.snapshot.swap_available_bytes; + } + if (sample->resource_observation.snapshot.swap_activity_known) { + if (observer->swap_baseline_known + && sample->resource_observation.snapshot.swap_pages_in + >= observer->swap_pages_in + && sample->resource_observation.snapshot.swap_pages_out + >= observer->swap_pages_out) { + resources->swap_delta_known = true; + resources->swap_pages_in_delta = + sample->resource_observation.snapshot.swap_pages_in + - observer->swap_pages_in; + resources->swap_pages_out_delta = + sample->resource_observation.snapshot.swap_pages_out + - observer->swap_pages_out; + } + observer->swap_baseline_known = true; + observer->swap_pages_in = + sample->resource_observation.snapshot.swap_pages_in; + observer->swap_pages_out = + sample->resource_observation.snapshot.swap_pages_out; + } else { + observer->swap_baseline_known = false; + } + + resources->io_active = sample->availability.io_slots_reserved; + resources->io_available = sample->availability.io_slots_available; + if (active && active->has_execution_contract) { + resources->batch_known = true; + resources->batch_size = active->execution_contract.batch_size; + } + /* A Governor's last diagnostic is keyed only by kind/version and can + * belong to an earlier Task or sequence. It must never override this + * Task's installed contract. Until an exact Task+sequence observation + * exists, private inflight/helper/backend and utilization values remain + * explicitly unknown. */ + resources->gpu_backend = LARDON3D_TUI_GPU_BACKEND_UNKNOWN; + copy_text(resources->gpu_backend_reason, + sizeof(resources->gpu_backend_reason), + "Exact active Task backend association UNKNOWN"); + + governor_reason(sample, resources, resources->governor_reason); +} + +static bool +realtime_elapsed( + const struct timespec *now, + const struct timespec *started, + uint64_t *seconds +) +{ + if (!now || !started || !seconds || now->tv_sec < 0 || started->tv_sec <= 0 + || now->tv_nsec < 0 || now->tv_nsec >= 1000000000L + || started->tv_nsec < 0 || started->tv_nsec >= 1000000000L + || now->tv_sec < started->tv_sec) { + return false; + } + time_t elapsed = now->tv_sec - started->tv_sec; + if (now->tv_nsec < started->tv_nsec) { + if (elapsed == 0) { + return false; + } + --elapsed; + } + if (elapsed < 0) { + return false; + } + *seconds = (uint64_t)elapsed; + return true; +} + +static bool +build_snapshot( + Lardon3DRuntimeObserver *observer, + bool project_loaded, + uint64_t now_ns, + const Lardon3DRuntimeObserverSample *sample, + const char *capture_reason, + Lardon3DRuntimeSnapshot *result +) +{ + if (sample->task_count > LARDON3D_TUI_TASK_CAPACITY) { + return false; + } + *result = (Lardon3DRuntimeSnapshot) {0}; + result->generation = observer->cache.generation == UINT64_MAX + ? UINT64_MAX + : observer->cache.generation + 1; + result->captured_monotonic_ns = now_ns; + result->task_count = project_loaded ? sample->task_count : 0; + result->task_summary = project_loaded + ? sample->task_summary : (Lardon3DTaskQueueSummary) {0}; + if (result->task_count > 0) { + memcpy(result->tasks, sample->tasks, + result->task_count * sizeof(sample->tasks[0])); + } + result->active_task_index = active_task_index( + result->tasks, result->task_count, &result->active_task_known); + const Lardon3DTaskObservation *active = result->active_task_known + ? &result->tasks[result->active_task_index] + : NULL; + + Lardon3DResourcePressure pressure = sample->resource_valid + ? sample->pressure + : LARDON3D_RESOURCE_PRESSURE_YELLOW; + lardon3d_tui_stage_views_build(project_loaded, result->tasks, + result->task_count, pressure, result->stages); + if (active) { + Lardon3DTuiProgressSample progress = { + .task_id = active->id, + .monotonic_ns = now_ns, + .task_state = active->state, + .typed_task = active->has_task_kind, + .progress_percent = active->progress, + .durable_counts_known = active->durable_progress_known, + .durable_completed = active->durable_completed, + .durable_total = active->durable_total, + .pressure_limited = + pressure != LARDON3D_RESOURCE_PRESSURE_GREEN, + }; + if (!lardon3d_tui_progress_update(&observer->progress_tracker, + &progress, &result->active_progress)) { + return false; + } + result->active_progress.elapsed_known = realtime_elapsed( + &sample->realtime_now, &active->started_at, + &result->active_progress.elapsed_seconds); + } else { + observer->progress_tracker = (Lardon3DTuiProgressTracker) {0}; + } + + build_resources(observer, sample, now_ns, active, &result->resources); + result->ssd_controller_available = sample->ssd_controller_available; + result->ssd = sample->ssd; + copy_text(result->status, sizeof(result->status), + capture_reason && capture_reason[0] + ? capture_reason + : (sample->resource_valid + ? "Runtime observation updated" + : "Runtime updated; resource telemetry unavailable")); + return true; +} + +Lardon3DRuntimeObserver * +lardon3d_runtime_observer_create_with_provider( + const Lardon3DHardwareProfile *profile, + Lardon3DRuntimeObserverProvider provider, + uint64_t minimum_refresh_interval_ns +) +{ + if (!profile || profile->logical_cpu_count == 0 + || profile->memory_total_bytes == 0 || !provider.ops + || !provider.ops->monotonic_now_ns || !provider.ops->capture + || minimum_refresh_interval_ns < RUNTIME_OBSERVER_MINIMUM_INTERVAL_NS) { + return NULL; + } + Lardon3DRuntimeObserver *observer = calloc(1, sizeof(*observer)); + if (!observer) { + return NULL; + } + observer->profile = *profile; + observer->provider = provider; + observer->minimum_refresh_interval_ns = minimum_refresh_interval_ns; + return observer; +} + +Lardon3DRuntimeObserver * +lardon3d_runtime_observer_create( + const Lardon3DHardwareProfile *profile, + Lardon3DTaskQueue *queue, + Lardon3DResourceGovernor *governor +) +{ + if (!profile || !queue || !governor) { + return NULL; + } + ProductionProvider *context = calloc(1, sizeof(*context)); + if (!context) { + return NULL; + } + *context = (ProductionProvider) { + .profile = *profile, + .queue = queue, + .governor = governor, + }; + Lardon3DRuntimeObserverProvider provider = { + .ops = &production_ops, + .context = context, + }; + Lardon3DRuntimeObserver *observer = + lardon3d_runtime_observer_create_with_provider( + profile, provider, RUNTIME_OBSERVER_MINIMUM_INTERVAL_NS); + if (!observer) { + free(context); + } + return observer; +} + +void +lardon3d_runtime_observer_destroy(Lardon3DRuntimeObserver *observer) +{ + if (!observer) { + return; + } + if (observer->provider.ops && observer->provider.ops->destroy) { + observer->provider.ops->destroy(observer->provider.context); + } + free(observer); +} + +bool +lardon3d_runtime_observer_refresh( + Lardon3DRuntimeObserver *observer, + bool project_loaded, + bool force, + Lardon3DRuntimeSnapshot *snapshot +) +{ + if (snapshot) { + *snapshot = (Lardon3DRuntimeSnapshot) {0}; + } + if (!observer || !snapshot) { + return false; + } + uint64_t now_ns; + if (!observer->provider.ops->monotonic_now_ns( + observer->provider.context, &now_ns)) { + if (observer->cache_known) { + observer->cache.stale = true; + copy_text(observer->cache.status, sizeof(observer->cache.status), + "Runtime clock unavailable; showing stale observation"); + *snapshot = observer->cache; + } + return false; + } + bool project_changed = !observer->cached_project_known + || observer->cached_project_loaded != project_loaded; + if (!force && !project_changed && observer->cache_known + && observer->last_attempt_known && now_ns >= observer->last_attempt_ns + && now_ns - observer->last_attempt_ns + < observer->minimum_refresh_interval_ns) { + *snapshot = observer->cache; + return true; + } + observer->last_attempt_known = true; + observer->last_attempt_ns = now_ns; + + Lardon3DRuntimeObserverSample sample; + char reason[LARDON3D_TUI_TEXT_CAPACITY] = ""; + if (!observer->provider.ops->capture( + observer->provider.context, &sample, reason)) { + if (observer->cache_known) { + observer->cache.stale = true; + copy_text(observer->cache.status, sizeof(observer->cache.status), + reason[0] ? reason + : "Runtime refresh failed; showing stale observation"); + *snapshot = observer->cache; + } + return false; + } + Lardon3DRuntimeSnapshot next; + if (!build_snapshot(observer, project_loaded, now_ns, &sample, reason, + &next)) { + if (observer->cache_known) { + observer->cache.stale = true; + copy_text(observer->cache.status, sizeof(observer->cache.status), + "Invalid runtime sample; showing stale observation"); + *snapshot = observer->cache; + } + return false; + } + observer->cache = next; + observer->cache_known = true; + observer->cached_project_known = true; + observer->cached_project_loaded = project_loaded; + *snapshot = observer->cache; + return true; +} diff --git a/src/runtime_observer_internal.h b/src/runtime_observer_internal.h new file mode 100644 index 0000000..702addd --- /dev/null +++ b/src/runtime_observer_internal.h @@ -0,0 +1,55 @@ +#ifndef LARDON3D_RUNTIME_OBSERVER_INTERNAL_H +#define LARDON3D_RUNTIME_OBSERVER_INTERNAL_H + +#include +#include +#include + +#include + +#include "resource_governor_internal.h" + +typedef struct { + struct timespec realtime_now; + Lardon3DTaskObservation tasks[LARDON3D_TUI_TASK_CAPACITY]; + size_t task_count; + Lardon3DTaskQueueSummary task_summary; + + bool resource_valid; + Lardon3DResourceObservation resource_observation; + Lardon3DResourceAvailability availability; + Lardon3DResourcePolicy policy; + Lardon3DResourceCpuPolicyDiagnostic cpu_policy; + Lardon3DResourcePressure pressure; + bool external_storage_registered; + Lardon3DResourceExternalStorage external_storage; + bool ssd_controller_available; + bool ssd_snapshot_valid; + Lardon3DSsdSnapshot ssd; +} Lardon3DRuntimeObserverSample; + +typedef struct { + bool (*monotonic_now_ns)(void *context, uint64_t *now_ns); + bool (*capture)( + void *context, + Lardon3DRuntimeObserverSample *sample, + char reason[LARDON3D_TUI_TEXT_CAPACITY] + ); + void (*destroy)(void *context); +} Lardon3DRuntimeObserverProviderOps; + +typedef struct { + const Lardon3DRuntimeObserverProviderOps *ops; + void *context; +} Lardon3DRuntimeObserverProvider; + +/* Private deterministic seam: the observer owns provider.context only after a + * successful create. Tests inject one complete bounded sample per refresh; + * production uses the same pure model builder after collecting real owners. */ +Lardon3DRuntimeObserver *lardon3d_runtime_observer_create_with_provider( + const Lardon3DHardwareProfile *profile, + Lardon3DRuntimeObserverProvider provider, + uint64_t minimum_refresh_interval_ns +); + +#endif diff --git a/src/runtime_session.c b/src/runtime_session.c new file mode 100644 index 0000000..19dd9a5 --- /dev/null +++ b/src/runtime_session.c @@ -0,0 +1,35 @@ +#include + +#include +#include +#include + +bool +lardon3d_runtime_project_boundary( + Lardon3DAppState *state, + size_t queue_capacity +) +{ + if (!state || !state->resource_governor || queue_capacity == 0) { + return false; + } + + /* INVARIANT: callbacks may dereference Project DB until Queue destroy + * returns. Keeping state->task_queue published during destroy also + * preserves the documented read-only callback reentrancy window. */ + lardon3d_task_queue_destroy(state->task_queue); + state->task_queue = NULL; + if (state->project_loaded || state->project_db) { + lardon3d_project_close(state); + } + + state->task_queue = lardon3d_task_queue_create( + state->resource_governor, queue_capacity); + if (!state->task_queue) { + (void)snprintf(state->status_message, + sizeof(state->status_message), + "Erreur : impossible de recréer la file de tâches."); + return false; + } + return true; +} diff --git a/src/sift_task.c b/src/sift_task.c index 5c5deb8..d2094b4 100644 --- a/src/sift_task.c +++ b/src/sift_task.c @@ -1,5 +1,6 @@ #include #include +#include #include #include #include @@ -167,12 +168,19 @@ static bool run(Lardon3DTask *task, void *userdata) { struct timespec end = {0}; bool timing_known = clock_gettime(CLOCK_MONOTONIC, &begin) == 0; Lardon3DOpenCvTaskThreadControl threads; - if (!lardon3d_opencv_task_threads_begin(task, 12, &threads)) { + if (!lardon3d_opencv_task_threads_begin(task, INT_MAX, &threads)) { + /* A failed configure can still mutate OpenCV. Retain and retry the captured + * restoration obligation once before returning across the C Task seam. */ + if (threads.restore_required && + !lardon3d_opencv_task_threads_end(&threads)) { + return lardon3d_task_fail(task, "Restauration OpenCV SIFT impossible."); + } return lardon3d_task_fail(task, "Contrat CPU OpenCV SIFT invalide."); } - /* SIFT/RootSIFT consume the immutable admitted 1..12 OpenCV count. Queue's - * single Task owner makes the process-wide set/restore deterministic and - * race-free without changing extractor identity or output semantics. */ + /* EXTERNAL LIBRARY: OpenCV accepts a positive signed-int thread count. + * Governor clamps the portable INT_MAX capability to the host compute pool; + * Queue's single Task owner makes the process-wide set/restore deterministic + * without changing SIFT/RootSIFT identity or output semantics. */ size_t durable_items = 0; bool result = run_body(task, userdata, &durable_items); if (!lardon3d_opencv_task_threads_end(&threads)) { @@ -289,9 +297,9 @@ Lardon3DTask *lardon3d_project_create_sift_extract_task( .memory_bytes_per_item = 1024ULL * 1024 * 1024, .minimum_batch_size = 1, .maximum_batch_size = 1, - /* Canonical durable maximum; the admitted OpenCV count may - * adapt within the validated 1..12 range. */ - .desired_cpu_threads = 12, + /* EXTERNAL LIBRARY: OpenCV's positive-int API is the safe + * ceiling; Governor supplies the portable host maximum. */ + .desired_cpu_threads = INT_MAX, .desired_io_slots = 1, .task_class = LARDON3D_RESOURCE_TASK_CPU}; const char *kind = parameters->rootsift ? LARDON3D_ROOTSIFT_EXTRACT_TASK_KIND diff --git a/src/sparse_sfm_geometry.cpp b/src/sparse_sfm_geometry.cpp index 30d2f76..01aa04a 100644 --- a/src/sparse_sfm_geometry.cpp +++ b/src/sparse_sfm_geometry.cpp @@ -13,6 +13,14 @@ namespace { using Result = Lardon3DSparseGeometryResult; +/* + * OpenCV exposes these robust-estimator controls as signed int. Keep the + * public fixed-width encoding unchanged, but reject an unrepresentable value + * before allocation, RNG mutation, output mutation, or entry into OpenCV. + */ +constexpr uint32_t kOpenCvSignedMaximum = + static_cast(std::numeric_limits::max()); + struct RngGuard { uint64_t saved; explicit RngGuard(uint64_t seed) : saved(cv::theRNG().state) { @@ -187,8 +195,12 @@ extern "C" Lardon3DSparseGeometryResult lardon3d_sparse_geometry_relative_pose( const Lardon3DSparseGeometryRelativePoseParameters *parameters, Lardon3DSparseGeometryRelativePoseResult *result) { if (!calibration_a || !calibration_b || !pixels_a || !pixels_b || - !parameters || !result || count > static_cast(INT_MAX) || - parameters->max_iterations == 0 || parameters->confidence <= 0.0 || + !parameters || !result || + count > static_cast(std::numeric_limits::max()) || + parameters->max_iterations == 0 || + parameters->max_iterations > kOpenCvSignedMaximum || + parameters->minimum_inliers > kOpenCvSignedMaximum || + parameters->confidence <= 0.0 || parameters->confidence >= 1.0 || parameters->robust_threshold_px <= 0.0 || parameters->minimum_parallax_rad < 0.0 || parameters->minimum_cheirality_ratio <= 0.0 || @@ -200,6 +212,8 @@ extern "C" Lardon3DSparseGeometryResult lardon3d_sparse_geometry_relative_pose( return LARDON3D_SPARSE_GEOMETRY_INVALID_ARGUMENT; if (!points_finite(pixels_a, count) || !points_finite(pixels_b, count)) return LARDON3D_SPARSE_GEOMETRY_NONFINITE_INPUT; + const int max_iterations = static_cast(parameters->max_iterations); + const int minimum_inliers = static_cast(parameters->minimum_inliers); result->inlier_count = 0; result->inlier_ratio = 0.0; result->median_parallax_rad = 0.0; @@ -266,14 +280,14 @@ extern "C" Lardon3DSparseGeometryResult lardon3d_sparse_geometry_relative_pose( cv::Mat essential = cv::findEssentialMat( points_a, points_b, 1.0, cv::Point2d(0, 0), cv::RANSAC, parameters->confidence, normalized_threshold, - static_cast(parameters->max_iterations), mask); + max_iterations, mask); if (essential.empty()) return LARDON3D_SPARSE_GEOMETRY_ESTIMATION_FAILED; cv::Mat rotation, translation; int inliers = cv::recoverPose(essential, points_a, points_b, rotation, translation, 1.0, cv::Point2d(0, 0), mask); double count_value = static_cast(count); - if (inliers < static_cast(parameters->minimum_inliers) || + if (inliers < minimum_inliers || static_cast(inliers) / count_value < parameters->minimum_inlier_ratio || !rotation_valid(rotation) || cv::norm(translation) < 1e-12) @@ -519,8 +533,12 @@ extern "C" Lardon3DSparseGeometryResult lardon3d_sparse_geometry_pnp( const Lardon3DSparseGeometryPnPParameters *parameters, Lardon3DSparseGeometryPnPResult *result) { if (!calibration || !points || !pixels || !parameters || !result || - count < 4 || count > static_cast(INT_MAX) || - parameters->max_iterations == 0 || parameters->confidence <= 0.0 || + count < 4 || + count > static_cast(std::numeric_limits::max()) || + parameters->max_iterations == 0 || + parameters->max_iterations > kOpenCvSignedMaximum || + parameters->minimum_inliers > kOpenCvSignedMaximum || + parameters->confidence <= 0.0 || parameters->confidence >= 1.0 || parameters->reprojection_threshold_px <= 0.0 || parameters->minimum_inlier_ratio <= 0.0 || parameters->minimum_inlier_ratio > 1.0) @@ -534,6 +552,9 @@ extern "C" Lardon3DSparseGeometryResult lardon3d_sparse_geometry_pnp( !finite_value(points[index].z) || !finite_value(pixels[index].x) || !finite_value(pixels[index].y)) return LARDON3D_SPARSE_GEOMETRY_NONFINITE_INPUT; + const int max_iterations = static_cast(parameters->max_iterations); + const int minimum_inliers = + std::max(static_cast(parameters->minimum_inliers), 4); try { std::vector object_points; std::vector image_points; @@ -561,12 +582,11 @@ extern "C" Lardon3DSparseGeometryResult lardon3d_sparse_geometry_pnp( RngGuard rng(parameters->deterministic_seed); bool solved = cv::solvePnPRansac( object_points, image_points, camera_matrix(*calibration), - distortion(*calibration), rvec, tvec, false, parameters->max_iterations, + distortion(*calibration), rvec, tvec, false, max_iterations, static_cast(parameters->reprojection_threshold_px), parameters->confidence, inliers, cv::SOLVEPNP_EPNP); - uint32_t minimum = std::max(parameters->minimum_inliers, 4); double count_value = static_cast(count); - if (!solved || inliers.rows < static_cast(minimum) || + if (!solved || inliers.rows < minimum_inliers || static_cast(inliers.rows) / count_value < parameters->minimum_inlier_ratio) return LARDON3D_SPARSE_GEOMETRY_ESTIMATION_FAILED; diff --git a/src/ssd_controller.c b/src/ssd_controller.c new file mode 100644 index 0000000..1ac2e8c --- /dev/null +++ b/src/ssd_controller.c @@ -0,0 +1,2636 @@ +#ifndef _XOPEN_SOURCE +#define _XOPEN_SOURCE 700 +#endif + +#include "ssd_controller_internal.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +enum { + SSD_DBUS_TIMEOUT_MS = 10000, + SSD_MAX_UDISKS_OBJECTS = 1024, + SSD_MAX_MOUNT_POINTS = 8, + SSD_PROC_BUFFER_CAPACITY = 131072, +}; + +static const uint64_t SSD_DEFAULT_POLL_INTERVAL_NS = UINT64_C(1000000000); +static const uint64_t SSD_HOST_MEMORY_RESERVE_BYTES = + UINT64_C(3) * UINT64_C(1024) * UINT64_C(1024) * UINT64_C(1024); +static const double SSD_MEMORY_PSI_ELEVATED_PERCENT = 1.0; +static const double SSD_IO_PSI_ELEVATED_PERCENT = 80.0; + +typedef enum { + SSD_OBSERVATION_ABSENT = 0, + SSD_OBSERVATION_INCOMPLETE, + SSD_OBSERVATION_VALID, + SSD_OBSERVATION_INVALID, +} SsdObservationStatus; + +typedef struct { + uint64_t id; + Lardon3DSsdScratchLease *address; +} SsdActiveLease; + +struct Lardon3DSsdController { + pthread_mutex_t mutex; + Lardon3DSsdProvider provider; + uint64_t minimum_poll_interval_ns; + bool have_poll_time; + uint64_t last_poll_ns; + bool last_poll_succeeded; + + bool have_observation; + Lardon3DSsdProviderSnapshot observation; + bool refresh_error; + char refresh_reason[LARDON3D_SSD_REASON_CAPACITY]; + bool operation_error; + char operation_reason[LARDON3D_SSD_REASON_CAPACITY]; + + bool drain_requested; + char drain_reason[LARDON3D_SSD_REASON_CAPACITY]; + bool safe_latched; + char last_drive_identity[LARDON3D_SSD_IDENTITY_CAPACITY]; + char last_swap_uuid[LARDON3D_SSD_TEXT_CAPACITY]; + char last_scratch_uuid[LARDON3D_SSD_TEXT_CAPACITY]; + /* CONTRACT: once physical ownership becomes ambiguous, the exact tuple + * owned before disappearance/replacement remains authoritative across all + * later polls. Only a verified inactive drain of this same tuple clears + * the latch; a replacement must never inherit its control authority. */ + bool physical_hazard_latched; + char hazard_drive_identity[LARDON3D_SSD_IDENTITY_CAPACITY]; + char hazard_swap_uuid[LARDON3D_SSD_TEXT_CAPACITY]; + char hazard_scratch_uuid[LARDON3D_SSD_TEXT_CAPACITY]; + + uint64_t next_lease_id; + /* Each fixed-capacity record binds the monotonic ID to its original + * caller-owned object address. Numeric token bytes alone are not release + * authority and copied/constructed objects cannot decrement use. */ + SsdActiveLease active_leases[LARDON3D_SSD_MAX_SCRATCH_LEASES]; + size_t lease_count; + + uint64_t generation; + Lardon3DSsdSnapshot snapshot; +}; + +static void copy_text(char *destination, size_t capacity, const char *source) { + if (!destination || capacity == 0) { + return; + } + if (!source) { + source = ""; + } + (void)snprintf(destination, capacity, "%s", source); +} + +static void set_message(char *destination, size_t capacity, const char *format, ...) { + va_list arguments; + + if (!destination || capacity == 0) { + return; + } + va_start(arguments, format); + (void)vsnprintf(destination, capacity, format, arguments); + va_end(arguments); +} + +static bool text_is_present(const char *text) { + return text && text[0] != '\0'; +} + +static void bump_generation_locked(Lardon3DSsdController *controller) { + if (controller->generation != UINT64_MAX) { + controller->generation += 1; + } + controller->snapshot.generation = controller->generation; +} + +static void initialize_unknown_snapshot(Lardon3DSsdSnapshot *snapshot) { + memset(snapshot, 0, sizeof(*snapshot)); + snapshot->state = LARDON3D_SSD_ABSENT; + snapshot->scratch_lease_capacity = LARDON3D_SSD_MAX_SCRATCH_LEASES; + copy_text(snapshot->model, sizeof(snapshot->model), "UNKNOWN"); + copy_text(snapshot->serial, sizeof(snapshot->serial), "UNKNOWN"); + copy_text(snapshot->drive_identity, sizeof(snapshot->drive_identity), "UNKNOWN"); + copy_text(snapshot->swap_uuid, sizeof(snapshot->swap_uuid), "UNKNOWN"); + copy_text(snapshot->scratch_uuid, sizeof(snapshot->scratch_uuid), "UNKNOWN"); + copy_text(snapshot->swap_device, sizeof(snapshot->swap_device), "UNKNOWN"); + copy_text(snapshot->scratch_device, sizeof(snapshot->scratch_device), "UNKNOWN"); + copy_text(snapshot->scratch_mount_path, sizeof(snapshot->scratch_mount_path), "UNKNOWN"); + copy_text(snapshot->reason, sizeof(snapshot->reason), "External Lardon SSD is absent"); +} + +static SsdObservationStatus validate_observation( + const Lardon3DSsdProviderSnapshot *observation, + char reason[LARDON3D_SSD_REASON_CAPACITY] +) { + if (observation->invalid_observation) { + copy_text( + reason, + LARDON3D_SSD_REASON_CAPACITY, + text_is_present(observation->invalid_reason) + ? observation->invalid_reason + : "Provider returned an invalid SSD observation" + ); + return SSD_OBSERVATION_INVALID; + } + if (observation->ambiguous_labels) { + copy_text( + reason, + LARDON3D_SSD_REASON_CAPACITY, + "Multiple partitions have a reserved Lardon SSD label" + ); + return SSD_OBSERVATION_INVALID; + } + if (!observation->swap.present && !observation->scratch.present) { + copy_text(reason, LARDON3D_SSD_REASON_CAPACITY, "External Lardon SSD is absent"); + return SSD_OBSERVATION_ABSENT; + } + if (!observation->swap.present || !observation->scratch.present) { + copy_text( + reason, + LARDON3D_SSD_REASON_CAPACITY, + observation->swap.present + ? "LARDON_SCRATCH partition is missing" + : "LARDON_SWAP partition is missing" + ); + return SSD_OBSERVATION_INCOMPLETE; + } + if (strcmp(observation->swap.label, LARDON3D_SSD_SWAP_LABEL) != 0 + || strcmp(observation->scratch.label, LARDON3D_SSD_SCRATCH_LABEL) != 0) { + copy_text( + reason, + LARDON3D_SSD_REASON_CAPACITY, + "Reserved SSD partition labels do not match exactly" + ); + return SSD_OBSERVATION_INVALID; + } + if (!text_is_present(observation->swap.uuid) + || !text_is_present(observation->scratch.uuid)) { + copy_text( + reason, + LARDON3D_SSD_REASON_CAPACITY, + "Both reserved SSD partitions require stable nonempty UUIDs" + ); + return SSD_OBSERVATION_INVALID; + } + if (!text_is_present(observation->swap.drive_identity) + || !text_is_present(observation->scratch.drive_identity) + || strcmp( + observation->swap.drive_identity, + observation->scratch.drive_identity + ) != 0) { + copy_text( + reason, + LARDON3D_SSD_REASON_CAPACITY, + "Reserved SSD partitions do not belong to the same UDisks Drive" + ); + return SSD_OBSERVATION_INVALID; + } + if (!observation->swap.unit_ready || !observation->scratch.unit_ready) { + copy_text( + reason, + LARDON3D_SSD_REASON_CAPACITY, + "Reserved SSD drive reports Unit Not Ready" + ); + return SSD_OBSERVATION_INVALID; + } + if (observation->swap.size_bytes == 0 || observation->scratch.size_bytes == 0) { + copy_text( + reason, + LARDON3D_SSD_REASON_CAPACITY, + "Reserved SSD partition has zero usable size" + ); + return SSD_OBSERVATION_INVALID; + } + if (!observation->swap.interface_available + || !observation->scratch.interface_available + || !text_is_present(observation->swap.object_path) + || !text_is_present(observation->scratch.object_path) + || !text_is_present(observation->swap.device) + || !text_is_present(observation->scratch.device)) { + copy_text( + reason, + LARDON3D_SSD_REASON_CAPACITY, + "Reserved SSD partition lacks a required UDisks interface or current path" + ); + return SSD_OBSERVATION_INVALID; + } + if (!observation->swap.active_known) { + copy_text( + reason, + LARDON3D_SSD_REASON_CAPACITY, + "UDisks Swapspace lacks exact boolean Active telemetry" + ); + return SSD_OBSERVATION_INVALID; + } + + reason[0] = '\0'; + return SSD_OBSERVATION_VALID; +} + +static bool observation_matches_identity( + const Lardon3DSsdProviderSnapshot *observation, + const char *drive_identity, + const char *swap_uuid, + const char *scratch_uuid +) { + return observation && text_is_present(drive_identity) + && text_is_present(swap_uuid) && text_is_present(scratch_uuid) + && strcmp(observation->swap.drive_identity, drive_identity) == 0 + && strcmp(observation->scratch.drive_identity, drive_identity) == 0 + && strcmp(observation->swap.uuid, swap_uuid) == 0 + && strcmp(observation->scratch.uuid, scratch_uuid) == 0; +} + +static void remember_identity_locked( + Lardon3DSsdController *controller, + const Lardon3DSsdProviderSnapshot *observation +) { + copy_text( + controller->last_drive_identity, + sizeof(controller->last_drive_identity), + observation->swap.drive_identity + ); + copy_text( + controller->last_swap_uuid, + sizeof(controller->last_swap_uuid), + observation->swap.uuid + ); + copy_text( + controller->last_scratch_uuid, + sizeof(controller->last_scratch_uuid), + observation->scratch.uuid + ); +} + +static void forget_identity_locked(Lardon3DSsdController *controller) { + controller->last_drive_identity[0] = '\0'; + controller->last_swap_uuid[0] = '\0'; + controller->last_scratch_uuid[0] = '\0'; +} + +static void latch_physical_hazard_locked(Lardon3DSsdController *controller) { + if (controller->physical_hazard_latched) { + return; + } + /* WHY: the last accepted tuple predates the poll that made physical + * ownership ambiguous. Copy it before any later healthy-looking device can + * replace ordinary discovery state. */ + copy_text( + controller->hazard_drive_identity, + sizeof(controller->hazard_drive_identity), + controller->last_drive_identity + ); + copy_text( + controller->hazard_swap_uuid, + sizeof(controller->hazard_swap_uuid), + controller->last_swap_uuid + ); + copy_text( + controller->hazard_scratch_uuid, + sizeof(controller->hazard_scratch_uuid), + controller->last_scratch_uuid + ); + controller->physical_hazard_latched = true; +} + +static bool observation_is_hazard_pair_locked( + const Lardon3DSsdController *controller +) { + char reason[LARDON3D_SSD_REASON_CAPACITY]; + + return controller->physical_hazard_latched + && controller->have_observation + && validate_observation(&controller->observation, reason) + == SSD_OBSERVATION_VALID + && observation_matches_identity( + &controller->observation, + controller->hazard_drive_identity, + controller->hazard_swap_uuid, + controller->hazard_scratch_uuid + ); +} + +static void clear_physical_hazard_locked(Lardon3DSsdController *controller) { + controller->physical_hazard_latched = false; + controller->hazard_drive_identity[0] = '\0'; + controller->hazard_swap_uuid[0] = '\0'; + controller->hazard_scratch_uuid[0] = '\0'; +} + +static void derive_state_locked(Lardon3DSsdController *controller); +static bool refresh_locked(Lardon3DSsdController *controller, bool force); + +static void bind_possible_enable_side_effect_locked( + Lardon3DSsdController *controller +) { + /* CONTRACT: a synchronous D-Bus error/timeout does not prove that Start or + * Mount had no physical effect. Bind control authority to the exact tuple + * before entering either call, so even a failed verification cannot let a + * replacement inherit Stop/Unmount or a later enable attempt. The marker is + * temporary only when a subsequent valid observation proves this same pair. */ + copy_text( + controller->hazard_drive_identity, + sizeof(controller->hazard_drive_identity), + controller->observation.swap.drive_identity + ); + copy_text( + controller->hazard_swap_uuid, + sizeof(controller->hazard_swap_uuid), + controller->observation.swap.uuid + ); + copy_text( + controller->hazard_scratch_uuid, + sizeof(controller->hazard_scratch_uuid), + controller->observation.scratch.uuid + ); + controller->physical_hazard_latched = true; + controller->safe_latched = false; +} + +static bool refresh_and_resolve_enable_side_effect_locked( + Lardon3DSsdController *controller +) { + if (!refresh_locked(controller, true) + || !observation_is_hazard_pair_locked(controller)) { + return false; + } + + /* A verified observation of the original tuple resolves the indeterminate + * action to current truth. If active/mounted, ordinary remembered ownership + * remains and any later replacement re-latches the hazard; if inactive and + * unmounted, there is no physical effect to drain. */ + clear_physical_hazard_locked(controller); + remember_identity_locked(controller, &controller->observation); + derive_state_locked(controller); + return true; +} + +static void copy_observation_to_snapshot_locked(Lardon3DSsdController *controller) { + const Lardon3DSsdProviderSnapshot *source = &controller->observation; + Lardon3DSsdSnapshot next; + + initialize_unknown_snapshot(&next); + next.generation = controller->generation; + next.device_detected = source->swap.present || source->scratch.present; + next.swap_detected = source->swap.present; + next.scratch_detected = source->scratch.present; + + if (source->model_known && text_is_present(source->model)) { + next.model_known = true; + copy_text(next.model, sizeof(next.model), source->model); + } + if (source->serial_known && text_is_present(source->serial)) { + next.serial_known = true; + copy_text(next.serial, sizeof(next.serial), source->serial); + } + if (source->connection_speed_known) { + next.connection_speed_known = true; + next.connection_speed_mbps = source->connection_speed_mbps; + } + + if (source->swap.present) { + copy_text(next.swap_uuid, sizeof(next.swap_uuid), source->swap.uuid); + copy_text(next.swap_device, sizeof(next.swap_device), source->swap.device); + next.swap_partition_size_known = source->swap.size_bytes > 0; + next.swap_partition_size_bytes = source->swap.size_bytes; + } + if (source->scratch.present) { + copy_text(next.scratch_uuid, sizeof(next.scratch_uuid), source->scratch.uuid); + copy_text(next.scratch_device, sizeof(next.scratch_device), source->scratch.device); + next.scratch_partition_size_known = source->scratch.size_bytes > 0; + next.scratch_partition_size_bytes = source->scratch.size_bytes; + } + if (text_is_present(source->swap.drive_identity) + && text_is_present(source->scratch.drive_identity) + && strcmp(source->swap.drive_identity, source->scratch.drive_identity) == 0) { + copy_text( + next.drive_identity, + sizeof(next.drive_identity), + source->swap.drive_identity + ); + } + + next.swap_active = source->swap.active; + next.swap_total_known = source->swap.total_known; + next.swap_used_known = source->swap.used_known; + next.swap_total_bytes = source->swap.total_bytes; + next.swap_used_bytes = source->swap.used_bytes; + + next.scratch_mounted = source->scratch.mounted; + next.scratch_total_known = source->scratch.total_known; + next.scratch_free_known = source->scratch.free_known; + next.scratch_total_bytes = source->scratch.total_bytes; + next.scratch_free_bytes = source->scratch.free_bytes; + if (source->scratch.mounted) { + copy_text( + next.scratch_mount_path, + sizeof(next.scratch_mount_path), + source->scratch.mount_path + ); + } + + next.scratch_lease_count = controller->lease_count; + next.scratch_lease_capacity = LARDON3D_SSD_MAX_SCRATCH_LEASES; + next.drain_requested = controller->drain_requested; + controller->snapshot = next; +} + +static void derive_state_locked(Lardon3DSsdController *controller) { + char validation_reason[LARDON3D_SSD_REASON_CAPACITY]; + const SsdObservationStatus status = controller->have_observation + ? validate_observation(&controller->observation, validation_reason) + : SSD_OBSERVATION_ABSENT; + Lardon3DSsdSnapshot *snapshot = &controller->snapshot; + const bool recovery_pair = observation_is_hazard_pair_locked(controller); + + snapshot->scratch_lease_count = controller->lease_count; + snapshot->scratch_lease_capacity = LARDON3D_SSD_MAX_SCRATCH_LEASES; + snapshot->drain_requested = controller->drain_requested; + snapshot->pairing_valid = status == SSD_OBSERVATION_VALID + && (!controller->physical_hazard_latched || recovery_pair); + snapshot->generation = controller->generation; + snapshot->can_enable = false; + snapshot->can_disable = false; + snapshot->can_cancel_drain = false; + snapshot->scratch_allocations_allowed = false; + + if (controller->physical_hazard_latched) { + /* The remembered tuple is ownership identity, while device nodes are + * ephemeral observations. Never combine the original identity with a + * replacement's paths or activity telemetry. */ + copy_text( + snapshot->drive_identity, + sizeof(snapshot->drive_identity), + text_is_present(controller->hazard_drive_identity) + ? controller->hazard_drive_identity : "UNKNOWN" + ); + copy_text( + snapshot->swap_uuid, + sizeof(snapshot->swap_uuid), + text_is_present(controller->hazard_swap_uuid) + ? controller->hazard_swap_uuid : "UNKNOWN" + ); + copy_text( + snapshot->scratch_uuid, + sizeof(snapshot->scratch_uuid), + text_is_present(controller->hazard_scratch_uuid) + ? controller->hazard_scratch_uuid : "UNKNOWN" + ); + if (!recovery_pair) { + snapshot->model_known = false; + snapshot->serial_known = false; + snapshot->connection_speed_known = false; + copy_text(snapshot->model, sizeof(snapshot->model), "UNKNOWN"); + copy_text(snapshot->serial, sizeof(snapshot->serial), "UNKNOWN"); + snapshot->connection_speed_mbps = 0; + copy_text(snapshot->swap_device, sizeof(snapshot->swap_device), "UNKNOWN"); + copy_text( + snapshot->scratch_device, + sizeof(snapshot->scratch_device), + "UNKNOWN" + ); + copy_text( + snapshot->scratch_mount_path, + sizeof(snapshot->scratch_mount_path), + "UNKNOWN" + ); + snapshot->swap_active = false; + snapshot->swap_total_known = false; + snapshot->swap_used_known = false; + snapshot->swap_total_bytes = 0; + snapshot->swap_used_bytes = 0; + snapshot->scratch_mounted = false; + snapshot->scratch_total_known = false; + snapshot->scratch_free_known = false; + snapshot->scratch_total_bytes = 0; + snapshot->scratch_free_bytes = 0; + snapshot->swap_partition_size_known = false; + snapshot->scratch_partition_size_known = false; + snapshot->swap_partition_size_bytes = 0; + snapshot->scratch_partition_size_bytes = 0; + } + snapshot->state = LARDON3D_SSD_ERROR; + if (controller->refresh_error) { + set_message( + snapshot->reason, + sizeof(snapshot->reason), + "Physical SSD ownership remains unresolved; %s", + controller->refresh_reason + ); + } else if (recovery_pair) { + copy_text( + snapshot->reason, + sizeof(snapshot->reason), + "Original Drive/UUID pair reconnected after an unsafe disappearance; " + "a verified drain is required" + ); + } else if (status == SSD_OBSERVATION_VALID) { + copy_text( + snapshot->reason, + sizeof(snapshot->reason), + "Stable Drive/UUID pair changed while physical ownership is unresolved; " + "reconnect the original pair to drain it" + ); + } else if (status == SSD_OBSERVATION_INCOMPLETE + || status == SSD_OBSERVATION_INVALID) { + set_message( + snapshot->reason, + sizeof(snapshot->reason), + "Physical SSD ownership remains unresolved: %s", + validation_reason + ); + } else { + copy_text( + snapshot->reason, + sizeof(snapshot->reason), + "External SSD disappeared while active, leased, or draining; " + "physical ownership remains unresolved" + ); + } + goto actions; + } + if (controller->refresh_error) { + snapshot->state = LARDON3D_SSD_ERROR; + copy_text(snapshot->reason, sizeof(snapshot->reason), controller->refresh_reason); + goto actions; + } + if (status == SSD_OBSERVATION_ABSENT) { + if (controller->drain_requested) { + snapshot->state = LARDON3D_SSD_ERROR; + copy_text( + snapshot->reason, + sizeof(snapshot->reason), + "Cannot drain because the paired external SSD is absent" + ); + } else { + snapshot->state = LARDON3D_SSD_ABSENT; + copy_text(snapshot->reason, sizeof(snapshot->reason), validation_reason); + } + goto actions; + } + if (status == SSD_OBSERVATION_INCOMPLETE) { + snapshot->state = controller->drain_requested + ? LARDON3D_SSD_ERROR + : LARDON3D_SSD_DETECTED; + copy_text(snapshot->reason, sizeof(snapshot->reason), validation_reason); + goto actions; + } + if (status == SSD_OBSERVATION_INVALID) { + snapshot->state = LARDON3D_SSD_ERROR; + copy_text(snapshot->reason, sizeof(snapshot->reason), validation_reason); + goto actions; + } + if (controller->operation_error) { + snapshot->state = LARDON3D_SSD_ERROR; + copy_text(snapshot->reason, sizeof(snapshot->reason), controller->operation_reason); + goto actions; + } + if (controller->safe_latched && !snapshot->swap_active && !snapshot->scratch_mounted) { + snapshot->state = LARDON3D_SSD_SAFE_TO_UNPLUG; + copy_text( + snapshot->reason, + sizeof(snapshot->reason), + "Swap is inactive and scratch is unmounted; device is safe to unplug" + ); + goto actions; + } + if (controller->drain_requested) { + snapshot->state = LARDON3D_SSD_DRAINING; + copy_text( + snapshot->reason, + sizeof(snapshot->reason), + text_is_present(controller->drain_reason) + ? controller->drain_reason + : "Drain is pending" + ); + goto actions; + } + if (snapshot->swap_active && snapshot->scratch_mounted) { + if (strcmp(snapshot->scratch_mount_path, LARDON3D_SSD_SCRATCH_MOUNT_PATH) != 0) { + snapshot->state = LARDON3D_SSD_ERROR; + copy_text( + snapshot->reason, + sizeof(snapshot->reason), + "Scratch filesystem is mounted at an unexpected path" + ); + goto actions; + } + snapshot->state = controller->lease_count > 0 + ? LARDON3D_SSD_IN_USE + : LARDON3D_SSD_ENABLED; + copy_text( + snapshot->reason, + sizeof(snapshot->reason), + controller->lease_count > 0 + ? "External SSD is enabled and scratch has active leases" + : "External SSD swap and scratch are enabled" + ); + goto actions; + } + if (controller->lease_count > 0) { + snapshot->state = LARDON3D_SSD_ERROR; + copy_text( + snapshot->reason, + sizeof(snapshot->reason), + "Scratch became unavailable while leases remain active" + ); + goto actions; + } + + snapshot->state = LARDON3D_SSD_DETECTED; + if (snapshot->swap_active) { + copy_text( + snapshot->reason, + sizeof(snapshot->reason), + "External SSD is partially active: swap is active but scratch is unmounted" + ); + } else if (snapshot->scratch_mounted) { + copy_text( + snapshot->reason, + sizeof(snapshot->reason), + "External SSD is partially active: scratch is mounted but swap is inactive" + ); + } else { + copy_text( + snapshot->reason, + sizeof(snapshot->reason), + "Healthy paired external SSD is detected but disabled" + ); + } + +actions: + /* INVARIANT: this is the controller's single lease-authority decision. + * Governor/TUI code must not approximate it from a friendly-looking state + * because refresh errors, sticky replacement ownership, token exhaustion, + * or a pending drain all revoke new allocation without releasing existing + * lease ownership. */ + snapshot->scratch_allocations_allowed = + !controller->refresh_error + && !controller->operation_error + && !controller->physical_hazard_latched + && status == SSD_OBSERVATION_VALID + && snapshot->pairing_valid + && !controller->drain_requested + && snapshot->swap_active + && snapshot->scratch_mounted + && strcmp(snapshot->scratch_mount_path, + LARDON3D_SSD_SCRATCH_MOUNT_PATH) == 0 + && controller->lease_count < LARDON3D_SSD_MAX_SCRATCH_LEASES + && controller->next_lease_id != 0 + && (snapshot->state == LARDON3D_SSD_ENABLED + || snapshot->state == LARDON3D_SSD_IN_USE); + /* CONTRACT: presentation never reverse-engineers physical control + * authority. A failed refresh or replacement exposes no action. The one + * exception is the freshly observed exact original tuple after a sticky + * disappearance: it may only be drained, even if already inactive, so + * that the controller verifies and clears ownership at the endpoint. */ + if (controller->refresh_error) { + return; + } + if (controller->physical_hazard_latched) { + snapshot->can_disable = recovery_pair; + return; + } + if (status != SSD_OBSERVATION_VALID || !snapshot->pairing_valid) { + return; + } + if (snapshot->state == LARDON3D_SSD_DRAINING) { + snapshot->can_cancel_drain = controller->drain_requested; + } else if (snapshot->state == LARDON3D_SSD_ENABLED + || snapshot->state == LARDON3D_SSD_IN_USE + || (snapshot->state == LARDON3D_SSD_DETECTED + && (snapshot->swap_active || snapshot->scratch_mounted)) + || (snapshot->state == LARDON3D_SSD_ERROR + && (snapshot->swap_active || snapshot->scratch_mounted + || controller->drain_requested))) { + snapshot->can_disable = true; + } else if ((snapshot->state == LARDON3D_SSD_DETECTED + && !snapshot->swap_active && !snapshot->scratch_mounted) + || snapshot->state == LARDON3D_SSD_SAFE_TO_UNPLUG) { + snapshot->can_enable = true; + } +} + +/* The controller owns the only provider instance and serializes every provider + * callback with this mutex. A normal observation cannot cause a 75 ms UI loop + * to enumerate D-Bus or /proc repeatedly: monotonic cache age is checked before + * the provider is entered. User actions intentionally force one bounded poll. */ +static bool refresh_locked(Lardon3DSsdController *controller, bool force) { + uint64_t now_ns = 0; + char reason[LARDON3D_SSD_REASON_CAPACITY] = {0}; + Lardon3DSsdProviderSnapshot next; + SsdObservationStatus next_status; + bool previously_owned; + + if (!controller->provider.ops->monotonic_now_ns( + controller->provider.context, + &now_ns + )) { + controller->refresh_error = true; + copy_text( + controller->refresh_reason, + sizeof(controller->refresh_reason), + "Cannot read the monotonic clock for SSD telemetry" + ); + controller->last_poll_succeeded = false; + bump_generation_locked(controller); + derive_state_locked(controller); + return false; + } + + if (!force && controller->have_poll_time && now_ns >= controller->last_poll_ns + && now_ns - controller->last_poll_ns < controller->minimum_poll_interval_ns) { + return controller->last_poll_succeeded; + } + + memset(&next, 0, sizeof(next)); + controller->have_poll_time = true; + controller->last_poll_ns = now_ns; + if (!controller->provider.ops->refresh( + controller->provider.context, + &next, + reason + )) { + controller->refresh_error = true; + set_message( + controller->refresh_reason, + sizeof(controller->refresh_reason), + "SSD discovery/telemetry failed: %s", + text_is_present(reason) ? reason : "provider error" + ); + controller->last_poll_succeeded = false; + /* Device-node observations are not identity and must never be retained + * across a failed/disappearance poll. Active booleans stay visible as + * last-known truth, but their paths become explicitly unknown. */ + copy_text( + controller->snapshot.swap_device, + sizeof(controller->snapshot.swap_device), + "UNKNOWN" + ); + copy_text( + controller->snapshot.scratch_device, + sizeof(controller->snapshot.scratch_device), + "UNKNOWN" + ); + bump_generation_locked(controller); + derive_state_locked(controller); + return false; + } + + previously_owned = controller->physical_hazard_latched + || controller->snapshot.swap_active + || controller->snapshot.scratch_mounted + || controller->lease_count > 0 + || (controller->drain_requested + && text_is_present(controller->last_drive_identity) + && !controller->safe_latched); + next_status = validate_observation(&next, reason); + + const bool have_remembered_identity = + text_is_present(controller->last_drive_identity) + && text_is_present(controller->last_swap_uuid) + && text_is_present(controller->last_scratch_uuid); + const bool same_remembered_pair = next_status == SSD_OBSERVATION_VALID + && have_remembered_identity + && observation_matches_identity( + &next, + controller->last_drive_identity, + controller->last_swap_uuid, + controller->last_scratch_uuid + ); + + /* INVARIANT: successful discovery is not authority transfer. If an owned + * pair vanishes, becomes invalid, or is replaced, capture its tuple once + * and keep the hazard sticky. Later polling may only demonstrate that the + * original tuple has returned; it cannot clear the required drain. */ + if (!controller->physical_hazard_latched && previously_owned + && have_remembered_identity + && (next_status != SSD_OBSERVATION_VALID || !same_remembered_pair)) { + latch_physical_hazard_locked(controller); + controller->safe_latched = false; + } + + if (!controller->physical_hazard_latched) { + if (next_status == SSD_OBSERVATION_VALID) { + if (!same_remembered_pair) { + remember_identity_locked(controller, &next); + controller->safe_latched = false; + } + } else if (next_status == SSD_OBSERVATION_ABSENT && !previously_owned) { + /* A verified safe/inactive disappearance relinquishes ownership; + * a later healthy pair starts a fresh lifecycle. */ + controller->safe_latched = false; + forget_identity_locked(controller); + } + } + controller->observation = next; + controller->have_observation = true; + controller->refresh_error = false; + controller->refresh_reason[0] = '\0'; + controller->last_poll_succeeded = true; + + copy_observation_to_snapshot_locked(controller); + if (controller->snapshot.swap_active || controller->snapshot.scratch_mounted) { + controller->safe_latched = false; + } + bump_generation_locked(controller); + derive_state_locked(controller); + return true; +} + +static void latch_operation_error_locked( + Lardon3DSsdController *controller, + const char *format, + ... +) { + va_list arguments; + + controller->operation_error = true; + va_start(arguments, format); + (void)vsnprintf( + controller->operation_reason, + sizeof(controller->operation_reason), + format, + arguments + ); + va_end(arguments); + bump_generation_locked(controller); + derive_state_locked(controller); +} + +static bool current_pair_is_valid_locked(Lardon3DSsdController *controller) { + char reason[LARDON3D_SSD_REASON_CAPACITY]; + + return controller->have_observation + && !controller->physical_hazard_latched + && validate_observation(&controller->observation, reason) == SSD_OBSERVATION_VALID; +} + +static bool current_pair_is_authorized_for_drain_locked( + Lardon3DSsdController *controller +) { + char reason[LARDON3D_SSD_REASON_CAPACITY]; + + if (!controller->have_observation + || validate_observation(&controller->observation, reason) + != SSD_OBSERVATION_VALID) { + return false; + } + return !controller->physical_hazard_latched + || observation_is_hazard_pair_locked(controller); +} + +static Lardon3DSsdControlResult continue_drain_locked( + Lardon3DSsdController *controller +) { + char reason[LARDON3D_SSD_REASON_CAPACITY] = {0}; + + if (controller->lease_count > 0) { + set_message( + controller->drain_reason, + sizeof(controller->drain_reason), + "Waiting for %zu scratch lease%s to release", + controller->lease_count, + controller->lease_count == 1 ? "" : "s" + ); + bump_generation_locked(controller); + derive_state_locked(controller); + return LARDON3D_SSD_CONTROL_PENDING; + } + + if (!refresh_locked(controller, true) + || !current_pair_is_authorized_for_drain_locked(controller)) { + return LARDON3D_SSD_CONTROL_ERROR; + } + + if (controller->snapshot.swap_active) { + const Lardon3DSsdProviderSnapshot *telemetry = &controller->observation; + + if (!controller->snapshot.swap_used_known) { + copy_text( + controller->drain_reason, + sizeof(controller->drain_reason), + "Cannot stop swap: external swap used bytes are unknown" + ); + bump_generation_locked(controller); + derive_state_locked(controller); + return LARDON3D_SSD_CONTROL_PENDING; + } + if (!telemetry->memory_pressure_known || !telemetry->io_pressure_known) { + copy_text( + controller->drain_reason, + sizeof(controller->drain_reason), + "Cannot stop swap: current memory/I/O PSI evidence is unknown" + ); + bump_generation_locked(controller); + derive_state_locked(controller); + return LARDON3D_SSD_CONTROL_PENDING; + } + if (telemetry->memory_pressure_elevated || telemetry->io_pressure_elevated) { + copy_text( + controller->drain_reason, + sizeof(controller->drain_reason), + telemetry->memory_pressure_elevated + ? "Cannot stop swap while memory PSI is elevated" + : "Cannot stop swap while I/O PSI is elevated" + ); + bump_generation_locked(controller); + derive_state_locked(controller); + return LARDON3D_SSD_CONTROL_PENDING; + } + if (!telemetry->swap_activity_known) { + copy_text( + controller->drain_reason, + sizeof(controller->drain_reason), + "Cannot stop swap until a swap-in/out delta interval is observed" + ); + bump_generation_locked(controller); + derive_state_locked(controller); + return LARDON3D_SSD_CONTROL_PENDING; + } + if (telemetry->swap_pages_in_delta != 0 + || telemetry->swap_pages_out_delta != 0) { + copy_text( + controller->drain_reason, + sizeof(controller->drain_reason), + "Cannot stop swap while swap-in/out activity is current" + ); + bump_generation_locked(controller); + derive_state_locked(controller); + return LARDON3D_SSD_CONTROL_PENDING; + } + if (controller->snapshot.swap_used_bytes > 0) { + const uint64_t used = controller->snapshot.swap_used_bytes; + const uint64_t available = telemetry->memory_available_bytes; + + if (!telemetry->memory_available_known) { + copy_text( + controller->drain_reason, + sizeof(controller->drain_reason), + "Cannot stop used swap: MemAvailable is unknown" + ); + bump_generation_locked(controller); + derive_state_locked(controller); + return LARDON3D_SSD_CONTROL_PENDING; + } + /* Subtraction after the reserve comparison avoids reserve+used + * overflow. The 3 GiB floor is host safety policy only; it never + * rejects scientific data or becomes Task memory capacity. */ + if (available < SSD_HOST_MEMORY_RESERVE_BYTES + || used > available - SSD_HOST_MEMORY_RESERVE_BYTES) { + copy_text( + controller->drain_reason, + sizeof(controller->drain_reason), + "Cannot absorb external swap while retaining 3 GiB MemAvailable" + ); + bump_generation_locked(controller); + derive_state_locked(controller); + return LARDON3D_SSD_CONTROL_PENDING; + } + } + + if (!controller->provider.ops->stop_swap( + controller->provider.context, + controller->observation.swap.object_path, + reason + )) { + (void)refresh_locked(controller, true); + latch_operation_error_locked( + controller, + "UDisks Swapspace.Stop failed: %s", + text_is_present(reason) ? reason : "provider error" + ); + return LARDON3D_SSD_CONTROL_ERROR; + } + if (!refresh_locked(controller, true) || controller->snapshot.swap_active) { + latch_operation_error_locked( + controller, + "UDisks reported success but external swap remains active" + ); + return LARDON3D_SSD_CONTROL_ERROR; + } + } + + if (controller->snapshot.scratch_mounted) { + reason[0] = '\0'; + /* A wrong mount path forbids scratch use but must not make safe cleanup + * impossible: this still unmounts the exact UUID/Drive-selected Block + * object. Only controller-owned leases can authorize use. A foreign + * open file remains protected because normal (non-force) UDisks + * Unmount reports DeviceBusy instead of forcing. */ + if (!controller->provider.ops->unmount_scratch( + controller->provider.context, + controller->observation.scratch.object_path, + reason + )) { + (void)refresh_locked(controller, true); + latch_operation_error_locked( + controller, + "UDisks Filesystem.Unmount failed: %s", + text_is_present(reason) ? reason : "provider error" + ); + return LARDON3D_SSD_CONTROL_ERROR; + } + if (!refresh_locked(controller, true) || controller->snapshot.scratch_mounted) { + latch_operation_error_locked( + controller, + "UDisks reported success but scratch remains mounted" + ); + return LARDON3D_SSD_CONTROL_ERROR; + } + } + + if (!current_pair_is_authorized_for_drain_locked(controller) + || controller->snapshot.swap_active + || controller->snapshot.scratch_mounted) { + latch_operation_error_locked( + controller, + "Safe-to-unplug verification did not observe the stable inactive pair" + ); + return LARDON3D_SSD_CONTROL_ERROR; + } + + controller->safe_latched = true; + controller->drain_requested = false; + controller->drain_reason[0] = '\0'; + /* Only this verified inactive endpoint releases sticky physical ownership. + * The ordinary refresh path deliberately has no equivalent transition. */ + clear_physical_hazard_locked(controller); + bump_generation_locked(controller); + derive_state_locked(controller); + return LARDON3D_SSD_CONTROL_OK; +} + +Lardon3DSsdController *lardon3d_ssd_controller_create_with_provider( + Lardon3DSsdProvider provider, + uint64_t minimum_poll_interval_ns +) { + Lardon3DSsdController *controller; + + if (!provider.ops || !provider.ops->monotonic_now_ns || !provider.ops->refresh + || !provider.ops->start_swap || !provider.ops->stop_swap + || !provider.ops->mount_scratch || !provider.ops->unmount_scratch + || !provider.ops->destroy + || minimum_poll_interval_ns < SSD_DEFAULT_POLL_INTERVAL_NS) { + return NULL; + } + + controller = calloc(1, sizeof(*controller)); + if (!controller) { + return NULL; + } + if (pthread_mutex_init(&controller->mutex, NULL) != 0) { + free(controller); + return NULL; + } + controller->provider = provider; + controller->minimum_poll_interval_ns = minimum_poll_interval_ns; + controller->next_lease_id = 1; + initialize_unknown_snapshot(&controller->snapshot); + + (void)pthread_mutex_lock(&controller->mutex); + (void)refresh_locked(controller, true); + (void)pthread_mutex_unlock(&controller->mutex); + return controller; +} + +Lardon3DSsdController *lardon3d_ssd_controller_create(void) { + Lardon3DSsdProvider provider = {0}; + + if (!lardon3d_ssd_production_provider_create(&provider)) { + return NULL; + } + Lardon3DSsdController *controller = lardon3d_ssd_controller_create_with_provider( + provider, + SSD_DEFAULT_POLL_INTERVAL_NS + ); + if (!controller) { + provider.ops->destroy(provider.context); + } + return controller; +} + +bool lardon3d_ssd_controller_destroy(Lardon3DSsdController *controller) { + if (!controller) { + return true; + } + + (void)pthread_mutex_lock(&controller->mutex); + if (controller->lease_count != 0) { + (void)pthread_mutex_unlock(&controller->mutex); + return false; + } + (void)pthread_mutex_unlock(&controller->mutex); + + /* Public callers must prevent new ingress once successful destruction + * begins. The mutex serializes calls already inside the controller; unlike + * a hidden worker, the synchronous provider has no callback to join. */ + controller->provider.ops->destroy(controller->provider.context); + (void)pthread_mutex_destroy(&controller->mutex); + free(controller); + return true; +} + +bool lardon3d_ssd_controller_refresh( + Lardon3DSsdController *controller, + bool force +) { + bool result; + + if (!controller) { + return false; + } + (void)pthread_mutex_lock(&controller->mutex); + result = refresh_locked(controller, force); + (void)pthread_mutex_unlock(&controller->mutex); + return result; +} + +bool lardon3d_ssd_controller_get_snapshot( + Lardon3DSsdController *controller, + Lardon3DSsdSnapshot *snapshot +) { + bool result; + + if (!snapshot) { + return false; + } + /* CONTRACT: a writable output is always initialized, including the + * invalid-controller path, so callers never observe stale/unbounded bytes + * after a failed snapshot request. */ + initialize_unknown_snapshot(snapshot); + if (!controller) { + return false; + } + (void)pthread_mutex_lock(&controller->mutex); + result = refresh_locked(controller, false); + *snapshot = controller->snapshot; + (void)pthread_mutex_unlock(&controller->mutex); + return result; +} + +bool lardon3d_ssd_controller_copy_snapshot( + Lardon3DSsdController *controller, + Lardon3DSsdSnapshot *snapshot +) +{ + if (!snapshot) { + return false; + } + initialize_unknown_snapshot(snapshot); + if (!controller) { + return false; + } + /* WHY: Governor lease reconciliation already follows a physical call. + * Re-entering the provider here would add a second potentially blocking + * observation and create an unnecessary race window. */ + (void)pthread_mutex_lock(&controller->mutex); + *snapshot = controller->snapshot; + (void)pthread_mutex_unlock(&controller->mutex); + return true; +} + +#ifdef LARDON3D_SSD_CONTROLLER_TESTING +bool +lardon3d_ssd_controller_set_generation_for_test( + Lardon3DSsdController *controller, + uint64_t generation +) +{ + if (!controller) { + return false; + } + (void)pthread_mutex_lock(&controller->mutex); + controller->generation = generation; + controller->snapshot.generation = generation; + (void)pthread_mutex_unlock(&controller->mutex); + return true; +} + +bool +lardon3d_ssd_controller_corrupt_cached_snapshot_for_test( + Lardon3DSsdController *controller +) +{ + if (!controller) { + return false; + } + (void)pthread_mutex_lock(&controller->mutex); + memset(controller->snapshot.model, 'X', + sizeof(controller->snapshot.model)); + (void)pthread_mutex_unlock(&controller->mutex); + return true; +} +#endif + +Lardon3DSsdControlResult lardon3d_ssd_controller_enable( + Lardon3DSsdController *controller +) { + char reason[LARDON3D_SSD_REASON_CAPACITY] = {0}; + char mount_path[LARDON3D_SSD_PATH_CAPACITY] = {0}; + Lardon3DSsdControlResult result = LARDON3D_SSD_CONTROL_ERROR; + + if (!controller) { + return result; + } + (void)pthread_mutex_lock(&controller->mutex); + controller->operation_error = false; + controller->operation_reason[0] = '\0'; + controller->drain_requested = false; + controller->drain_reason[0] = '\0'; + controller->safe_latched = false; + + if (!refresh_locked(controller, true) || !current_pair_is_valid_locked(controller)) { + goto done; + } + if (controller->snapshot.scratch_mounted + && strcmp( + controller->snapshot.scratch_mount_path, + LARDON3D_SSD_SCRATCH_MOUNT_PATH + ) != 0) { + latch_operation_error_locked( + controller, + "Scratch is already mounted at '%s', not '%s'", + controller->snapshot.scratch_mount_path, + LARDON3D_SSD_SCRATCH_MOUNT_PATH + ); + goto done; + } + + controller->snapshot.state = LARDON3D_SSD_ENABLING; + copy_text( + controller->snapshot.reason, + sizeof(controller->snapshot.reason), + "Enabling external SSD swap and scratch through UDisks" + ); + bump_generation_locked(controller); + + if (!controller->snapshot.swap_active) { + bool action_succeeded; + bool verification_succeeded; + + bind_possible_enable_side_effect_locked(controller); + action_succeeded = controller->provider.ops->start_swap( + controller->provider.context, + controller->observation.swap.object_path, + reason + ); + verification_succeeded = + refresh_and_resolve_enable_side_effect_locked(controller); + if (!action_succeeded) { + latch_operation_error_locked( + controller, + "UDisks Swapspace.Start failed: %s", + text_is_present(reason) ? reason : "provider error" + ); + goto done; + } + if (!verification_succeeded || !controller->snapshot.swap_active) { + latch_operation_error_locked( + controller, + "UDisks reported success but external swap is not active" + ); + goto done; + } + } + + if (!controller->snapshot.scratch_mounted) { + bool action_succeeded; + bool verification_succeeded; + + reason[0] = '\0'; + bind_possible_enable_side_effect_locked(controller); + action_succeeded = controller->provider.ops->mount_scratch( + controller->provider.context, + controller->observation.scratch.object_path, + mount_path, + reason + ); + verification_succeeded = + refresh_and_resolve_enable_side_effect_locked(controller); + if (!action_succeeded) { + latch_operation_error_locked( + controller, + "UDisks Filesystem.Mount failed: %s", + text_is_present(reason) ? reason : "provider error" + ); + goto done; + } + if (!verification_succeeded || !controller->snapshot.scratch_mounted) { + latch_operation_error_locked( + controller, + "UDisks reported success but scratch is not mounted" + ); + goto done; + } + if (strcmp(mount_path, LARDON3D_SSD_SCRATCH_MOUNT_PATH) != 0 + || strcmp( + controller->snapshot.scratch_mount_path, + LARDON3D_SSD_SCRATCH_MOUNT_PATH + ) != 0) { + latch_operation_error_locked( + controller, + "UDisks mounted scratch at '%s', not '%s'", + strcmp( + controller->snapshot.scratch_mount_path, + LARDON3D_SSD_SCRATCH_MOUNT_PATH + ) != 0 + ? controller->snapshot.scratch_mount_path + : (text_is_present(mount_path) ? mount_path : "UNKNOWN"), + LARDON3D_SSD_SCRATCH_MOUNT_PATH + ); + goto done; + } + } + + if (!refresh_locked(controller, true) + || !current_pair_is_valid_locked(controller) + || !controller->snapshot.swap_active + || !controller->snapshot.scratch_mounted + || strcmp( + controller->snapshot.scratch_mount_path, + LARDON3D_SSD_SCRATCH_MOUNT_PATH + ) != 0) { + latch_operation_error_locked( + controller, + "Enable verification did not observe active swap at the exact scratch path" + ); + goto done; + } + + controller->operation_error = false; + controller->operation_reason[0] = '\0'; + bump_generation_locked(controller); + derive_state_locked(controller); + result = LARDON3D_SSD_CONTROL_OK; + +done: + (void)pthread_mutex_unlock(&controller->mutex); + return result; +} + +Lardon3DSsdControlResult lardon3d_ssd_controller_disable( + Lardon3DSsdController *controller +) { + Lardon3DSsdControlResult result; + + if (!controller) { + return LARDON3D_SSD_CONTROL_ERROR; + } + (void)pthread_mutex_lock(&controller->mutex); + controller->drain_requested = true; + controller->safe_latched = false; + controller->operation_error = false; + controller->operation_reason[0] = '\0'; + controller->drain_reason[0] = '\0'; + bump_generation_locked(controller); + derive_state_locked(controller); + result = continue_drain_locked(controller); + (void)pthread_mutex_unlock(&controller->mutex); + return result; +} + +bool lardon3d_ssd_controller_cancel_drain( + Lardon3DSsdController *controller +) { + if (!controller) { + return false; + } + (void)pthread_mutex_lock(&controller->mutex); + if (!controller->drain_requested) { + (void)pthread_mutex_unlock(&controller->mutex); + return false; + } + controller->drain_requested = false; + controller->drain_reason[0] = '\0'; + controller->operation_error = false; + controller->operation_reason[0] = '\0'; + controller->safe_latched = false; + bump_generation_locked(controller); + derive_state_locked(controller); + (void)pthread_mutex_unlock(&controller->mutex); + return true; +} + +bool lardon3d_ssd_controller_acquire_scratch( + Lardon3DSsdController *controller, + Lardon3DSsdScratchLease *lease +) { + size_t slot; + uint64_t lease_id; + + if (!controller || !lease) { + return false; + } + (void)pthread_mutex_lock(&controller->mutex); + /* INVARIANT: the controller mutex serializes both token bytes and the + * address registry. Check the registry first so reconstructing or zeroing + * an active object cannot acquire a second capability at that address. */ + for (slot = 0; slot < LARDON3D_SSD_MAX_SCRATCH_LEASES; ++slot) { + if (controller->active_leases[slot].id != 0 + && controller->active_leases[slot].address == lease) { + (void)pthread_mutex_unlock(&controller->mutex); + return false; + } + } + if (lease->opaque_controller != 0 || lease->opaque_lease_id != 0) { + (void)pthread_mutex_unlock(&controller->mutex); + return false; + } + if (!refresh_locked(controller, false) + || controller->drain_requested + || controller->operation_error + || !current_pair_is_valid_locked(controller) + || !controller->snapshot.swap_active + || !controller->snapshot.scratch_mounted + || strcmp( + controller->snapshot.scratch_mount_path, + LARDON3D_SSD_SCRATCH_MOUNT_PATH + ) != 0 + || controller->lease_count >= LARDON3D_SSD_MAX_SCRATCH_LEASES + || controller->next_lease_id == 0) { + (void)pthread_mutex_unlock(&controller->mutex); + return false; + } + + for (slot = 0; slot < LARDON3D_SSD_MAX_SCRATCH_LEASES; ++slot) { + if (controller->active_leases[slot].id == 0) { + break; + } + } + if (slot == LARDON3D_SSD_MAX_SCRATCH_LEASES) { + (void)pthread_mutex_unlock(&controller->mutex); + return false; + } + + lease_id = controller->next_lease_id; + controller->next_lease_id += 1; + controller->active_leases[slot].id = lease_id; + controller->active_leases[slot].address = lease; + controller->lease_count += 1; + lease->opaque_controller = (uintptr_t)controller; + lease->opaque_lease_id = lease_id; + bump_generation_locked(controller); + derive_state_locked(controller); + (void)pthread_mutex_unlock(&controller->mutex); + return true; +} + +bool lardon3d_ssd_controller_release_scratch( + Lardon3DSsdController *controller, + Lardon3DSsdScratchLease *lease +) { + size_t slot; + bool found = false; + + if (!controller || !lease) { + return false; + } + (void)pthread_mutex_lock(&controller->mutex); + /* CONTRACT: all reads and writes of caller token storage occur under the + * same mutex as the fixed address/ID registry. Two same-controller calls + * therefore cannot race the lease fields or decrement one use twice. */ + if (lease->opaque_controller != (uintptr_t)controller + || lease->opaque_lease_id == 0) { + (void)pthread_mutex_unlock(&controller->mutex); + return false; + } + for (slot = 0; slot < LARDON3D_SSD_MAX_SCRATCH_LEASES; ++slot) { + if (controller->active_leases[slot].id == lease->opaque_lease_id + && controller->active_leases[slot].address == lease) { + controller->active_leases[slot].id = 0; + controller->active_leases[slot].address = NULL; + found = true; + break; + } + } + if (!found || controller->lease_count == 0) { + (void)pthread_mutex_unlock(&controller->mutex); + return false; + } + + controller->lease_count -= 1; + lease->opaque_controller = 0; + lease->opaque_lease_id = 0; + bump_generation_locked(controller); + derive_state_locked(controller); + if (controller->drain_requested && controller->lease_count == 0) { + (void)continue_drain_locked(controller); + } + (void)pthread_mutex_unlock(&controller->mutex); + return true; +} + +const char *lardon3d_ssd_state_name(Lardon3DSsdState state) { + switch (state) { + case LARDON3D_SSD_ABSENT: + return "ABSENT"; + case LARDON3D_SSD_DETECTED: + return "DETECTED"; + case LARDON3D_SSD_ENABLING: + return "ENABLING"; + case LARDON3D_SSD_ENABLED: + return "ENABLED"; + case LARDON3D_SSD_IN_USE: + return "IN_USE"; + case LARDON3D_SSD_DRAINING: + return "DRAINING"; + case LARDON3D_SSD_SAFE_TO_UNPLUG: + return "SAFE_TO_UNPLUG"; + case LARDON3D_SSD_ERROR: + return "ERROR"; + } + return "UNKNOWN"; +} + +typedef struct { + GDBusConnection *connection; + bool have_vmstat_baseline; + uint64_t previous_pages_in; + uint64_t previous_pages_out; + uint64_t previous_vmstat_ns; +} SsdProductionProvider; + +typedef struct { + pthread_mutex_t mutex; + pthread_cond_t condition; + bool completed; + struct timespec deadline; + GCancellable *cancellable; +} SsdBusConnectDeadline; + +static const char *const SSD_UDISKS_SERVICE = "org.freedesktop.UDisks2"; +static const char *const SSD_UDISKS_ROOT = "/org/freedesktop/UDisks2"; +static const char *const SSD_OBJECT_MANAGER_INTERFACE = + "org.freedesktop.DBus.ObjectManager"; +static const char *const SSD_BLOCK_INTERFACE = "org.freedesktop.UDisks2.Block"; +static const char *const SSD_DRIVE_INTERFACE = "org.freedesktop.UDisks2.Drive"; +static const char *const SSD_SWAP_INTERFACE = "org.freedesktop.UDisks2.Swapspace"; +static const char *const SSD_FILESYSTEM_INTERFACE = + "org.freedesktop.UDisks2.Filesystem"; + +static void *cancel_bus_connect_at_deadline(void *userdata) { + SsdBusConnectDeadline *deadline = userdata; + + (void)pthread_mutex_lock(&deadline->mutex); + while (!deadline->completed) { + const int result = pthread_cond_timedwait( + &deadline->condition, + &deadline->mutex, + &deadline->deadline + ); + if (result == ETIMEDOUT) { + if (!deadline->completed) { + g_cancellable_cancel(deadline->cancellable); + } + break; + } + if (result != 0) { + g_cancellable_cancel(deadline->cancellable); + break; + } + } + (void)pthread_mutex_unlock(&deadline->mutex); + return NULL; +} + +static GDBusConnection *connect_system_bus_bounded(GError **error) { + SsdBusConnectDeadline deadline; + pthread_t timeout_thread; + pthread_condattr_t condition_attributes; + GDBusConnection *connection; + const time_t timeout_seconds = (time_t)(SSD_DBUS_TIMEOUT_MS / 1000); + + memset(&deadline, 0, sizeof(deadline)); + deadline.cancellable = g_cancellable_new(); + if (!deadline.cancellable || clock_gettime(CLOCK_MONOTONIC, &deadline.deadline) != 0 + || deadline.deadline.tv_sec > (time_t)(INT64_MAX - timeout_seconds)) { + if (deadline.cancellable) { + g_object_unref(deadline.cancellable); + } + return NULL; + } + deadline.deadline.tv_sec += timeout_seconds; + if (pthread_mutex_init(&deadline.mutex, NULL) != 0) { + g_object_unref(deadline.cancellable); + return NULL; + } + if (pthread_condattr_init(&condition_attributes) != 0) { + (void)pthread_mutex_destroy(&deadline.mutex); + g_object_unref(deadline.cancellable); + return NULL; + } + if (pthread_condattr_setclock(&condition_attributes, CLOCK_MONOTONIC) != 0 + || pthread_cond_init(&deadline.condition, &condition_attributes) != 0) { + (void)pthread_condattr_destroy(&condition_attributes); + (void)pthread_mutex_destroy(&deadline.mutex); + g_object_unref(deadline.cancellable); + return NULL; + } + (void)pthread_condattr_destroy(&condition_attributes); + if (pthread_create( + &timeout_thread, + NULL, + cancel_bus_connect_at_deadline, + &deadline + ) != 0) { + (void)pthread_cond_destroy(&deadline.condition); + (void)pthread_mutex_destroy(&deadline.mutex); + g_object_unref(deadline.cancellable); + return NULL; + } + + /* GIO honors GCancellable while opening the local system bus. The helper's + * condition uses a monotonic deadline, exists only for this synchronous + * constructor call, and is joined before return; no callback/thread + * lifetime escapes into controller state. */ + connection = g_bus_get_sync(G_BUS_TYPE_SYSTEM, deadline.cancellable, error); + (void)pthread_mutex_lock(&deadline.mutex); + deadline.completed = true; + (void)pthread_cond_broadcast(&deadline.condition); + (void)pthread_mutex_unlock(&deadline.mutex); + (void)pthread_join(timeout_thread, NULL); + + (void)pthread_cond_destroy(&deadline.condition); + (void)pthread_mutex_destroy(&deadline.mutex); + g_object_unref(deadline.cancellable); + return connection; +} + +static bool production_monotonic_now_ns(void *context, uint64_t *now_ns) { + struct timespec now; + + (void)context; + if (!now_ns || clock_gettime(CLOCK_MONOTONIC, &now) != 0 || now.tv_sec < 0 + || (uint64_t)now.tv_sec > UINT64_MAX / UINT64_C(1000000000)) { + return false; + } + *now_ns = (uint64_t)now.tv_sec * UINT64_C(1000000000) + (uint64_t)now.tv_nsec; + return true; +} + +static void format_gerror( + char reason[LARDON3D_SSD_REASON_CAPACITY], + const GError *error +) { + char *remote_name; + + if (!error) { + copy_text(reason, LARDON3D_SSD_REASON_CAPACITY, "unknown D-Bus error"); + return; + } + remote_name = g_dbus_error_get_remote_error(error); + if (remote_name) { + set_message( + reason, + LARDON3D_SSD_REASON_CAPACITY, + "%s: %s", + remote_name, + error->message ? error->message : "D-Bus call failed" + ); + g_free(remote_name); + } else { + copy_text( + reason, + LARDON3D_SSD_REASON_CAPACITY, + error->message ? error->message : "D-Bus call failed" + ); + } + for (size_t index = 0; reason[index] != '\0'; ++index) { + if (reason[index] == '\n' || reason[index] == '\r') { + reason[index] = ' '; + } + } +} + +static bool copy_variant_string( + GVariant *properties, + const char *key, + char *destination, + size_t capacity +) { + const char *value = NULL; + + if (!properties || !g_variant_lookup(properties, key, "&s", &value) + || !value || strlen(value) >= capacity) { + return false; + } + copy_text(destination, capacity, value); + return true; +} + +static bool copy_variant_object_path( + GVariant *properties, + const char *key, + char *destination, + size_t capacity +) { + const char *value = NULL; + + if (!properties || !g_variant_lookup(properties, key, "&o", &value) + || !value || strlen(value) >= capacity) { + return false; + } + copy_text(destination, capacity, value); + return true; +} + +static bool copy_variant_bytestring( + GVariant *properties, + const char *key, + char *destination, + size_t capacity +) { + GVariant *value; + gsize length = 0; + const guint8 *bytes; + + if (!properties) { + return false; + } + value = g_variant_lookup_value(properties, key, G_VARIANT_TYPE_BYTESTRING); + if (!value) { + return false; + } + bytes = g_variant_get_fixed_array(value, &length, sizeof(*bytes)); + if (!bytes || length < 2 || bytes[length - 1] != 0 || length > capacity + || memchr(bytes, 0, length - 1) != NULL) { + g_variant_unref(value); + return false; + } + memcpy(destination, bytes, length); + g_variant_unref(value); + return true; +} + +static bool read_file_bounded( + const char *path, + char *buffer, + size_t capacity +) { + int descriptor; + size_t used = 0; + + if (!path || !buffer || capacity < 2) { + return false; + } + descriptor = open(path, O_RDONLY | O_CLOEXEC); + if (descriptor < 0) { + return false; + } + while (used < capacity - 1) { + const ssize_t count = read(descriptor, buffer + used, capacity - 1 - used); + if (count > 0) { + used += (size_t)count; + continue; + } + if (count == 0) { + break; + } + if (errno != EINTR) { + (void)close(descriptor); + return false; + } + } + if (used == capacity - 1) { + char extra; + ssize_t count; + do { + count = read(descriptor, &extra, 1); + } while (count < 0 && errno == EINTR); + if (count != 0) { + (void)close(descriptor); + return false; + } + } + if (close(descriptor) != 0) { + return false; + } + buffer[used] = '\0'; + return true; +} + +static bool parse_positive_u64_text(const char *text, uint64_t *value) { + char *end = NULL; + uintmax_t parsed; + + errno = 0; + parsed = strtoumax(text, &end, 10); + if (errno != 0 || end == text || parsed == 0 || parsed > UINT64_MAX) { + return false; + } + while (*end == ' ' || *end == '\t' || *end == '\n' || *end == '\r') { + ++end; + } + if (*end != '\0') { + return false; + } + *value = (uint64_t)parsed; + return true; +} + +static void populate_connection_speed_from_sysfs( + Lardon3DSsdProviderSnapshot *snapshot +) { + char class_path[PATH_MAX]; + char resolved[PATH_MAX]; + char speed_path[PATH_MAX]; + char speed_text[128]; + const char *device; + const char *name; + int class_path_length; + + if (snapshot->connection_speed_known || !snapshot->scratch.present) { + return; + } + device = snapshot->scratch.device; + name = strrchr(device, '/'); + name = name ? name + 1 : device; + if (!text_is_present(name) || strcmp(name, ".") == 0 || strcmp(name, "..") == 0) { + return; + } + class_path_length = snprintf( + class_path, + sizeof(class_path), + "/sys/class/block/%s", + name + ); + if (class_path_length < 0 || (size_t)class_path_length >= sizeof(class_path) + || !realpath(class_path, resolved)) { + return; + } + + /* Standard UDisks Drive metadata exposes the connection bus but not the + * negotiated rate. That rate is optional telemetry, not product identity: + * walk only the bounded resolved sysfs ancestry of the current UDisks + * device. A future sdX rename therefore changes neither pairing nor + * leases. Linux USB `speed` files are expressed in Mb/s. */ + for (size_t depth = 0; depth < 32; ++depth) { + const int length = snprintf(speed_path, sizeof(speed_path), "%s/speed", resolved); + if (length > 0 && (size_t)length < sizeof(speed_path) + && read_file_bounded(speed_path, speed_text, sizeof(speed_text)) + && parse_positive_u64_text( + speed_text, + &snapshot->connection_speed_mbps + )) { + snapshot->connection_speed_known = true; + return; + } + + char *slash = strrchr(resolved, '/'); + if (!slash || slash == resolved || strcmp(resolved, "/sys") == 0) { + return; + } + *slash = '\0'; + } +} + +static bool parse_named_u64(const char *text, const char *name, uint64_t *value) { + const size_t name_length = strlen(name); + const char *cursor = text; + + while (cursor && *cursor != '\0') { + const char *end = strchr(cursor, '\n'); + const size_t length = end ? (size_t)(end - cursor) : strlen(cursor); + if (length > name_length && strncmp(cursor, name, name_length) == 0 + && (cursor[name_length] == ' ' || cursor[name_length] == '\t')) { + char *parse_end = NULL; + errno = 0; + const uintmax_t parsed = strtoumax(cursor + name_length, &parse_end, 10); + if (errno != 0 || parse_end == cursor + name_length || parsed > UINT64_MAX) { + return false; + } + *value = (uint64_t)parsed; + return true; + } + cursor = end ? end + 1 : NULL; + } + return false; +} + +static bool parse_mem_available(const char *text, uint64_t *bytes) { + const char *line = strstr(text, "MemAvailable:"); + char *end = NULL; + uintmax_t kib; + + if (!line) { + return false; + } + line += strlen("MemAvailable:"); + errno = 0; + kib = strtoumax(line, &end, 10); + if (errno != 0 || end == line || kib > UINT64_MAX / UINT64_C(1024)) { + return false; + } + while (*end == ' ' || *end == '\t') { + ++end; + } + if (strncmp(end, "kB", 2) != 0) { + return false; + } + *bytes = (uint64_t)kib * UINT64_C(1024); + return true; +} + +static bool parse_psi_elevated( + const char *text, + double elevated_threshold, + bool *elevated +) { + const char *some = strstr(text, "some "); + const char *average; + char *end = NULL; + double value; + + if (!some || !(average = strstr(some, "avg10="))) { + return false; + } + average += strlen("avg10="); + errno = 0; + value = strtod(average, &end); + if (errno != 0 || end == average || value < 0.0) { + return false; + } + /* These are the established default Governor pressure boundaries (1% + * memory, 80% I/O avg10), reused as physical-removal safety evidence + * without adding a second scheduler or redefining admission. Historical + * swap occupancy is deliberately irrelevant here. */ + *elevated = value >= elevated_threshold; + return true; +} + +static void populate_host_telemetry( + SsdProductionProvider *provider, + Lardon3DSsdProviderSnapshot *snapshot +) { + char buffer[SSD_PROC_BUFFER_CAPACITY]; + uint64_t pages_in; + uint64_t pages_out; + uint64_t now_ns; + + if (read_file_bounded("/proc/meminfo", buffer, sizeof(buffer))) { + snapshot->memory_available_known = parse_mem_available( + buffer, + &snapshot->memory_available_bytes + ); + } + if (read_file_bounded("/proc/pressure/memory", buffer, sizeof(buffer))) { + snapshot->memory_pressure_known = parse_psi_elevated( + buffer, + SSD_MEMORY_PSI_ELEVATED_PERCENT, + &snapshot->memory_pressure_elevated + ); + } + if (read_file_bounded("/proc/pressure/io", buffer, sizeof(buffer))) { + snapshot->io_pressure_known = parse_psi_elevated( + buffer, + SSD_IO_PSI_ELEVATED_PERCENT, + &snapshot->io_pressure_elevated + ); + } + if (!read_file_bounded("/proc/vmstat", buffer, sizeof(buffer)) + || !parse_named_u64(buffer, "pswpin", &pages_in) + || !parse_named_u64(buffer, "pswpout", &pages_out) + || !production_monotonic_now_ns(provider, &now_ns)) { + return; + } + if (!provider->have_vmstat_baseline || now_ns < provider->previous_vmstat_ns + || pages_in < provider->previous_pages_in + || pages_out < provider->previous_pages_out) { + provider->previous_pages_in = pages_in; + provider->previous_pages_out = pages_out; + provider->previous_vmstat_ns = now_ns; + provider->have_vmstat_baseline = true; + return; + } + /* A force-refresh performed immediately after construction must not turn + * a near-zero sampling window into false proof of quiet swap. Accumulate + * against the existing baseline until at least the normal poll interval + * has elapsed. */ + if (now_ns - provider->previous_vmstat_ns >= SSD_DEFAULT_POLL_INTERVAL_NS) { + snapshot->swap_activity_known = true; + snapshot->swap_pages_in_delta = pages_in - provider->previous_pages_in; + snapshot->swap_pages_out_delta = pages_out - provider->previous_pages_out; + provider->previous_pages_in = pages_in; + provider->previous_pages_out = pages_out; + provider->previous_vmstat_ns = now_ns; + } +} + +static void populate_swap_usage(Lardon3DSsdProviderSnapshot *snapshot) { + char buffer[SSD_PROC_BUFFER_CAPACITY]; + char *save = NULL; + char *line; + + /* Block.Size validates a nonzero partition but is not exact usable swap + * capacity. Publish total/used only from the kernel's active swap table. */ + if (!snapshot->swap.present) { + return; + } + if (!read_file_bounded("/proc/swaps", buffer, sizeof(buffer))) { + if (!snapshot->swap.active) { + snapshot->swap.used_known = true; + snapshot->swap.used_bytes = 0; + } + return; + } + (void)strtok_r(buffer, "\n", &save); /* Skip the fixed /proc/swaps header. */ + while ((line = strtok_r(NULL, "\n", &save)) != NULL) { + char device[LARDON3D_SSD_PATH_CAPACITY]; + char type[32]; + uintmax_t total_kib; + uintmax_t used_kib; + int priority; + + if (sscanf( + line, + "%255s %31s %" SCNuMAX " %" SCNuMAX " %d", + device, + type, + &total_kib, + &used_kib, + &priority + ) != 5 + || strcmp(device, snapshot->swap.device) != 0) { + continue; + } + (void)type; + (void)priority; + /* /proc/swaps is kernel truth and may be newer than the D-Bus property + * cache. A matching row can only strengthen Active; a stale false must + * never lead to SAFE_TO_UNPLUG. */ + snapshot->swap.active = true; + if (total_kib <= UINT64_MAX / UINT64_C(1024) + && used_kib <= UINT64_MAX / UINT64_C(1024)) { + snapshot->swap.total_known = true; + snapshot->swap.total_bytes = (uint64_t)total_kib * UINT64_C(1024); + snapshot->swap.used_known = true; + snapshot->swap.used_bytes = (uint64_t)used_kib * UINT64_C(1024); + } + return; + } + if (!snapshot->swap.active) { + snapshot->swap.used_known = true; + snapshot->swap.used_bytes = 0; + } +} + +static bool copy_mount_points( + GVariant *filesystem, + Lardon3DSsdProviderScratch *scratch, + Lardon3DSsdProviderSnapshot *snapshot +) { + GVariant *mounts; + GVariantIter iterator; + GVariant *encoded; + size_t count = 0; + + mounts = g_variant_lookup_value( + filesystem, + "MountPoints", + G_VARIANT_TYPE("aay") + ); + if (!mounts) { + snapshot->invalid_observation = true; + copy_text( + snapshot->invalid_reason, + sizeof(snapshot->invalid_reason), + "UDisks Filesystem lacks MountPoints telemetry" + ); + return false; + } + g_variant_iter_init(&iterator, mounts); + while ((encoded = g_variant_iter_next_value(&iterator)) != NULL) { + gsize length = 0; + const guint8 *mount_path = g_variant_get_fixed_array( + encoded, + &length, + sizeof(*mount_path) + ); + count += 1; + if (count > SSD_MAX_MOUNT_POINTS || !mount_path || length < 2 + || mount_path[length - 1] != 0 || length > sizeof(scratch->mount_path) + || memchr(mount_path, 0, length - 1) != NULL) { + snapshot->invalid_observation = true; + copy_text( + snapshot->invalid_reason, + sizeof(snapshot->invalid_reason), + "UDisks returned too many or overlong scratch mount points" + ); + g_variant_unref(encoded); + g_variant_unref(mounts); + return false; + } + if (count == 1) { + memcpy(scratch->mount_path, mount_path, length); + } + g_variant_unref(encoded); + } + g_variant_unref(mounts); + if (count > 1) { + snapshot->invalid_observation = true; + copy_text( + snapshot->invalid_reason, + sizeof(snapshot->invalid_reason), + "Scratch filesystem has multiple mount points" + ); + return false; + } + scratch->mounted = count == 1 && text_is_present(scratch->mount_path); + return true; +} + +static void populate_volume_from_block( + const char *object_path, + GVariant *interfaces, + GVariant *block, + bool is_swap, + Lardon3DSsdProviderSnapshot *snapshot +) { + uint64_t size = 0; + char label[LARDON3D_SSD_TEXT_CAPACITY] = {0}; + char uuid[LARDON3D_SSD_TEXT_CAPACITY] = {0}; + char drive[LARDON3D_SSD_IDENTITY_CAPACITY] = {0}; + char device[LARDON3D_SSD_PATH_CAPACITY] = {0}; + + if (!copy_variant_string(block, "IdLabel", label, sizeof(label))) { + return; + } + if ((is_swap && strcmp(label, LARDON3D_SSD_SWAP_LABEL) != 0) + || (!is_swap && strcmp(label, LARDON3D_SSD_SCRATCH_LABEL) != 0)) { + return; + } + + if (!g_variant_lookup(block, "Size", "t", &size) + || !copy_variant_string(block, "IdUUID", uuid, sizeof(uuid)) + || !copy_variant_object_path(block, "Drive", drive, sizeof(drive)) + || !copy_variant_bytestring(block, "Device", device, sizeof(device)) + || strlen(object_path) >= LARDON3D_SSD_OBJECT_PATH_CAPACITY) { + snapshot->invalid_observation = true; + copy_text( + snapshot->invalid_reason, + sizeof(snapshot->invalid_reason), + "Reserved UDisks Block properties are missing or overlong" + ); + return; + } + + if (is_swap) { + GVariant *swap = g_variant_lookup_value( + interfaces, + SSD_SWAP_INTERFACE, + G_VARIANT_TYPE_VARDICT + ); + if (snapshot->swap.present) { + snapshot->ambiguous_labels = true; + if (swap) { + g_variant_unref(swap); + } + return; + } + snapshot->swap.present = true; + snapshot->swap.size_bytes = size; + copy_text(snapshot->swap.label, sizeof(snapshot->swap.label), label); + copy_text(snapshot->swap.uuid, sizeof(snapshot->swap.uuid), uuid); + copy_text( + snapshot->swap.drive_identity, + sizeof(snapshot->swap.drive_identity), + drive + ); + copy_text(snapshot->swap.object_path, sizeof(snapshot->swap.object_path), object_path); + copy_text(snapshot->swap.device, sizeof(snapshot->swap.device), device); + snapshot->swap.interface_available = swap != NULL; + if (swap) { + GVariant *active = g_variant_lookup_value( + swap, + "Active", + G_VARIANT_TYPE_BOOLEAN + ); + + /* CONTRACT: Swapspace.Active is required control telemetry, not an + * optional display value. Missing or differently typed storage is + * corruption at the provider boundary and must never become the + * unsafe default "inactive" used by drain/Safe-to-Unplug logic. */ + if (!active) { + snapshot->invalid_observation = true; + copy_text( + snapshot->invalid_reason, + sizeof(snapshot->invalid_reason), + "UDisks Swapspace lacks exact boolean Active telemetry" + ); + } else { + snapshot->swap.active_known = true; + snapshot->swap.active = g_variant_get_boolean(active) != FALSE; + g_variant_unref(active); + } + g_variant_unref(swap); + } + } else { + GVariant *filesystem = g_variant_lookup_value( + interfaces, + SSD_FILESYSTEM_INTERFACE, + G_VARIANT_TYPE_VARDICT + ); + if (snapshot->scratch.present) { + snapshot->ambiguous_labels = true; + if (filesystem) { + g_variant_unref(filesystem); + } + return; + } + snapshot->scratch.present = true; + snapshot->scratch.size_bytes = size; + copy_text(snapshot->scratch.label, sizeof(snapshot->scratch.label), label); + copy_text(snapshot->scratch.uuid, sizeof(snapshot->scratch.uuid), uuid); + copy_text( + snapshot->scratch.drive_identity, + sizeof(snapshot->scratch.drive_identity), + drive + ); + copy_text( + snapshot->scratch.object_path, + sizeof(snapshot->scratch.object_path), + object_path + ); + copy_text(snapshot->scratch.device, sizeof(snapshot->scratch.device), device); + snapshot->scratch.interface_available = filesystem != NULL; + if (filesystem) { + (void)copy_mount_points(filesystem, &snapshot->scratch, snapshot); + g_variant_unref(filesystem); + } + } +} + +static void populate_drive_properties( + GVariant *objects, + const char *drive_identity, + Lardon3DSsdProviderSnapshot *snapshot, + bool *unit_ready +) { + GVariant *interfaces; + GVariant *drive; + /* GLib's boolean ABI is wider than C17 bool and must not write directly + * into controller storage. Missing MediaAvailable is already conservative: + * it leaves Unit Not Ready and cannot authorize an action. */ + gboolean media_available = FALSE; + + *unit_ready = false; + interfaces = g_variant_lookup_value( + objects, + drive_identity, + G_VARIANT_TYPE("a{sa{sv}}") + ); + if (!interfaces) { + return; + } + drive = g_variant_lookup_value( + interfaces, + SSD_DRIVE_INTERFACE, + G_VARIANT_TYPE_VARDICT + ); + if (!drive) { + g_variant_unref(interfaces); + return; + } + + if (g_variant_lookup(drive, "MediaAvailable", "b", &media_available)) { + *unit_ready = media_available != FALSE; + } + if (!snapshot->model_known + && copy_variant_string(drive, "Model", snapshot->model, sizeof(snapshot->model)) + && text_is_present(snapshot->model)) { + snapshot->model_known = true; + } + if (!snapshot->serial_known + && copy_variant_string(drive, "Serial", snapshot->serial, sizeof(snapshot->serial)) + && text_is_present(snapshot->serial)) { + snapshot->serial_known = true; + } + g_variant_unref(drive); + g_variant_unref(interfaces); +} + +static bool parse_managed_objects( + GVariant *objects, + Lardon3DSsdProviderSnapshot *snapshot, + char reason[LARDON3D_SSD_REASON_CAPACITY] +) { + GVariantIter iterator; + const char *object_path; + GVariant *interfaces; + size_t object_count = 0; + + if (!objects || !snapshot + || !g_variant_is_of_type(objects, G_VARIANT_TYPE("a{oa{sa{sv}}}"))) { + copy_text(reason, LARDON3D_SSD_REASON_CAPACITY, "invalid UDisks object map"); + return false; + } + + g_variant_iter_init(&iterator, objects); + while (g_variant_iter_next( + &iterator, + "{&o@a{sa{sv}}}", + &object_path, + &interfaces + )) { + GVariant *block; + + object_count += 1; + if (object_count > SSD_MAX_UDISKS_OBJECTS) { + g_variant_unref(interfaces); + copy_text( + reason, + LARDON3D_SSD_REASON_CAPACITY, + "UDisks object count exceeds the controller bound" + ); + return false; + } + block = g_variant_lookup_value( + interfaces, + SSD_BLOCK_INTERFACE, + G_VARIANT_TYPE_VARDICT + ); + if (block) { + populate_volume_from_block(object_path, interfaces, block, true, snapshot); + populate_volume_from_block(object_path, interfaces, block, false, snapshot); + g_variant_unref(block); + } + g_variant_unref(interfaces); + } + + if (snapshot->swap.present) { + populate_drive_properties( + objects, + snapshot->swap.drive_identity, + snapshot, + &snapshot->swap.unit_ready + ); + } + if (snapshot->scratch.present) { + populate_drive_properties( + objects, + snapshot->scratch.drive_identity, + snapshot, + &snapshot->scratch.unit_ready + ); + } + reason[0] = '\0'; + return true; +} + +#ifdef LARDON3D_SSD_CONTROLLER_TESTING +bool lardon3d_ssd_parse_managed_objects_for_test( + GVariant *objects, + Lardon3DSsdProviderSnapshot *snapshot, + char reason[LARDON3D_SSD_REASON_CAPACITY] +) { + if (!snapshot || !reason) { + return false; + } + memset(snapshot, 0, sizeof(*snapshot)); + return parse_managed_objects(objects, snapshot, reason); +} +#endif + +static bool production_refresh( + void *context, + Lardon3DSsdProviderSnapshot *snapshot, + char reason[LARDON3D_SSD_REASON_CAPACITY] +) { + SsdProductionProvider *provider = context; + GError *error = NULL; + GVariant *reply; + GVariant *objects; + bool parsed; + + if (!provider || !snapshot) { + copy_text(reason, LARDON3D_SSD_REASON_CAPACITY, "invalid production provider"); + return false; + } + memset(snapshot, 0, sizeof(*snapshot)); + + /* GetManagedObjects and every mutating call have the same finite timeout. + * GDBus owns and bounds the complete D-Bus message; parsing additionally + * caps accepted object count before retaining any repository state. */ + reply = g_dbus_connection_call_sync( + provider->connection, + SSD_UDISKS_SERVICE, + SSD_UDISKS_ROOT, + SSD_OBJECT_MANAGER_INTERFACE, + "GetManagedObjects", + NULL, + G_VARIANT_TYPE("(a{oa{sa{sv}}})"), + G_DBUS_CALL_FLAGS_NONE, + SSD_DBUS_TIMEOUT_MS, + NULL, + &error + ); + if (!reply) { + format_gerror(reason, error); + g_clear_error(&error); + return false; + } + g_variant_get(reply, "(@a{oa{sa{sv}}})", &objects); + g_variant_unref(reply); + parsed = parse_managed_objects(objects, snapshot, reason); + g_variant_unref(objects); + if (!parsed) { + return false; + } + + populate_connection_speed_from_sysfs(snapshot); + populate_swap_usage(snapshot); + /* CONTRACT: Block.Size is partition extent, not usable filesystem space. + * A synchronous UI refresh must not issue an unbounded path-based statvfs; + * production therefore leaves scratch total/free explicitly UNKNOWN until + * a future bounded telemetry source exists. */ + populate_host_telemetry(provider, snapshot); + reason[0] = '\0'; + return true; +} + +static GVariant *empty_options_tuple(void) { + GVariant *dictionary = g_variant_new_array(G_VARIANT_TYPE("{sv}"), NULL, 0); + return g_variant_new("(@a{sv})", dictionary); +} + +static bool production_call_no_result( + SsdProductionProvider *provider, + const char *object_path, + const char *interface_name, + const char *method, + char reason[LARDON3D_SSD_REASON_CAPACITY] +) { + GError *error = NULL; + GVariant *reply; + + if (!provider || !object_path || !g_variant_is_object_path(object_path)) { + copy_text(reason, LARDON3D_SSD_REASON_CAPACITY, "invalid UDisks object path"); + return false; + } + reply = g_dbus_connection_call_sync( + provider->connection, + SSD_UDISKS_SERVICE, + object_path, + interface_name, + method, + empty_options_tuple(), + G_VARIANT_TYPE("()"), + G_DBUS_CALL_FLAGS_NONE, + SSD_DBUS_TIMEOUT_MS, + NULL, + &error + ); + if (!reply) { + format_gerror(reason, error); + g_clear_error(&error); + return false; + } + g_variant_unref(reply); + reason[0] = '\0'; + return true; +} + +static bool production_start_swap( + void *context, + const char *object_path, + char reason[LARDON3D_SSD_REASON_CAPACITY] +) { + return production_call_no_result( + context, + object_path, + SSD_SWAP_INTERFACE, + "Start", + reason + ); +} + +static bool production_stop_swap( + void *context, + const char *object_path, + char reason[LARDON3D_SSD_REASON_CAPACITY] +) { + return production_call_no_result( + context, + object_path, + SSD_SWAP_INTERFACE, + "Stop", + reason + ); +} + +static bool production_mount_scratch( + void *context, + const char *object_path, + char mount_path[LARDON3D_SSD_PATH_CAPACITY], + char reason[LARDON3D_SSD_REASON_CAPACITY] +) { + SsdProductionProvider *provider = context; + GError *error = NULL; + GVariant *reply; + const char *returned_path = NULL; + + if (!provider || !object_path || !g_variant_is_object_path(object_path)) { + copy_text(reason, LARDON3D_SSD_REASON_CAPACITY, "invalid UDisks object path"); + return false; + } + reply = g_dbus_connection_call_sync( + provider->connection, + SSD_UDISKS_SERVICE, + object_path, + SSD_FILESYSTEM_INTERFACE, + "Mount", + empty_options_tuple(), + G_VARIANT_TYPE("(s)"), + G_DBUS_CALL_FLAGS_NONE, + SSD_DBUS_TIMEOUT_MS, + NULL, + &error + ); + if (!reply) { + format_gerror(reason, error); + g_clear_error(&error); + return false; + } + g_variant_get(reply, "(&s)", &returned_path); + if (!returned_path || strlen(returned_path) >= LARDON3D_SSD_PATH_CAPACITY) { + g_variant_unref(reply); + copy_text(reason, LARDON3D_SSD_REASON_CAPACITY, "UDisks mount path is overlong"); + return false; + } + copy_text(mount_path, LARDON3D_SSD_PATH_CAPACITY, returned_path); + g_variant_unref(reply); + reason[0] = '\0'; + return true; +} + +static bool production_unmount_scratch( + void *context, + const char *object_path, + char reason[LARDON3D_SSD_REASON_CAPACITY] +) { + return production_call_no_result( + context, + object_path, + SSD_FILESYSTEM_INTERFACE, + "Unmount", + reason + ); +} + +static void production_destroy(void *context) { + SsdProductionProvider *provider = context; + + if (!provider) { + return; + } + if (provider->connection) { + g_object_unref(provider->connection); + } + free(provider); +} + +static const Lardon3DSsdProviderOps SSD_PRODUCTION_PROVIDER_OPS = { + .monotonic_now_ns = production_monotonic_now_ns, + .refresh = production_refresh, + .start_swap = production_start_swap, + .stop_swap = production_stop_swap, + .mount_scratch = production_mount_scratch, + .unmount_scratch = production_unmount_scratch, + .destroy = production_destroy, +}; + +bool lardon3d_ssd_production_provider_create(Lardon3DSsdProvider *provider) { + SsdProductionProvider *context; + GError *error = NULL; + + if (!provider) { + return false; + } + *provider = (Lardon3DSsdProvider){0}; + context = calloc(1, sizeof(*context)); + if (!context) { + return false; + } + context->connection = connect_system_bus_bounded(&error); + if (!context->connection) { + g_clear_error(&error); + free(context); + return false; + } + provider->ops = &SSD_PRODUCTION_PROVIDER_OPS; + provider->context = context; + return true; +} diff --git a/src/ssd_controller_internal.h b/src/ssd_controller_internal.h new file mode 100644 index 0000000..0f975c1 --- /dev/null +++ b/src/ssd_controller_internal.h @@ -0,0 +1,162 @@ +#ifndef LARDON3D_SSD_CONTROLLER_INTERNAL_H +#define LARDON3D_SSD_CONTROLLER_INTERNAL_H + +#include +#include + +#include + +#ifdef LARDON3D_SSD_CONTROLLER_TESTING +#include +#endif + +#ifdef __cplusplus +extern "C" { +#endif + +enum { + LARDON3D_SSD_OBJECT_PATH_CAPACITY = 256, +}; + +typedef struct { + bool present; + bool unit_ready; + bool interface_available; + uint64_t size_bytes; + char label[LARDON3D_SSD_TEXT_CAPACITY]; + char uuid[LARDON3D_SSD_TEXT_CAPACITY]; + char drive_identity[LARDON3D_SSD_IDENTITY_CAPACITY]; + char object_path[LARDON3D_SSD_OBJECT_PATH_CAPACITY]; + char device[LARDON3D_SSD_PATH_CAPACITY]; + + /* Required UDisks Swapspace.Active telemetry. `active` has no meaning + * unless active_known is true; missing/wrong D-Bus storage must never be + * narrowed to the unsafe default "inactive". */ + bool active_known; + bool active; + bool total_known; + bool used_known; + uint64_t total_bytes; + uint64_t used_bytes; +} Lardon3DSsdProviderSwap; + +typedef struct { + bool present; + bool unit_ready; + bool interface_available; + uint64_t size_bytes; + char label[LARDON3D_SSD_TEXT_CAPACITY]; + char uuid[LARDON3D_SSD_TEXT_CAPACITY]; + char drive_identity[LARDON3D_SSD_IDENTITY_CAPACITY]; + char object_path[LARDON3D_SSD_OBJECT_PATH_CAPACITY]; + char device[LARDON3D_SSD_PATH_CAPACITY]; + + bool mounted; + bool total_known; + bool free_known; + uint64_t total_bytes; + uint64_t free_bytes; + char mount_path[LARDON3D_SSD_PATH_CAPACITY]; +} Lardon3DSsdProviderScratch; + +typedef struct { + bool model_known; + bool serial_known; + bool connection_speed_known; + char model[LARDON3D_SSD_TEXT_CAPACITY]; + char serial[LARDON3D_SSD_TEXT_CAPACITY]; + uint64_t connection_speed_mbps; + + bool ambiguous_labels; + bool invalid_observation; + char invalid_reason[LARDON3D_SSD_REASON_CAPACITY]; + Lardon3DSsdProviderSwap swap; + Lardon3DSsdProviderScratch scratch; + + bool memory_available_known; + uint64_t memory_available_bytes; + bool memory_pressure_known; + bool memory_pressure_elevated; + bool io_pressure_known; + bool io_pressure_elevated; + bool swap_activity_known; + uint64_t swap_pages_in_delta; + uint64_t swap_pages_out_delta; +} Lardon3DSsdProviderSnapshot; + +typedef struct { + bool (*monotonic_now_ns)(void *context, uint64_t *now_ns); + bool (*refresh)( + void *context, + Lardon3DSsdProviderSnapshot *snapshot, + char reason[LARDON3D_SSD_REASON_CAPACITY] + ); + bool (*start_swap)( + void *context, + const char *object_path, + char reason[LARDON3D_SSD_REASON_CAPACITY] + ); + bool (*stop_swap)( + void *context, + const char *object_path, + char reason[LARDON3D_SSD_REASON_CAPACITY] + ); + bool (*mount_scratch)( + void *context, + const char *object_path, + char mount_path[LARDON3D_SSD_PATH_CAPACITY], + char reason[LARDON3D_SSD_REASON_CAPACITY] + ); + bool (*unmount_scratch)( + void *context, + const char *object_path, + char reason[LARDON3D_SSD_REASON_CAPACITY] + ); + void (*destroy)(void *context); +} Lardon3DSsdProviderOps; + +typedef struct { + const Lardon3DSsdProviderOps *ops; + void *context; +} Lardon3DSsdProvider; + +/* Private dependency-injection seam. The controller takes ownership of + * `provider.context` on success only. Tests use fixed in-memory providers; + * production constructs the GDBus provider below. This never extends the + * public ABI or permits provider replacement after creation. The interval is + * at least one second; tests use force-refresh instead of weakening caching. */ +Lardon3DSsdController *lardon3d_ssd_controller_create_with_provider( + Lardon3DSsdProvider provider, + uint64_t minimum_poll_interval_ns +); + +bool lardon3d_ssd_production_provider_create(Lardon3DSsdProvider *provider); + +#ifdef LARDON3D_SSD_CONTROLLER_TESTING +/* Parses a caller-owned GetManagedObjects fixture without touching D-Bus, + * /proc, mounts, or swap. This exists solely to validate exact GLib storage + * types and bounded UDisks discovery; production uses the same parser. */ +bool lardon3d_ssd_parse_managed_objects_for_test( + GVariant *objects, + Lardon3DSsdProviderSnapshot *snapshot, + char reason[LARDON3D_SSD_REASON_CAPACITY] +); +/* Deterministic saturation seam. It changes only the private source watermark + * and cached copy; later production operations still use the real saturating + * bump path and otherwise retain identical controller behavior. */ +bool lardon3d_ssd_controller_set_generation_for_test( + Lardon3DSsdController *controller, + uint64_t generation +); +/* Forces only the cached copy to fail bounded-string validation. Physical + * lease bookkeeping still completes through the production release path. */ +bool lardon3d_ssd_controller_corrupt_cached_snapshot_for_test( + Lardon3DSsdController *controller +); +#endif + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/src/task.c b/src/task.c index b90ee5a..9ed69e1 100644 --- a/src/task.c +++ b/src/task.c @@ -14,6 +14,9 @@ struct Lardon3DTask { uint64_t id; char name[LARDON3D_TASK_NAME_CAPACITY]; unsigned int progress; + bool durable_progress_known; + uint64_t durable_completed; + uint64_t durable_total; Lardon3DTaskState state; char message[LARDON3D_TASK_MESSAGE_CAPACITY]; struct timespec started_at; @@ -112,15 +115,17 @@ kind_has_validated_cpu_range(const char *task_kind, uint32_t task_kind_version) || strcmp(task_kind, "features.extract.sift") == 0 || strcmp(task_kind, "features.extract.rootsift") == 0 || strcmp(task_kind, "visual_index.update") == 0 - || strcmp(task_kind, "candidate_pair.generate") == 0; + || strcmp(task_kind, "candidate_pair.generate") == 0 + || strcmp(task_kind, "geometric_verifier.run") == 0; } static bool kind_has_validated_batch_range(const char *task_kind, uint32_t version) { - /* Candidate generation already executes/publishes at every canonical - * 1..64 sequence size. This private bit changes operational pacing only; - * it does not add a scientific or durable identity dimension. */ + /* Candidate generation explicitly co-adapts its sequence window. GV keeps + * its 16-item preparation window while trialling CPU, because CPU2 with a + * one-item batch cannot exercise a second participant; Gate G may still + * reduce that bounded window for capacity or pressure. */ return task_kind && version == 1 && strcmp(task_kind, "candidate_pair.generate") == 0; } @@ -1107,6 +1112,62 @@ lardon3d_task_set_progress( bool accepted = !is_terminal(task->state); if (accepted) { task->progress = progress; + task->durable_progress_known = false; + task->durable_completed = 0; + task->durable_total = 0; + if (message) { + copy_text(task->message, sizeof(task->message), message); + } + } + (void)pthread_mutex_unlock(&task->mutex); + return accepted; +} + +static unsigned int +durable_percentage(uint64_t completed, uint64_t total) +{ + if (completed >= total) { + return 100; + } + /* Compare against ceil(percent * total / 100). Splitting total first + * avoids overflowing uint64_t even at the public input maximum. */ + uint64_t quotient = total / 100U; + uint64_t remainder = total % 100U; + for (unsigned int percent = 99; percent > 0; --percent) { + uint64_t threshold = quotient * percent; + uint64_t residual_product = remainder * percent; + threshold += residual_product / 100U; + if (residual_product % 100U != 0) { + ++threshold; + } + if (completed >= threshold) { + return percent; + } + } + return 0; +} + +bool +lardon3d_task_set_durable_progress( + Lardon3DTask *task, + uint64_t completed, + uint64_t total, + const char *message +) +{ + if (!task || total == 0 || completed > total) { + return false; + } + (void)pthread_mutex_lock(&task->mutex); + /* Only a typed Task owner can name a durable business prefix. Generic + * Task completion never fills the remaining count: doing so would invent + * a commit outside the typed transaction that owns this observation. */ + bool accepted = !is_terminal(task->state) && task->task_kind[0] != '\0'; + if (accepted) { + task->progress = durable_percentage(completed, total); + task->durable_progress_known = true; + task->durable_completed = completed; + task->durable_total = total; if (message) { copy_text(task->message, sizeof(task->message), message); } @@ -1159,6 +1220,48 @@ lardon3d_task_snapshot( return true; } +bool +lardon3d_task_observation( + const Lardon3DTask *task, + Lardon3DTaskObservation *observation +) +{ + if (observation) { + *observation = (Lardon3DTaskObservation) {0}; + } + if (!task || !observation) { + return false; + } + Lardon3DTask *mutable_task = (Lardon3DTask *)task; + (void)pthread_mutex_lock(&mutable_task->mutex); + *observation = (Lardon3DTaskObservation) { + .id = task->id, + .progress = task->progress, + .state = task->state, + .started_at = task->started_at, + .finished_at = task->finished_at, + .has_task_kind = task->task_kind[0] != '\0', + .task_kind_version = task->task_kind_version, + .durable_progress_known = task->durable_progress_known, + .durable_completed = task->durable_progress_known + ? task->durable_completed : 0, + .durable_total = task->durable_progress_known + ? task->durable_total : 0, + .sequence_count = task->sequence_count, + .has_execution_contract = task->has_contract, + .execution_contract = task->has_contract + ? task->contract + : (Lardon3DTaskExecutionContract) {0}, + }; + copy_text(observation->name, sizeof(observation->name), task->name); + copy_text(observation->task_kind, sizeof(observation->task_kind), + task->task_kind); + copy_text(observation->message, sizeof(observation->message), + task->message); + (void)pthread_mutex_unlock(&mutable_task->mutex); + return true; +} + bool lardon3d_task_durable_snapshot( const Lardon3DTask *task, diff --git a/src/task_kind_registry.c b/src/task_kind_registry.c index 39f23cc..fc3ab6a 100644 --- a/src/task_kind_registry.c +++ b/src/task_kind_registry.c @@ -1,3 +1,4 @@ +#include #include #include #include @@ -23,11 +24,18 @@ extern bool lardon3d_acquisition_campaign_task_internal_configure_restored( enum { CANDIDATE_LEGACY_FIXED_BYTES = 128 * 1024, CANDIDATE_CURRENT_FIXED_BYTES = 256 * 1024, - CANDIDATE_PER_ITEM_BYTES = 64 * 1024, + CANDIDATE_LEGACY_PER_ITEM_BYTES = 64 * 1024, + CANDIDATE_CURRENT_PER_ITEM_BYTES = 8 * 1024 * 1024, + FEATURE_FIXED_BYTES = 64 * 1024 * 1024, + FEATURE_PER_ITEM_BYTES = 512 * 1024 * 1024, MATCHER_LEGACY_FIXED_BYTES = 10 * 1024 * 1024, MATCHER_CURRENT_PER_ITEM_BYTES = 10 * 1024 * 1024, MATCHER_GPU_FIXED_BYTES = 640 * 1024, + GEOMETRIC_VERIFIER_LEGACY_FIXED_BYTES = 4 * 1024 * 1024, + GEOMETRIC_VERIFIER_CURRENT_PER_ITEM_BYTES = 8 * 1024 * 1024, SIFT_FIXED_BYTES = 64 * 1024 * 1024, + VISUAL_INDEX_FIXED_BYTES = 8 * 1024 * 1024, + VISUAL_INDEX_PER_ITEM_BYTES = 2 * 1024 * 1024, }; static bool @@ -58,16 +66,35 @@ normalize_known_legacy_estimate(const char *kind, if (strcmp(kind, "candidate_pair.generate") == 0) { current = (Lardon3DResourceEstimate) { .memory_fixed_bytes = CANDIDATE_CURRENT_FIXED_BYTES, - .memory_bytes_per_item = CANDIDATE_PER_ITEM_BYTES, + .memory_bytes_per_item = CANDIDATE_CURRENT_PER_ITEM_BYTES, .minimum_batch_size = 1, .maximum_batch_size = 64, - .desired_cpu_threads = 12, + .desired_cpu_threads = 64, .desired_io_slots = 1, .task_class = LARDON3D_RESOURCE_TASK_CPU, }; historical = current; - historical.memory_fixed_bytes = CANDIDATE_LEGACY_FIXED_BYTES; - historical.desired_cpu_threads = 1; + historical.memory_bytes_per_item = CANDIDATE_LEGACY_PER_ITEM_BYTES; + historical.desired_cpu_threads = 12; + oldest = historical; + oldest.memory_fixed_bytes = CANDIDATE_LEGACY_FIXED_BYTES; + oldest.desired_cpu_threads = 1; + has_oldest = true; + } else if (strcmp(kind, "features.extract") == 0) { + current = (Lardon3DResourceEstimate) { + .memory_fixed_bytes = FEATURE_FIXED_BYTES, + .memory_bytes_per_item = FEATURE_PER_ITEM_BYTES, + .minimum_batch_size = 1, + .maximum_batch_size = 1, + .desired_cpu_threads = INT_MAX, + .desired_io_slots = 1, + .task_class = LARDON3D_RESOURCE_TASK_CPU, + }; + historical = current; + historical.desired_cpu_threads = 12; + oldest = historical; + oldest.desired_cpu_threads = 1; + has_oldest = true; } else if (strcmp(kind, "matcher.run") == 0) { const bool gpu = durable->desired_gpu_slots == 1; current = (Lardon3DResourceEstimate) { @@ -108,12 +135,53 @@ normalize_known_legacy_estimate(const char *kind, .memory_bytes_per_item = UINT64_C(1024) * 1024 * 1024, .minimum_batch_size = 1, .maximum_batch_size = 1, - .desired_cpu_threads = 12, + .desired_cpu_threads = INT_MAX, .desired_io_slots = 1, .task_class = LARDON3D_RESOURCE_TASK_CPU, }; historical = current; - historical.desired_cpu_threads = 1; + historical.desired_cpu_threads = 12; + oldest = historical; + oldest.desired_cpu_threads = 1; + has_oldest = true; + } else if (strcmp(kind, "visual_index.update") == 0) { + current = (Lardon3DResourceEstimate) { + .memory_fixed_bytes = VISUAL_INDEX_FIXED_BYTES, + .memory_bytes_per_item = VISUAL_INDEX_PER_ITEM_BYTES, + .minimum_batch_size = 1, + .maximum_batch_size = 16, + .desired_cpu_threads = 16, + .desired_io_slots = 1, + .task_class = LARDON3D_RESOURCE_TASK_CPU, + }; + historical = current; + historical.desired_cpu_threads = 12; + /* An exact serial Visual Index Task signature predates bounded + * internal fan-out. Accept only that complete CPU1 durable shape. */ + oldest = historical; + oldest.desired_cpu_threads = 1; + has_oldest = true; + } else if (strcmp(kind, "geometric_verifier.run") == 0) { + current = (Lardon3DResourceEstimate) { + .memory_bytes_per_item = + GEOMETRIC_VERIFIER_CURRENT_PER_ITEM_BYTES, + .minimum_batch_size = 1, + .maximum_batch_size = 16, + .desired_cpu_threads = 8, + .desired_io_slots = 1, + .task_class = LARDON3D_RESOURCE_TASK_CPU, + }; + historical = (Lardon3DResourceEstimate) { + .memory_fixed_bytes = GEOMETRIC_VERIFIER_LEGACY_FIXED_BYTES, + .minimum_batch_size = 1, + .maximum_batch_size = 8, + .desired_cpu_threads = 1, + .desired_io_slots = 1, + .task_class = LARDON3D_RESOURCE_TASK_CPU, + }; + /* CPU/batch/memory admission is operational, not GVR identity. Accept + * only the complete frozen serial envelope, normalize it in memory, + * and never publish an estimate-only checkpoint during recovery. */ } else { *effective = *durable; return true; diff --git a/src/task_queue.c b/src/task_queue.c index c2a5c72..865360a 100644 --- a/src/task_queue.c +++ b/src/task_queue.c @@ -1,28 +1,68 @@ +#include #include #include +#include #include +#include #include +#include #include #include #include "task_internal.h" +#define LARDON3D_TASK_QUEUE_TEST_WEAK_REFERENCE +#include "task_queue_internal.h" +#undef LARDON3D_TASK_QUEUE_TEST_WEAK_REFERENCE typedef struct TaskNode { Lardon3DTask *task; + struct TaskNode *previous_all; struct TaskNode *next_all; struct TaskNode *next_pending; + /* Pins Task across Queue-unlocked control callbacks; only Queue mutex + * mutates this count. */ + size_t control_users; } TaskNode; enum { LARDON3D_PENDING_RESOURCE_WAIT_MILLISECONDS = 500, }; -/* The queue owns submission order and bounded backpressure only. - * It does not own resource policy; every admissible decision comes from the - * Governor via reserve/evaluate calls. +#define LARDON3D_TASK_QUEUE_CALL_GATE_CLOSING \ + ((size_t)1 << (sizeof(size_t) * CHAR_BIT - 1)) +#define LARDON3D_TASK_QUEUE_CALL_GATE_REFERENCES \ + (LARDON3D_TASK_QUEUE_CALL_GATE_CLOSING - (size_t)1) + +#if defined(LARDON3D_TASK_QUEUE_TESTING) +static void +notify_internal_test_event( + Lardon3DTaskQueue *queue, + Lardon3DTaskQueueTestEvent event +) +{ +#if defined(__GNUC__) || defined(__clang__) + if (lardon3d_task_queue_internal_test_event) { + lardon3d_task_queue_internal_test_event(queue, event); + } +#else + lardon3d_task_queue_internal_test_event(queue, event); +#endif +} +#endif + +/* The Queue owns Task lifetime, serialized dispatch, and pending backpressure. + * It does not own resource policy; every admission decision comes from the + * Governor. Terminal Task objects are deliberately replaced with fixed-size + * snapshots so task-owned working sets do not become application-lifetime + * allocations. */ struct Lardon3DTaskQueue { + /* One atomic word linearizes call registration against irreversible close. + * The high bit is monotonic CLOSING; low bits are calls that registered + * before it. Registered calls pin every following field, including mutex, + * until their release is observed by destroy. */ + atomic_size_t call_gate; pthread_mutex_t mutex; pthread_cond_t not_empty; pthread_cond_t not_full; @@ -32,11 +72,21 @@ struct Lardon3DTaskQueue { bool stopping; Lardon3DResourceGovernor *governor; uint64_t next_id; + /* INVARIANT: generation exhaustion is Queue-lifetime monotonic state, not + * an overloaded numeric cursor. Restored/preassigned IDs may still enter + * after exhaustion, but neither they nor history removal can re-arm a + * generated ID that could wrap and collide with an earlier lifetime ID. */ + bool generated_ids_exhausted; TaskNode *all_head; TaskNode *all_tail; TaskNode *pending_head; TaskNode *pending_tail; - Lardon3DTask *active; + TaskNode *active; + Lardon3DTaskObservation history[LARDON3D_TASK_QUEUE_HISTORY_CAPACITY]; + size_t history_count; + /* Lifetime telemetry is saturating and never decremented by history + * eviction/removal; it owns no Task or userdata. */ + size_t terminal_count; size_t capacity; size_t pending_count; size_t active_producers; @@ -49,6 +99,184 @@ terminal_state(Lardon3DTaskState state) || state == TASK_COMPLETED; } +static void +observation_to_snapshot( + const Lardon3DTaskObservation *observation, + Lardon3DTaskSnapshot *snapshot +) +{ + *snapshot = (Lardon3DTaskSnapshot) { + .id = observation->id, + .progress = observation->progress, + .state = observation->state, + .started_at = observation->started_at, + .finished_at = observation->finished_at, + }; + (void)snprintf(snapshot->name, sizeof(snapshot->name), "%s", + observation->name); + (void)snprintf(snapshot->message, sizeof(snapshot->message), "%s", + observation->message); +} + +static size_t +saturating_add(size_t left, size_t right) +{ + return left > SIZE_MAX - right ? SIZE_MAX : left + right; +} + +static size_t +call_gate_references(size_t state) +{ + return state & LARDON3D_TASK_QUEUE_CALL_GATE_REFERENCES; +} + +/* CONTRACT: successful CAS is the public-call lifetime linearization point. + * The acq_rel RMW and destroy's acq_rel close occupy one modification order: + * registration either precedes close and must be awaited, or observes the + * monotonic CLOSING bit and touches no mutex or Queue-owned Task. */ +static bool +register_call(Lardon3DTaskQueue *queue) +{ + size_t observed = atomic_load_explicit( + &queue->call_gate, memory_order_acquire); + for (;;) { + if ((observed & LARDON3D_TASK_QUEUE_CALL_GATE_CLOSING) != 0 + || call_gate_references(observed) + == LARDON3D_TASK_QUEUE_CALL_GATE_REFERENCES) { + return false; + } + if (atomic_compare_exchange_weak_explicit( + &queue->call_gate, + &observed, + observed + (size_t)1, + memory_order_acq_rel, + memory_order_acquire + )) { +#if defined(LARDON3D_TASK_QUEUE_TESTING) + notify_internal_test_event( + queue, LARDON3D_TASK_QUEUE_TEST_CALL_REGISTERED); +#endif + return true; + } + } +} + +/* Caller owns queue->mutex. A release that removes the final reference pairs + * with destroy's acquire load; the condition signal supplies the blocking + * handoff while the atomic remains the authoritative lifetime count. */ +static void +release_call_locked(Lardon3DTaskQueue *queue) +{ + size_t previous = atomic_fetch_sub_explicit( + &queue->call_gate, (size_t)1, memory_order_release); + if (call_gate_references(previous) == 1) { + (void)pthread_cond_broadcast(&queue->not_empty); + } +} + +static bool +begin_call(Lardon3DTaskQueue *queue) +{ + if (!register_call(queue)) { + return false; + } + (void)pthread_mutex_lock(&queue->mutex); + if (queue->stopping) { + release_call_locked(queue); + (void)pthread_mutex_unlock(&queue->mutex); + return false; + } + return true; +} + +/* Caller still owns queue->mutex. */ +static void +end_call_locked(Lardon3DTaskQueue *queue) +{ + release_call_locked(queue); + (void)pthread_mutex_unlock(&queue->mutex); +} + +static TaskNode * +find_live_locked(Lardon3DTaskQueue *queue, uint64_t task_id) +{ + for (TaskNode *node = queue->all_head; node; node = node->next_all) { + if (lardon3d_task_id(node->task) == task_id) { + return node; + } + } + return NULL; +} + +static bool +find_history_locked( + const Lardon3DTaskQueue *queue, + uint64_t task_id, + size_t *index +) +{ + for (size_t current = 0; current < queue->history_count; ++current) { + if (queue->history[current].id == task_id) { + if (index) { + *index = current; + } + return true; + } + } + return false; +} + +static void +remove_history_locked(Lardon3DTaskQueue *queue, size_t index) +{ + if (index + 1 < queue->history_count) { + memmove( + &queue->history[index], + &queue->history[index + 1], + (queue->history_count - index - 1) * sizeof(queue->history[0]) + ); + } + --queue->history_count; +} + +static void +append_history_locked( + Lardon3DTaskQueue *queue, + const Lardon3DTaskObservation *observation +) +{ + /* INVARIANT: history is oldest-to-newest internally. At the fixed TUI + * bound, deterministic left eviction avoids a second allocator/failure + * path exactly when the real Task must be released promptly. */ + if (queue->history_count == LARDON3D_TASK_QUEUE_HISTORY_CAPACITY) { + memmove( + &queue->history[0], + &queue->history[1], + (LARDON3D_TASK_QUEUE_HISTORY_CAPACITY - 1) + * sizeof(queue->history[0]) + ); + --queue->history_count; + } + queue->history[queue->history_count++] = *observation; +} + +static void +unlink_all_locked(Lardon3DTaskQueue *queue, TaskNode *node) +{ + if (node->previous_all) { + node->previous_all->next_all = node->next_all; + } else { + queue->all_head = node->next_all; + } + if (node->next_all) { + node->next_all->previous_all = node->previous_all; + } else { + queue->all_tail = node->previous_all; + } + node->previous_all = NULL; + node->next_all = NULL; +} + static void unlink_pending(Lardon3DTaskQueue *queue, TaskNode *previous, TaskNode *node) { @@ -68,20 +296,89 @@ unlink_pending(Lardon3DTaskQueue *queue, TaskNode *previous, TaskNode *node) (void)pthread_cond_signal(&queue->not_full); } +static void +unlink_pending_if_present_locked(Lardon3DTaskQueue *queue, TaskNode *node) +{ + TaskNode *previous = NULL; + TaskNode *current = queue->pending_head; + while (current && current != node) { + previous = current; + current = current->next_pending; + } + if (current) { + unlink_pending(queue, previous, current); + } +} + +/* CONTRACT: this is the sole live-Task -> terminal-record transition. The + * caller proves the finished callback has returned and later destroys the + * detached node without queue->mutex. active/control users prevent detachment + * while another thread can still dereference the Task. */ +static TaskNode * +retire_terminal_locked( + Lardon3DTaskQueue *queue, + TaskNode *node, + bool retain_history +) +{ + Lardon3DTaskObservation observation; + if (!node || node == queue->active || node->control_users != 0 + || !lardon3d_task_observation(node->task, &observation) + || !terminal_state(observation.state)) { + return NULL; + } + unlink_pending_if_present_locked(queue, node); + unlink_all_locked(queue, node); + queue->terminal_count = saturating_add(queue->terminal_count, 1); + if (retain_history) { + append_history_locked(queue, &observation); + } + (void)pthread_cond_broadcast(&queue->not_empty); + return node; +} + +static void +push_retired(TaskNode **retired, TaskNode *node) +{ + if (!node) { + return; + } + node->next_all = *retired; + *retired = node; +} + +static void +destroy_retired(TaskNode *retired) +{ + while (retired) { + TaskNode *next = retired->next_all; + /* WHY: Task destruction may join execution and invokes userdata + * destruction. It must never run while Queue observers/producers are + * excluded by queue->mutex. */ + lardon3d_task_destroy(retired->task); + free(retired); + retired = next; + } +} + /* Parcourt la file d'attente dans son ordre FIFO et sélectionne la première * tâche admissible; une attente de ressources peut donc laisser passer une * tâche antérieure sans lui faire perdre sa place dans la file. * Les tâches terminales ou refusées sont retirées de la file d'attente. * Une tâche en attente de ressources reste en file et sera réévaluée. * Retourne NULL si aucune tâche ne peut démarrer immédiatement. */ -static Lardon3DTask * +static TaskNode * select_admissible( Lardon3DTaskQueue *queue, Lardon3DResourceReservation **reservation, - bool *resource_wait_pending + bool *resource_wait_pending, + bool *reject_selected, + char reject_message[LARDON3D_TASK_MESSAGE_CAPACITY], + TaskNode **retired ) { *resource_wait_pending = false; + *reject_selected = false; TaskNode *previous = NULL; TaskNode *node = queue->pending_head; while (node) { @@ -89,11 +386,18 @@ select_admissible( Lardon3DTaskSnapshot task_snapshot; if (!lardon3d_task_snapshot(node->task, &task_snapshot)) { unlink_pending(queue, previous, node); - node = next; - continue; + *reject_selected = true; + (void)snprintf( + reject_message, + LARDON3D_TASK_MESSAGE_CAPACITY, + "%s", + "Impossible d'observer l'état de la tâche." + ); + return node; } if (terminal_state(task_snapshot.state)) { unlink_pending(queue, previous, node); + push_retired(retired, retire_terminal_locked(queue, node, true)); node = next; continue; } @@ -106,13 +410,15 @@ select_admissible( &candidate ); if (!evaluated) { - (void)lardon3d_task_reject( - node->task, + unlink_pending(queue, previous, node); + *reject_selected = true; + (void)snprintf( + reject_message, + LARDON3D_TASK_MESSAGE_CAPACITY, + "%s", "Impossible d'évaluer les ressources disponibles." ); - unlink_pending(queue, previous, node); - node = next; - continue; + return node; } if (decision.kind == LARDON3D_RESOURCE_WAIT) { *resource_wait_pending = true; @@ -121,14 +427,20 @@ select_admissible( continue; } if (decision.kind == LARDON3D_RESOURCE_REJECT || !candidate) { - (void)lardon3d_task_reject(node->task, decision.reason); unlink_pending(queue, previous, node); - node = next; - continue; + *reservation = candidate; + *reject_selected = true; + (void)snprintf( + reject_message, + LARDON3D_TASK_MESSAGE_CAPACITY, + "%s", + decision.reason + ); + return node; } unlink_pending(queue, previous, node); *reservation = candidate; - return node->task; + return node; } return NULL; } @@ -185,44 +497,67 @@ queue_worker(void *context) } Lardon3DResourceReservation *reservation = NULL; bool resource_wait_pending; - Lardon3DTask *selected = select_admissible( + bool reject_selected; + char reject_message[LARDON3D_TASK_MESSAGE_CAPACITY] = {0}; + TaskNode *retired = NULL; + TaskNode *selected = select_admissible( queue, &reservation, - &resource_wait_pending + &resource_wait_pending, + &reject_selected, + reject_message, + &retired ); if (!selected) { - wait_for_pending_change(queue, resource_wait_pending); + if (!retired) { + wait_for_pending_change(queue, resource_wait_pending); + } (void)pthread_mutex_unlock(&queue->mutex); + destroy_retired(retired); continue; } queue->active = selected; (void)pthread_mutex_unlock(&queue->mutex); + destroy_retired(retired); - /* Policy may change between Tasks. Reapply and verify only on this - * worker; failure is recorded by the Governor and execution continues - * under the conservative compute-count admission without corrupting - * Queue ownership or durable scientific state. */ - (void)lardon3d_resource_governor_internal_apply_worker_affinity( - queue->governor); - if (!lardon3d_task_start(selected, queue->governor, reservation)) { - (void)lardon3d_task_reject( - selected, - "Réservation de ressources invalide." - ); + if (reject_selected) { + /* Admission rejection can invoke the durable finished callback. + * It runs without Queue mutex held and is therefore fully complete + * before the Task is converted to lightweight history below. + * Unlocked permits observation, not self-removal/destruction: those + * operations depend on this callback returning. */ + (void)lardon3d_task_reject(selected->task, reject_message); + } else { + /* Policy may change between Tasks. Reapply and verify only on this + * worker; failure is recorded by the Governor and execution + * continues under conservative compute-count admission. */ + (void)lardon3d_resource_governor_internal_apply_worker_affinity( + queue->governor); + if (!lardon3d_task_start( + selected->task, queue->governor, reservation)) { + (void)lardon3d_task_reject( + selected->task, + "Réservation de ressources invalide." + ); + } } /* La tâche peut avoir libéré et re-réservé via sequence_break pendant * son callback. Dans ce cas la réservation d'origine est déjà libérée * et cet appel est sans effet ; la réservation courante de la tâche a * été libérée par lardon3d_task_start. */ - (void)lardon3d_resource_governor_release( - queue->governor, - reservation - ); + if (reservation) { + (void)lardon3d_resource_governor_release( + queue->governor, + reservation + ); + } (void)pthread_mutex_lock(&queue->mutex); queue->active = NULL; + retired = retire_terminal_locked(queue, selected, true); (void)pthread_cond_broadcast(&queue->not_empty); (void)pthread_mutex_unlock(&queue->mutex); + destroy_retired(retired); } } @@ -236,6 +571,7 @@ lardon3d_task_queue_create(Lardon3DResourceGovernor *governor, size_t capacity) if (!queue) { return NULL; } + atomic_init(&queue->call_gate, 0); if (pthread_mutex_init(&queue->mutex, NULL) != 0) { free(queue); return NULL; @@ -291,37 +627,47 @@ lardon3d_task_queue_cancel(Lardon3DTaskQueue *queue, uint64_t task_id) if (!queue || task_id == 0) { return false; } - (void)pthread_mutex_lock(&queue->mutex); - TaskNode *node = queue->all_head; - while (node && lardon3d_task_id(node->task) != task_id) { - node = node->next_all; + if (!begin_call(queue)) { + return false; } - if (node) { - Lardon3DTaskSnapshot task_snapshot; - bool was_pending = lardon3d_task_snapshot(node->task, &task_snapshot) - && task_snapshot.state != TASK_RUNNING - && task_snapshot.state != TASK_PAUSED; - lardon3d_task_request_cancel(node->task); - if (was_pending && queue->pending_count == queue->capacity) { - (void)pthread_cond_signal(&queue->not_full); - } - (void)pthread_cond_broadcast(&queue->not_empty); + TaskNode *node = find_live_locked(queue, task_id); + if (!node) { + bool retained = find_history_locked(queue, task_id, NULL); + end_call_locked(queue); + return retained; } + /* request_cancel may synchronously execute the finished callback. Pin the + * node, release Queue mutex for that potentially blocking durability work, + * then retire only after the callback has returned. */ + ++node->control_users; (void)pthread_mutex_unlock(&queue->mutex); - return node != NULL; + lardon3d_task_request_cancel(node->task); + (void)pthread_mutex_lock(&queue->mutex); + --node->control_users; + TaskNode *retired = retire_terminal_locked(queue, node, true); + (void)pthread_cond_broadcast(&queue->not_empty); + if (retired) { + /* Keep this public call registered while destruction runs unlocked, so + * Queue destroy cannot free the Queue before this caller and its + * userdata destructor have fully returned. */ + (void)pthread_mutex_unlock(&queue->mutex); + destroy_retired(retired); + (void)pthread_mutex_lock(&queue->mutex); + } + end_call_locked(queue); + return true; } bool lardon3d_task_queue_pause(Lardon3DTaskQueue *queue, uint64_t task_id) { if (!queue || task_id == 0) return false; - (void)pthread_mutex_lock(&queue->mutex); - TaskNode *node = queue->all_head; - while (node && lardon3d_task_id(node->task) != task_id) { - node = node->next_all; + if (!begin_call(queue)) { + return false; } + TaskNode *node = find_live_locked(queue, task_id); bool paused = node && lardon3d_task_pause(node->task); - (void)pthread_mutex_unlock(&queue->mutex); + end_call_locked(queue); return paused; } @@ -329,14 +675,13 @@ bool lardon3d_task_queue_resume(Lardon3DTaskQueue *queue, uint64_t task_id) { if (!queue || task_id == 0) return false; - (void)pthread_mutex_lock(&queue->mutex); - TaskNode *node = queue->all_head; - while (node && lardon3d_task_id(node->task) != task_id) { - node = node->next_all; + if (!begin_call(queue)) { + return false; } + TaskNode *node = find_live_locked(queue, task_id); bool resumed = node && lardon3d_task_resume(node->task); if (resumed) (void)pthread_cond_broadcast(&queue->not_empty); - (void)pthread_mutex_unlock(&queue->mutex); + end_call_locked(queue); return resumed; } @@ -346,9 +691,11 @@ lardon3d_task_queue_resources_changed(Lardon3DTaskQueue *queue) if (!queue) { return; } - (void)pthread_mutex_lock(&queue->mutex); + if (!begin_call(queue)) { + return; + } (void)pthread_cond_broadcast(&queue->not_empty); - (void)pthread_mutex_unlock(&queue->mutex); + end_call_locked(queue); } void @@ -357,27 +704,59 @@ lardon3d_task_queue_destroy(Lardon3DTaskQueue *queue) if (!queue) { return; } + /* Closing is monotonic and shares the registration modification order. + * The application must already have prevented new API invocations; this + * gate resolves only calls whose registration races with this close. */ + size_t previous_gate = atomic_fetch_or_explicit( + &queue->call_gate, + LARDON3D_TASK_QUEUE_CALL_GATE_CLOSING, + memory_order_acq_rel + ); + if ((previous_gate & LARDON3D_TASK_QUEUE_CALL_GATE_CLOSING) != 0) { + /* Concurrent/repeated destruction is outside the raw-pointer lifetime + * contract. If it overlaps while memory is still live, only the call + * that linearized CLOSING owns cancellation and physical destruction. */ + return; + } (void)pthread_mutex_lock(&queue->mutex); queue->stopping = true; + (void)pthread_cond_broadcast(&queue->not_empty); + (void)pthread_cond_broadcast(&queue->not_full); +#if defined(LARDON3D_TASK_QUEUE_TESTING) + notify_internal_test_event(queue, LARDON3D_TASK_QUEUE_TEST_CLOSING); +#endif + while (call_gate_references(atomic_load_explicit( + &queue->call_gate, memory_order_acquire)) != 0 + || queue->active_producers > 0) { + (void)pthread_cond_wait(&queue->not_empty, &queue->mutex); + } + /* Pin every remaining node while cancellation runs without Queue mutex. + * Pending cancellation may execute a durable finished callback; active + * execution may finish concurrently, but the worker cannot retire a pin. */ + for (TaskNode *node = queue->all_head; node; node = node->next_all) { + ++node->control_users; + } + (void)pthread_mutex_unlock(&queue->mutex); for (TaskNode *node = queue->all_head; node; node = node->next_all) { lardon3d_task_request_cancel(node->task); } - (void)pthread_cond_broadcast(&queue->not_empty); - (void)pthread_cond_broadcast(&queue->not_full); - while (queue->active_producers > 0) { - (void)pthread_cond_wait(&queue->not_empty, &queue->mutex); + (void)pthread_mutex_lock(&queue->mutex); + for (TaskNode *node = queue->all_head; node; node = node->next_all) { + --node->control_users; } (void)pthread_mutex_unlock(&queue->mutex); if (queue->worker_started) { (void)pthread_join(queue->worker, NULL); } + (void)pthread_mutex_lock(&queue->mutex); TaskNode *node = queue->all_head; - while (node) { - TaskNode *next = node->next_all; - lardon3d_task_destroy(node->task); - free(node); - node = next; - } + queue->all_head = NULL; + queue->all_tail = NULL; + queue->pending_head = NULL; + queue->pending_tail = NULL; + queue->pending_count = 0; + (void)pthread_mutex_unlock(&queue->mutex); + destroy_retired(node); (void)pthread_cond_destroy(&queue->not_full); (void)pthread_cond_destroy(&queue->not_empty); (void)pthread_mutex_destroy(&queue->mutex); @@ -403,16 +782,29 @@ enqueue_locked( return false; } } + if (id != 0 && find_history_locked(queue, id, NULL)) { + return false; + } if (id == 0) { - if (queue->next_id == 0 + if (queue->generated_ids_exhausted || !lardon3d_task_assign_id(task, queue->next_id)) { return false; } - id = queue->next_id++; - } else if (id >= queue->next_id) { - queue->next_id = id == UINT64_MAX ? 0 : id + 1; + id = queue->next_id; + if (id == UINT64_MAX) { + queue->generated_ids_exhausted = true; + } else { + queue->next_id = id + 1; + } + } else if (!queue->generated_ids_exhausted && id >= queue->next_id) { + if (id == UINT64_MAX) { + queue->generated_ids_exhausted = true; + } else { + queue->next_id = id + 1; + } } node->task = task; + node->previous_all = queue->all_tail; if (queue->all_tail) { queue->all_tail->next_all = node; } else { @@ -447,15 +839,22 @@ lardon3d_task_queue_add( if (!node) { return false; } - (void)pthread_mutex_lock(&queue->mutex); + if (!begin_call(queue)) { + free(node); + return false; + } ++queue->active_producers; while (!queue->stopping && queue->pending_count >= queue->capacity) { +#if defined(LARDON3D_TASK_QUEUE_TESTING) + notify_internal_test_event( + queue, LARDON3D_TASK_QUEUE_TEST_PRODUCER_WAITING); +#endif (void)pthread_cond_wait(&queue->not_full, &queue->mutex); } --queue->active_producers; (void)pthread_cond_broadcast(&queue->not_empty); bool accepted = enqueue_locked(queue, node, task, task_id); - (void)pthread_mutex_unlock(&queue->mutex); + end_call_locked(queue); if (!accepted) { free(node); } @@ -487,7 +886,10 @@ lardon3d_task_queue_try_add_ex( if (!node) { return LARDON3D_TASK_QUEUE_ADD_ERROR; } - (void)pthread_mutex_lock(&queue->mutex); + if (!begin_call(queue)) { + free(node); + return LARDON3D_TASK_QUEUE_ADD_STOPPING; + } Lardon3DTaskQueueAddResult result = LARDON3D_TASK_QUEUE_ADD_OK; if (queue->stopping) { result = LARDON3D_TASK_QUEUE_ADD_STOPPING; @@ -502,12 +904,16 @@ lardon3d_task_queue_try_add_ex( break; } } + if (result == LARDON3D_TASK_QUEUE_ADD_OK && id != 0 + && find_history_locked(queue, id, NULL)) { + result = LARDON3D_TASK_QUEUE_ADD_DUPLICATE_ID; + } if (result == LARDON3D_TASK_QUEUE_ADD_OK && !enqueue_locked(queue, node, task, task_id)) { result = LARDON3D_TASK_QUEUE_ADD_ERROR; } } - (void)pthread_mutex_unlock(&queue->mutex); + end_call_locked(queue); if (result != LARDON3D_TASK_QUEUE_ADD_OK) { free(node); } @@ -520,43 +926,47 @@ lardon3d_task_queue_remove(Lardon3DTaskQueue *queue, uint64_t task_id) if (!queue || task_id == 0) { return false; } - (void)pthread_mutex_lock(&queue->mutex); - TaskNode *previous = NULL; - TaskNode *node = queue->all_head; - while (node && lardon3d_task_id(node->task) != task_id) { - previous = node; - node = node->next_all; - } - Lardon3DTaskSnapshot snapshot; - if (!node || !lardon3d_task_snapshot(node->task, &snapshot) - || !terminal_state(snapshot.state)) { - (void)pthread_mutex_unlock(&queue->mutex); + if (!begin_call(queue)) { return false; } - while (node->task == queue->active) { - (void)pthread_cond_wait(&queue->not_empty, &queue->mutex); + TaskNode *retired = NULL; + for (;;) { + TaskNode *node = find_live_locked(queue, task_id); + if (!node) { + size_t history_index; + bool retained = find_history_locked(queue, task_id, &history_index); + if (retained) { + remove_history_locked(queue, history_index); + } + end_call_locked(queue); + return retained; + } + Lardon3DTaskSnapshot snapshot; + if (!lardon3d_task_snapshot(node->task, &snapshot) + || !terminal_state(snapshot.state)) { + end_call_locked(queue); + return false; + } + if (node == queue->active || node->control_users != 0) { + (void)pthread_cond_wait(&queue->not_empty, &queue->mutex); + if (queue->stopping) { + end_call_locked(queue); + return false; + } + continue; + } + /* Explicit removal still contributes to lifetime summary, but skips + * insertion into recent history. */ + retired = retire_terminal_locked(queue, node, false); + bool removed = retired != NULL; + if (retired) { + (void)pthread_mutex_unlock(&queue->mutex); + destroy_retired(retired); + (void)pthread_mutex_lock(&queue->mutex); + } + end_call_locked(queue); + return removed; } - if (previous) { - previous->next_all = node->next_all; - } else { - queue->all_head = node->next_all; - } - if (queue->all_tail == node) { - queue->all_tail = previous; - } - TaskNode *pending_previous = NULL; - TaskNode *pending = queue->pending_head; - while (pending && pending != node) { - pending_previous = pending; - pending = pending->next_pending; - } - if (pending) { - unlink_pending(queue, pending_previous, pending); - } - (void)pthread_mutex_unlock(&queue->mutex); - lardon3d_task_destroy(node->task); - free(node); - return true; } size_t @@ -565,9 +975,11 @@ lardon3d_task_queue_count(Lardon3DTaskQueue *queue) if (!queue) { return 0; } - (void)pthread_mutex_lock(&queue->mutex); + if (!begin_call(queue)) { + return 0; + } size_t count = queue->pending_count; - (void)pthread_mutex_unlock(&queue->mutex); + end_call_locked(queue); return count; } @@ -581,13 +993,48 @@ lardon3d_task_queue_get( if (!queue || task_id == 0 || !snapshot) { return false; } - (void)pthread_mutex_lock(&queue->mutex); - TaskNode *node = queue->all_head; - while (node && lardon3d_task_id(node->task) != task_id) { - node = node->next_all; + if (!begin_call(queue)) { + return false; } + TaskNode *node = find_live_locked(queue, task_id); bool found = node && lardon3d_task_snapshot(node->task, snapshot); - (void)pthread_mutex_unlock(&queue->mutex); + if (!found) { + size_t history_index; + found = find_history_locked(queue, task_id, &history_index); + if (found) { + observation_to_snapshot(&queue->history[history_index], snapshot); + } + } + end_call_locked(queue); + return found; +} + +bool +lardon3d_task_queue_get_observation( + Lardon3DTaskQueue *queue, + uint64_t task_id, + Lardon3DTaskObservation *observation +) +{ + if (observation) { + *observation = (Lardon3DTaskObservation) {0}; + } + if (!queue || task_id == 0 || !observation) { + return false; + } + if (!begin_call(queue)) { + return false; + } + TaskNode *node = find_live_locked(queue, task_id); + bool found = node && lardon3d_task_observation(node->task, observation); + if (!found) { + size_t history_index; + found = find_history_locked(queue, task_id, &history_index); + if (found) { + *observation = queue->history[history_index]; + } + } + end_call_locked(queue); return found; } @@ -601,14 +1048,21 @@ lardon3d_task_queue_get_at( if (!queue || !snapshot) { return false; } - (void)pthread_mutex_lock(&queue->mutex); - TaskNode *node = queue->all_head; + if (!begin_call(queue)) { + return false; + } + TaskNode *node = queue->all_tail; while (node && index > 0) { - node = node->next_all; + node = node->previous_all; --index; } bool found = node && lardon3d_task_snapshot(node->task, snapshot); - (void)pthread_mutex_unlock(&queue->mutex); + if (!node && index < queue->history_count) { + observation_to_snapshot( + &queue->history[queue->history_count - index - 1], snapshot); + found = true; + } + end_call_locked(queue); return found; } @@ -626,27 +1080,109 @@ lardon3d_task_queue_snapshot( if (!queue || (!snapshots && capacity > 0)) { return 0; } - (void)pthread_mutex_lock(&queue->mutex); + if (!begin_call(queue)) { + return 0; + } size_t copied = 0; - for (TaskNode *node = queue->all_head; node; node = node->next_all) { + for (TaskNode *node = queue->all_tail; node; node = node->previous_all) { Lardon3DTaskSnapshot snapshot; if (!lardon3d_task_snapshot(node->task, &snapshot)) { continue; } - if (summary) { - ++summary->total; - if (snapshot.state == TASK_RUNNING || snapshot.state == TASK_PAUSED) { - ++summary->running; - } else if (snapshot.state == TASK_PENDING) { - ++summary->pending; - } else { - ++summary->completed; - } - } if (copied < capacity) { snapshots[copied++] = snapshot; } } - (void)pthread_mutex_unlock(&queue->mutex); + for (size_t offset = 0; offset < queue->history_count; ++offset) { + if (copied < capacity) { + snapshots[copied++] = + (Lardon3DTaskSnapshot) {0}; + observation_to_snapshot( + &queue->history[queue->history_count - offset - 1], + &snapshots[copied - 1]); + } + } + if (summary) { + size_t live_terminal = 0; + for (TaskNode *node = queue->all_head; node; node = node->next_all) { + Lardon3DTaskSnapshot snapshot; + if (!lardon3d_task_snapshot(node->task, &snapshot)) { + continue; + } + if (snapshot.state == TASK_RUNNING || snapshot.state == TASK_PAUSED) { + summary->running = saturating_add(summary->running, 1); + } else if (snapshot.state == TASK_PENDING) { + summary->pending = saturating_add(summary->pending, 1); + } else { + live_terminal = saturating_add(live_terminal, 1); + } + } + summary->completed = saturating_add( + queue->terminal_count, live_terminal); + summary->total = saturating_add( + summary->completed, + saturating_add(summary->running, summary->pending) + ); + } + end_call_locked(queue); + return copied; +} + +size_t +lardon3d_task_queue_observe( + Lardon3DTaskQueue *queue, + Lardon3DTaskObservation *observations, + size_t capacity, + Lardon3DTaskQueueSummary *summary +) +{ + if (summary) { + *summary = (Lardon3DTaskQueueSummary) {0}; + } + if (!queue || (!observations && capacity > 0)) { + return 0; + } + if (!begin_call(queue)) { + return 0; + } + size_t copied = 0; + for (TaskNode *node = queue->all_tail; node; node = node->previous_all) { + Lardon3DTaskObservation observation; + if (!lardon3d_task_observation(node->task, &observation)) { + continue; + } + if (copied < capacity) { + observations[copied++] = observation; + } + } + for (size_t offset = 0; offset < queue->history_count; ++offset) { + if (copied < capacity) { + observations[copied++] = + queue->history[queue->history_count - offset - 1]; + } + } + if (summary) { + size_t live_terminal = 0; + for (TaskNode *node = queue->all_head; node; node = node->next_all) { + Lardon3DTaskSnapshot snapshot; + if (!lardon3d_task_snapshot(node->task, &snapshot)) { + continue; + } + if (snapshot.state == TASK_RUNNING || snapshot.state == TASK_PAUSED) { + summary->running = saturating_add(summary->running, 1); + } else if (snapshot.state == TASK_PENDING) { + summary->pending = saturating_add(summary->pending, 1); + } else { + live_terminal = saturating_add(live_terminal, 1); + } + } + summary->completed = saturating_add( + queue->terminal_count, live_terminal); + summary->total = saturating_add( + summary->completed, + saturating_add(summary->running, summary->pending) + ); + } + end_call_locked(queue); return copied; } diff --git a/src/task_queue_internal.h b/src/task_queue_internal.h new file mode 100644 index 0000000..bf2954a --- /dev/null +++ b/src/task_queue_internal.h @@ -0,0 +1,35 @@ +#ifndef LARDON3D_TASK_QUEUE_INTERNAL_H +#define LARDON3D_TASK_QUEUE_INTERNAL_H + +#include + +#if defined(LARDON3D_TASK_QUEUE_TESTING) + +/* TEST CONTRACT: these events expose exact Queue lifetime linearization points + * only to test-task-queue and test-resource-external-storage. Production must + * not declare, reference or call this seam. Both test binaries provide one + * strong callback definition; task_queue.c keeps a weak reference so the seam + * cannot become a required library/public ABI symbol. */ +typedef enum { + LARDON3D_TASK_QUEUE_TEST_CALL_REGISTERED = 1, + LARDON3D_TASK_QUEUE_TEST_PRODUCER_WAITING = 2, + LARDON3D_TASK_QUEUE_TEST_CLOSING = 3, +} Lardon3DTaskQueueTestEvent; + +#if defined(LARDON3D_TASK_QUEUE_TEST_WEAK_REFERENCE) \ + && (defined(__GNUC__) || defined(__clang__)) +#define LARDON3D_TASK_QUEUE_TEST_ATTRIBUTE __attribute__((weak)) +#else +#define LARDON3D_TASK_QUEUE_TEST_ATTRIBUTE +#endif + +void lardon3d_task_queue_internal_test_event( + Lardon3DTaskQueue *queue, + Lardon3DTaskQueueTestEvent event +) LARDON3D_TASK_QUEUE_TEST_ATTRIBUTE; + +#undef LARDON3D_TASK_QUEUE_TEST_ATTRIBUTE + +#endif + +#endif diff --git a/src/tui.c b/src/tui.c index 9d44ecd..9f04176 100644 --- a/src/tui.c +++ b/src/tui.c @@ -1,8 +1,10 @@ #include +#include #include #include #include #include +#include #include #include @@ -10,15 +12,16 @@ #include #include #include -#include -#include -#include +#include +#include #include +#include +#include +#include enum { - MINIMUM_ROWS = 20, - MINIMUM_COLUMNS = 72, - DISPLAYED_TASK_CAPACITY = 64, + MINIMUM_ROWS = 15, + MINIMUM_COLUMNS = 60, }; typedef enum { @@ -27,6 +30,13 @@ typedef enum { INPUT_PROJECT_OPEN, INPUT_IMPORT_DIRECTORY, INPUT_IMAGE_FILTER, + INPUT_OPTICS_CREATE_BODY, + INPUT_OPTICS_CREATE_LENS, + INPUT_OPTICS_CREATE_CONFIGURATION, + INPUT_OPTICS_INSPECT_CAPTURE, + INPUT_OPTICS_ASSIGN_CAPTURE, + INPUT_OPTICS_ASSIGN_GROUP, + INPUT_OPTICS_LOOKUP_METADATA, } InputMode; typedef struct { @@ -35,6 +45,20 @@ typedef struct { size_t length; } TuiInput; +typedef struct { + Lardon3DRuntimeObserver *observer; + Lardon3DRuntimeSnapshot observation; + Lardon3DTuiSsdAsync *ssd_operation; + Lardon3DTuiSsdAsyncSnapshot ssd_operation_snapshot; + Lardon3DTuiOptics *optics; + Lardon3DTuiOpticsSnapshot optics_snapshot; + Lardon3DProjectDb *optics_database; + bool optics_retry_requested; + Lardon3DTuiPalette palette; + size_t selected_task; + bool fatal_error; +} TuiRuntime; + static size_t catalog_page_size(void) { @@ -149,75 +173,420 @@ reload_catalog(Lardon3DAppState *state, bool announce_success) return true; } +static void +sync_optics(Lardon3DAppState *state, TuiRuntime *runtime) +{ + if (runtime->optics_database != state->project_db + || runtime->optics_retry_requested) { + lardon3d_tui_optics_unbind(runtime->optics); + runtime->optics_database = state->project_db; + runtime->optics_retry_requested = false; + if (state->project_db) { + (void)lardon3d_tui_optics_bind( + runtime->optics, state->project_db); + } + } + (void)lardon3d_tui_optics_snapshot( + runtime->optics, &runtime->optics_snapshot); +} + +static bool +reset_project_session(Lardon3DAppState *state, TuiRuntime *runtime) +{ + /* Observers borrow Queue/DB owners and must disappear before the exact + * Queue-finished-callback -> DB-close boundary executes. */ + lardon3d_tui_optics_unbind(runtime->optics); + runtime->optics_database = NULL; + runtime->optics_retry_requested = false; + lardon3d_runtime_observer_destroy(runtime->observer); + runtime->observer = NULL; + if (!lardon3d_runtime_project_boundary( + state, LARDON3D_TASK_QUEUE_PRODUCTION_CAPACITY)) { + runtime->fatal_error = true; + state->running = false; + return false; + } + runtime->observer = lardon3d_runtime_observer_create( + &state->hardware_profile, state->task_queue, + state->resource_governor); + if (!runtime->observer) { + (void)snprintf(state->status_message, + sizeof(state->status_message), + "Erreur : impossible de recréer l'observateur runtime."); + runtime->fatal_error = true; + state->running = false; + return false; + } + runtime->observation = (Lardon3DRuntimeSnapshot) {0}; + runtime->selected_task = 0; + return true; +} + +static const char * +input_label(InputMode mode) +{ + switch (mode) { + case INPUT_PROJECT_OPEN: + return "Nom du dossier projet :"; + case INPUT_IMPORT_DIRECTORY: + return "Dossier source :"; + case INPUT_IMAGE_FILTER: + return "Filtre :"; + case INPUT_OPTICS_CREATE_BODY: + return "Body immutable: manufacturer|model|name"; + case INPUT_OPTICS_CREATE_LENS: + return "Lens: interface|range|min_mm|max_mm|maker|model|name"; + case INPUT_OPTICS_CREATE_CONFIGURATION: + return "Focale entière mm, ou ? pour absence explicite"; + case INPUT_OPTICS_INSPECT_CAPTURE: + return "Capture ID à inspecter :"; + case INPUT_OPTICS_ASSIGN_CAPTURE: + return "Capture ID à assigner :"; + case INPUT_OPTICS_ASSIGN_GROUP: + return "Campaign Task ID:group ID :"; + case INPUT_OPTICS_LOOKUP_METADATA: + return "make|model|lens_make|lens_model (exact; lens vide permis)"; + case INPUT_PROJECT_CREATE: + case INPUT_NONE: + default: + return "Nom du nouveau projet :"; + } +} + +static void +merge_ssd_observation( + Lardon3DRuntimeSnapshot *observation, + const Lardon3DTuiSsdAsyncSnapshot *ssd +) +{ + if (!observation || !ssd || !ssd->controller_snapshot_known) { + return; + } + observation->ssd_controller_available = true; + observation->ssd = ssd->controller_snapshot; + /* Physical identity/actions stay on the SSD panel. Governor usage is + * captured separately by RuntimeObserver from the registered copy and is + * never overwritten by an independently timed controller observation. */ +} + static void redraw( - const Lardon3DAppState *state, + Lardon3DAppState *state, const TuiInput *input, - Lardon3DImportTask *task + Lardon3DImportTask *task, + TuiRuntime *runtime ) { int rows; int columns; getmaxyx(stdscr, rows, columns); - - const char *text = input->mode != INPUT_NONE ? input->text : NULL; - const char *label = "Nom du nouveau projet :"; - if (input->mode == INPUT_PROJECT_OPEN) { - label = "Nom du dossier projet :"; - } else if (input->mode == INPUT_IMPORT_DIRECTORY) { - label = "Dossier source :"; - } else if (input->mode == INPUT_IMAGE_FILTER) { - label = "Filtre :"; + (void)lardon3d_runtime_observer_refresh( + runtime->observer, state->project_loaded, false, + &runtime->observation); + if (runtime->selected_task >= runtime->observation.task_count) { + runtime->selected_task = runtime->observation.task_count > 0 + ? runtime->observation.task_count - 1 : 0; } + if (runtime->ssd_operation) { + /* This schedules at most one coalesced worker call; no controller or + * D-Bus work executes on the ncurses thread. */ + (void)lardon3d_tui_ssd_async_refresh(runtime->ssd_operation); + (void)lardon3d_tui_ssd_async_poll( + runtime->ssd_operation, &runtime->ssd_operation_snapshot); + merge_ssd_observation( + &runtime->observation, &runtime->ssd_operation_snapshot); + } else { + runtime->ssd_operation_snapshot = + (Lardon3DTuiSsdAsyncSnapshot) {0}; + } + sync_optics(state, runtime); + Lardon3DImportTaskSnapshot snapshot; const Lardon3DImportTaskSnapshot *displayed_snapshot = NULL; if (task && lardon3d_import_task_snapshot(task, &snapshot)) { displayed_snapshot = &snapshot; } - Lardon3DTaskSnapshot task_snapshots[DISPLAYED_TASK_CAPACITY]; - Lardon3DTaskQueueSummary task_summary; - size_t task_count = lardon3d_task_queue_snapshot( - state->task_queue, - task_snapshots, - DISPLAYED_TASK_CAPACITY, - &task_summary - ); - Lardon3DResourceSnapshot resource_snapshot; - Lardon3DResourceAvailability resource_availability; - const Lardon3DResourceAvailability *displayed_resources = NULL; - if (state->screen == LARDON3D_SCREEN_RESOURCES - && lardon3d_resource_snapshot_capture( - &state->hardware_profile, - &resource_snapshot, - NULL, - 0 - ) - && lardon3d_resource_governor_availability( - state->resource_governor, - &resource_snapshot, - &resource_availability - )) { - displayed_resources = &resource_availability; - } - lardon3d_layout_draw( + lardon3d_layout_draw_runtime( state, - text, - label, + input->mode != INPUT_NONE ? input->text : NULL, + input_label(input->mode), displayed_snapshot, - task_snapshots, - task_count, - &task_summary, - displayed_resources, + &runtime->observation, + &runtime->ssd_operation_snapshot, + &runtime->optics_snapshot, + runtime->selected_task, + &runtime->palette, + input->mode != INPUT_NONE + ? LARDON3D_TUI_INTERACTION_TEXT_INPUT + : (task ? LARDON3D_TUI_INTERACTION_IMPORT_RUNNING + : LARDON3D_TUI_INTERACTION_IDLE), rows, columns ); (void)curs_set( input->mode != INPUT_NONE - && rows >= MINIMUM_ROWS - && columns >= MINIMUM_COLUMNS - ? 1 - : 0 - ); + && rows >= MINIMUM_ROWS && columns >= MINIMUM_COLUMNS ? 1 : 0); +} + +static bool +is_optics_input(InputMode mode) +{ + return mode >= INPUT_OPTICS_CREATE_BODY + && mode <= INPUT_OPTICS_LOOKUP_METADATA; +} + +static bool +parse_positive_u64(const char *text, uint64_t maximum, uint64_t *value) +{ + if (value) { + *value = 0; + } + if (!text || !text[0] || text[0] == '-' || !value || maximum == 0) { + return false; + } + char *end = NULL; + errno = 0; + unsigned long long parsed = strtoull(text, &end, 10); + if (errno != 0 || !end || *end != '\0' || parsed == 0 + || parsed > maximum) { + return false; + } + *value = (uint64_t)parsed; + return true; +} + +static bool +parse_nonnegative_u32(const char *text, uint32_t *value) +{ + if (value) { + *value = 0; + } + if (!text || !text[0] || text[0] == '-' || !value) { + return false; + } + char *end = NULL; + errno = 0; + unsigned long long parsed = strtoull(text, &end, 10); + if (errno != 0 || !end || *end != '\0' || parsed > UINT32_MAX) { + return false; + } + *value = (uint32_t)parsed; + return true; +} + +static bool +parse_focal_mm(const char *text, uint32_t *micrometres) +{ + uint32_t millimetres; + if (!parse_nonnegative_u32(text, &millimetres) || millimetres == 0 + || millimetres > UINT32_MAX / 1000U) { + return false; + } + *micrometres = millimetres * 1000U; + return true; +} + +static bool +split_exact_fields(char *text, char **fields, size_t expected_count) +{ + if (!text || !fields || expected_count == 0) { + return false; + } + size_t count = 1; + fields[0] = text; + for (char *character = text; *character; ++character) { + if (*character != '|') { + continue; + } + if (count >= expected_count) { + return false; + } + *character = '\0'; + fields[count++] = character + 1; + } + return count == expected_count; +} + +static bool +copy_optical_text( + char destination[LARDON3D_OPTICAL_TEXT_CAPACITY], + const char *source +) +{ + int written = snprintf(destination, LARDON3D_OPTICAL_TEXT_CAPACITY, + "%s", source ? source : ""); + return written >= 0 && written < LARDON3D_OPTICAL_TEXT_CAPACITY; +} + +static bool +parse_lens_interface( + const char *text, + Lardon3DOpticalLensInterface *interface_kind +) +{ + if (strcmp(text, "manual") == 0) { + *interface_kind = LARDON3D_OPTICAL_LENS_MANUAL; + } else if (strcmp(text, "electronic") == 0) { + *interface_kind = LARDON3D_OPTICAL_LENS_ELECTRONIC; + } else if (strcmp(text, "integrated") == 0) { + *interface_kind = LARDON3D_OPTICAL_LENS_INTEGRATED; + } else { + return false; + } + return true; +} + +static bool +parse_focal_range( + const char *text, + Lardon3DOpticalFocalRangeKind *range_kind +) +{ + if (strcmp(text, "unknown") == 0) { + *range_kind = LARDON3D_OPTICAL_FOCAL_RANGE_UNKNOWN; + } else if (strcmp(text, "prime") == 0) { + *range_kind = LARDON3D_OPTICAL_FOCAL_RANGE_PRIME; + } else if (strcmp(text, "zoom") == 0) { + *range_kind = LARDON3D_OPTICAL_FOCAL_RANGE_ZOOM; + } else { + return false; + } + return true; +} + +static void +copy_optics_message(Lardon3DAppState *state, TuiRuntime *runtime) +{ + (void)lardon3d_tui_optics_snapshot( + runtime->optics, &runtime->optics_snapshot); + (void)snprintf(state->status_message, sizeof(state->status_message), + "%s", runtime->optics_snapshot.message); +} + +static bool +complete_optics_input( + Lardon3DAppState *state, + TuiInput *input, + TuiRuntime *runtime +) +{ + char copy[sizeof(input->text)]; + (void)snprintf(copy, sizeof(copy), "%s", input->text); + bool success = false; + bool model_called = false; + + if (!runtime->optics_database) { + (void)snprintf(state->status_message, sizeof(state->status_message), + "Aucun Project DB lié; aucune identité optique n'est devinée."); + input->mode = INPUT_NONE; + return false; + } + + if (input->mode == INPUT_OPTICS_CREATE_BODY) { + char *fields[3]; + Lardon3DOpticalCameraBodyProfile body = {0}; + if (split_exact_fields(copy, fields, 3) + && copy_optical_text(body.manufacturer, fields[0]) + && copy_optical_text(body.model, fields[1]) + && copy_optical_text(body.name, fields[2])) { + model_called = true; + success = lardon3d_tui_optics_create_body( + runtime->optics, &body); + } else { + (void)snprintf(state->status_message, + sizeof(state->status_message), + "Format body invalide: manufacturer|model|name."); + } + } else if (input->mode == INPUT_OPTICS_CREATE_LENS) { + char *fields[7]; + Lardon3DOpticalLensProfile lens = {0}; + uint32_t minimum_mm; + uint32_t maximum_mm; + bool parsed = split_exact_fields(copy, fields, 7) + && parse_lens_interface(fields[0], &lens.interface_kind) + && parse_focal_range(fields[1], &lens.focal_range_kind) + && parse_nonnegative_u32(fields[2], &minimum_mm) + && parse_nonnegative_u32(fields[3], &maximum_mm) + && minimum_mm <= UINT32_MAX / 1000U + && maximum_mm <= UINT32_MAX / 1000U + && copy_optical_text(lens.manufacturer, fields[4]) + && copy_optical_text(lens.model, fields[5]) + && copy_optical_text(lens.name, fields[6]); + if (parsed) { + lens.minimum_focal_um = minimum_mm * 1000U; + lens.maximum_focal_um = maximum_mm * 1000U; + model_called = true; + success = lardon3d_tui_optics_create_lens( + runtime->optics, &lens); + } else { + (void)snprintf(state->status_message, + sizeof(state->status_message), + "Format lens invalide; interface manual/electronic/integrated, range unknown/prime/zoom."); + } + } else if (input->mode == INPUT_OPTICS_CREATE_CONFIGURATION) { + bool has_focal = strcmp(copy, "?") != 0; + uint32_t focal_um = 0; + if (!has_focal || parse_focal_mm(copy, &focal_um)) { + model_called = true; + success = lardon3d_tui_optics_create_configuration( + runtime->optics, has_focal, focal_um); + } else { + (void)snprintf(state->status_message, + sizeof(state->status_message), + "Focale invalide: entier positif en millimètres ou ?."); + } + } else if (input->mode == INPUT_OPTICS_INSPECT_CAPTURE + || input->mode == INPUT_OPTICS_ASSIGN_CAPTURE) { + uint64_t capture_id; + if (parse_positive_u64(copy, INT64_MAX, &capture_id)) { + model_called = true; + success = input->mode == INPUT_OPTICS_INSPECT_CAPTURE + ? lardon3d_tui_optics_inspect_capture( + runtime->optics, capture_id) + : lardon3d_tui_optics_assign_capture( + runtime->optics, capture_id); + } else { + (void)snprintf(state->status_message, + sizeof(state->status_message), + "Capture ID invalide (SQLite INTEGER positif requis)."); + } + } else if (input->mode == INPUT_OPTICS_ASSIGN_GROUP) { + char *separator = strchr(copy, ':'); + uint64_t campaign_id; + uint64_t group_id; + if (separator && !strchr(separator + 1, ':')) { + *separator = '\0'; + } + if (separator + && parse_positive_u64(copy, INT64_MAX, &campaign_id) + && parse_positive_u64(separator + 1, UINT32_MAX, &group_id)) { + model_called = true; + success = lardon3d_tui_optics_assign_campaign_group( + runtime->optics, campaign_id, (uint32_t)group_id); + } else { + (void)snprintf(state->status_message, + sizeof(state->status_message), + "Format campagne invalide: TaskID:groupID avec IDs positifs."); + } + } else if (input->mode == INPUT_OPTICS_LOOKUP_METADATA) { + char *fields[4]; + if (split_exact_fields(copy, fields, 4)) { + model_called = true; + success = lardon3d_tui_optics_lookup_exact_metadata( + runtime->optics, fields[0], fields[1], fields[2], fields[3]); + } else { + (void)snprintf(state->status_message, + sizeof(state->status_message), + "Format métadonnées invalide: make|model|lens_make|lens_model."); + } + } + if (model_called) { + copy_optics_message(state, runtime); + } + input->mode = INPUT_NONE; + input->text[0] = '\0'; + input->length = 0; + return success; } static bool @@ -257,17 +626,30 @@ start_import_task( return true; } +static bool handle_ssd_key( + Lardon3DAppState *state, + TuiRuntime *runtime +); + static bool handle_active_input( Lardon3DAppState *state, TuiInput *input, Lardon3DImportTask **task, + TuiRuntime *runtime, int key ) { if (key == KEY_RESIZE) { return true; } + if (key == KEY_F(10)) { + /* F10 remains reachable while a text field owns ordinary keystrokes. + * Discard the uncommitted field before switching screens so hidden + * input cannot later mutate a Project/optical workflow. */ + *input = (TuiInput) {0}; + return handle_ssd_key(state, runtime); + } if (key == 27) { const char *message = "Création du projet annulée."; @@ -277,6 +659,8 @@ handle_active_input( message = "Import annulé."; } else if (input->mode == INPUT_IMAGE_FILTER) { message = "Filtre annulé."; + } else if (is_optics_input(input->mode)) { + message = "Opération optique annulée."; } input->mode = INPUT_NONE; (void)snprintf( @@ -289,8 +673,11 @@ handle_active_input( } if (key == '\n' || key == '\r' || key == KEY_ENTER) { - if (input->mode == INPUT_PROJECT_OPEN) { - if (lardon3d_project_open(state, input->text)) { + if (is_optics_input(input->mode)) { + (void)complete_optics_input(state, input, runtime); + } else if (input->mode == INPUT_PROJECT_OPEN) { + if (reset_project_session(state, runtime) + && lardon3d_project_open(state, input->text)) { (void)reload_catalog(state, false); } } else if (input->mode == INPUT_IMPORT_DIRECTORY) { @@ -327,11 +714,14 @@ handle_active_input( ); } } else { - if (lardon3d_project_create(state, input->text)) { + if (reset_project_session(state, runtime) + && lardon3d_project_create(state, input->text)) { (void)reload_catalog(state, false); } } - input->mode = INPUT_NONE; + if (input->mode != INPUT_NONE) { + input->mode = INPUT_NONE; + } return true; } @@ -356,8 +746,20 @@ handle_active_input( ); return true; } + if (is_optics_input(input->mode)) { + (void)snprintf( + state->status_message, + sizeof(state->status_message), + "Erreur : saisie optique trop longue." + ); + input->mode = INPUT_NONE; + input->text[0] = '\0'; + input->length = 0; + return true; + } if (input->mode == INPUT_PROJECT_OPEN) { - if (lardon3d_project_open(state, input->text)) { + if (reset_project_session(state, runtime) + && lardon3d_project_open(state, input->text)) { (void)reload_catalog(state, false); } } else if (input->mode == INPUT_IMPORT_DIRECTORY) { @@ -367,7 +769,8 @@ handle_active_input( "Erreur : chemin source trop long." ); } else { - if (lardon3d_project_create(state, input->text)) { + if (reset_project_session(state, runtime) + && lardon3d_project_create(state, input->text)) { (void)reload_catalog(state, false); } } @@ -384,16 +787,181 @@ handle_active_input( return false; } +static void +begin_input(TuiInput *input, InputMode mode) +{ + *input = (TuiInput) { + .mode = mode, + .text = "", + .length = 0, + }; +} + +static bool +handle_ssd_key( + Lardon3DAppState *state, + TuiRuntime *runtime +) +{ + /* F10 is the persistent SSD control key, not merely navigation. Move to + * the state screen first, then act immediately only from the freshly + * polled, validated snapshot; UNKNOWN remains a no-action observation. */ + state->screen = LARDON3D_SCREEN_SSD; + if (!runtime->ssd_operation) { + (void)snprintf(state->status_message, + sizeof(state->status_message), + "Contrôleur SSD indisponible; état UNKNOWN, aucune action."); + return true; + } + (void)lardon3d_tui_ssd_async_poll( + runtime->ssd_operation, &runtime->ssd_operation_snapshot); + if (runtime->ssd_operation_snapshot.running) { + (void)snprintf(state->status_message, + sizeof(state->status_message), + "Opération SSD %s déjà en cours.", + lardon3d_tui_ssd_action_name( + runtime->ssd_operation_snapshot.action)); + return true; + } + if (!runtime->ssd_operation_snapshot.controller_snapshot_known + || !runtime->ssd_operation_snapshot.controller_snapshot_actionable) { + (void)snprintf(state->status_message, + sizeof(state->status_message), + "Télémétrie SSD indéterminée/invalide; contrôle désactivé."); + return true; + } + Lardon3DTuiSsdAction action; + const Lardon3DSsdSnapshot *current = + &runtime->ssd_operation_snapshot.controller_snapshot; + if (!lardon3d_tui_ssd_action_for_snapshot(current, &action)) { + (void)snprintf(state->status_message, + sizeof(state->status_message), + "Aucune transition SSD sûre depuis %s.", + lardon3d_ssd_state_name(current->state)); + return true; + } + if (!lardon3d_tui_ssd_async_request(runtime->ssd_operation, action)) { + (void)snprintf(state->status_message, + sizeof(state->status_message), + "Impossible de lancer l'opération SSD bornée."); + return true; + } + (void)snprintf(state->status_message, sizeof(state->status_message), + "SSD %s lancé hors du thread ncurses.", + lardon3d_tui_ssd_action_name(action)); + return true; +} + +static bool +handle_task_key( + Lardon3DAppState *state, + TuiRuntime *runtime, + int key +) +{ + if (key == KEY_UP || key == 'k') { + if (runtime->selected_task > 0) { + --runtime->selected_task; + } + return true; + } + if (key == KEY_DOWN || key == 'j') { + if (runtime->selected_task + 1 < runtime->observation.task_count) { + ++runtime->selected_task; + } + return true; + } + if (runtime->selected_task >= runtime->observation.task_count) { + return false; + } + uint64_t task_id = runtime->observation.tasks[ + runtime->selected_task].id; + bool result = false; + const char *operation = NULL; + if (key == 'p' || key == 'P') { + operation = "pause"; + result = lardon3d_task_queue_pause(state->task_queue, task_id); + } else if (key == 'r' || key == 'R') { + operation = "reprise"; + result = lardon3d_task_queue_resume(state->task_queue, task_id); + } else if (key == 'c' || key == 'C') { + operation = "annulation"; + result = lardon3d_task_queue_cancel(state->task_queue, task_id); + } + if (!operation) { + return false; + } + (void)snprintf(state->status_message, sizeof(state->status_message), + "Tâche #%llu: %s %s.", (unsigned long long)task_id, + operation, result ? "demandée" : "refusée/indisponible"); + return true; +} + +static bool +handle_optics_key( + Lardon3DAppState *state, + TuiInput *input, + TuiRuntime *runtime, + int key +) +{ + bool handled = true; + if (key == '\t') { + Lardon3DTuiOpticsPane next = (Lardon3DTuiOpticsPane)( + ((int)runtime->optics_snapshot.active_pane + 1) + % ((int)LARDON3D_TUI_OPTICS_PANE_CALIBRATION + 1)); + (void)lardon3d_tui_optics_select_pane(runtime->optics, next); + } else if (key == KEY_UP || key == 'k') { + (void)lardon3d_tui_optics_move_selection(runtime->optics, -1); + } else if (key == KEY_DOWN || key == 'j') { + (void)lardon3d_tui_optics_move_selection(runtime->optics, 1); + } else if (key == '[' || key == ']') { + (void)lardon3d_tui_optics_page(runtime->optics, + runtime->optics_snapshot.active_pane, key == ']'); + } else if (key == 'B') { + begin_input(input, INPUT_OPTICS_CREATE_BODY); + } else if (key == 'L') { + begin_input(input, INPUT_OPTICS_CREATE_LENS); + } else if (key == 'C') { + begin_input(input, INPUT_OPTICS_CREATE_CONFIGURATION); + } else if (key == 'V') { + begin_input(input, INPUT_OPTICS_INSPECT_CAPTURE); + } else if (key == 'A') { + begin_input(input, INPUT_OPTICS_ASSIGN_CAPTURE); + } else if (key == 'G') { + begin_input(input, INPUT_OPTICS_ASSIGN_GROUP); + } else if (key == 'E') { + begin_input(input, INPUT_OPTICS_LOOKUP_METADATA); + } else if (key == 'K') { + (void)lardon3d_tui_optics_select_capture_calibration( + runtime->optics); + copy_optics_message(state, runtime); + } else if (key == 'R') { + runtime->optics_retry_requested = true; + sync_optics(state, runtime); + copy_optics_message(state, runtime); + } else { + handled = false; + } + (void)lardon3d_tui_optics_snapshot( + runtime->optics, &runtime->optics_snapshot); + return handled; +} + static bool handle_normal_input( Lardon3DAppState *state, TuiInput *input, Lardon3DImportTask *task, + TuiRuntime *runtime, int key ) { + if (key == KEY_F(10)) { + return handle_ssd_key(state, runtime); + } if (task) { - if (key == 'c' || key == 'C') { + if (key == 'x' || key == 'X') { lardon3d_import_task_request_cancel(task); } else if (key == 'q' || key == 'Q') { (void)snprintf( @@ -408,13 +976,21 @@ handle_normal_input( "Accueil indisponible pendant l'import." ); } - return key == KEY_RESIZE || key == 'c' || key == 'C' + return key == KEY_RESIZE || key == 'x' || key == 'X' || key == 'q' || key == 'Q' || key == 27; } if (key == 'q' || key == 'Q') { state->running = false; return false; } + if (state->screen == LARDON3D_SCREEN_TASKS + && handle_task_key(state, runtime, key)) { + return true; + } + if (state->screen == LARDON3D_SCREEN_OPTICS + && handle_optics_key(state, input, runtime, key)) { + return true; + } switch (key) { case KEY_F(1): @@ -435,6 +1011,9 @@ handle_normal_input( case KEY_F(6): state->screen = LARDON3D_SCREEN_RESOURCES; return true; + case KEY_F(7): + state->screen = LARDON3D_SCREEN_OPTICS; + return true; case 27: state->screen = LARDON3D_SCREEN_HOME; return true; @@ -633,7 +1212,7 @@ handle_normal_input( case 'c': case 'C': if (state->screen == LARDON3D_SCREEN_PROJECTS) { - lardon3d_project_close(state); + (void)reset_project_session(state, runtime); return true; } return false; @@ -659,8 +1238,66 @@ lardon3d_tui_init(void) return true; } +static void +initialize_palette(Lardon3DTuiPalette *palette) +{ + if (!palette || !has_colors() || start_color() == ERR) { + lardon3d_tui_palette_plan(false, 0, palette); + return; + } + short background = use_default_colors() == OK ? -1 : COLOR_BLACK; + lardon3d_tui_palette_plan(true, COLOR_PAIRS, palette); + static const struct { + Lardon3DTuiSemantic semantic; + short foreground; + } colors[] = { + {LARDON3D_TUI_SEMANTIC_HEALTHY, COLOR_GREEN}, + {LARDON3D_TUI_SEMANTIC_WARNING, COLOR_YELLOW}, + {LARDON3D_TUI_SEMANTIC_ERROR, COLOR_RED}, + {LARDON3D_TUI_SEMANTIC_GPU, COLOR_CYAN}, + {LARDON3D_TUI_SEMANTIC_CPU, COLOR_BLUE}, + {LARDON3D_TUI_SEMANTIC_SSD, COLOR_MAGENTA}, + }; + bool any_pair = false; + for (size_t index = 0; index < sizeof(colors) / sizeof(colors[0]); + ++index) { + short pair = palette->color_pair[colors[index].semantic]; + if (pair > 0 && init_pair(pair, colors[index].foreground, + background) == OK) { + any_pair = true; + } else { + palette->color_pair[colors[index].semantic] = 0; + } + } + palette->color_enabled = any_pair; +} + +static void +destroy_runtime(TuiRuntime *runtime) +{ + if (!runtime) { + return; + } + /* The DB borrow ends here. The SSD worker is separately borrowed from App + * and deliberately survives this local runtime until Queue teardown. */ + lardon3d_tui_optics_unbind(runtime->optics); + runtime->optics_database = NULL; + lardon3d_tui_optics_destroy(runtime->optics); + runtime->optics = NULL; + /* The SSD operation is borrowed. App keeps its Governor registration alive + * until after Queue destroy has joined every Task and released every + * scratch lease; tearing it down in this local frame would invert that + * ownership boundary. */ + runtime->ssd_operation = NULL; + lardon3d_runtime_observer_destroy(runtime->observer); + runtime->observer = NULL; +} + bool -lardon3d_tui_run(Lardon3DAppState *state) +lardon3d_tui_run_with_ssd_operation( + Lardon3DAppState *state, + Lardon3DTuiSsdAsync *ssd_operation +) { if (!state) { return false; @@ -668,20 +1305,53 @@ lardon3d_tui_run(Lardon3DAppState *state) TuiInput input = {0}; Lardon3DImportTask *task = NULL; - redraw(state, &input, task); + TuiRuntime runtime = {0}; + runtime.observer = lardon3d_runtime_observer_create( + &state->hardware_profile, state->task_queue, + state->resource_governor); + runtime.optics = lardon3d_tui_optics_create(); + runtime.ssd_operation = ssd_operation; + if (!runtime.observer || !runtime.optics) { + destroy_runtime(&runtime); + return false; + } + initialize_palette(&runtime.palette); + if (!lardon3d_runtime_observer_refresh( + runtime.observer, state->project_loaded, true, + &runtime.observation)) { + /* With no previous cache the observer returns an initialized UNKNOWN + * copy; later failures retain and mark the last bounded copy stale. */ + if (!runtime.observation.status[0]) { + (void)snprintf(runtime.observation.status, + sizeof(runtime.observation.status), + "Runtime observation unavailable; values remain UNKNOWN"); + } + (void)snprintf(state->status_message, + sizeof(state->status_message), "%s", + runtime.observation.status); + } + redraw(state, &input, task, &runtime); + + bool success = true; while (state->running) { int key = getch(); bool should_redraw = task != NULL + || state->screen == LARDON3D_SCREEN_HOME || state->screen == LARDON3D_SCREEN_TASKS - || state->screen == LARDON3D_SCREEN_RESOURCES; + || state->screen == LARDON3D_SCREEN_RESOURCES + || state->screen == LARDON3D_SCREEN_SSD + || state->screen == LARDON3D_SCREEN_OPTICS + || runtime.ssd_operation_snapshot.running; if (task && lardon3d_import_task_is_finished(task)) { Lardon3DImportTaskSnapshot snapshot; if (!lardon3d_import_task_join(task) || !lardon3d_import_task_snapshot(task, &snapshot)) { lardon3d_import_task_destroy(task); - return false; + task = NULL; + success = false; + break; } (void)snprintf( state->status_message, @@ -699,13 +1369,14 @@ lardon3d_tui_run(Lardon3DAppState *state) if (key != ERR) { should_redraw = (input.mode != INPUT_NONE - ? handle_active_input(state, &input, &task, key) - : handle_normal_input(state, &input, task, key)) + ? handle_active_input(state, &input, &task, &runtime, key) + : handle_normal_input( + state, &input, task, &runtime, key)) || should_redraw; } if (should_redraw) { - redraw(state, &input, task); + redraw(state, &input, task, &runtime); } } @@ -713,12 +1384,41 @@ lardon3d_tui_run(Lardon3DAppState *state) lardon3d_import_task_request_cancel(task); if (!lardon3d_import_task_join(task)) { lardon3d_import_task_destroy(task); - return false; + task = NULL; + success = false; + } else { + lardon3d_import_task_destroy(task); + task = NULL; } - lardon3d_import_task_destroy(task); } - return true; + destroy_runtime(&runtime); + return success && !runtime.fatal_error; +} + +bool +lardon3d_tui_run_with_ssd( + Lardon3DAppState *state, + Lardon3DSsdController *ssd_controller +) +{ + Lardon3DTuiSsdAsync *operation = ssd_controller + ? lardon3d_tui_ssd_async_create(ssd_controller) : NULL; + if (ssd_controller && !operation) { + return false; + } + bool success = lardon3d_tui_run_with_ssd_operation(state, operation); + if (operation + && !lardon3d_tui_ssd_async_destroy_checked(&operation)) { + return false; + } + return success; +} + +bool +lardon3d_tui_run(Lardon3DAppState *state) +{ + return lardon3d_tui_run_with_ssd_operation(state, NULL); } void diff --git a/src/tui_model.c b/src/tui_model.c new file mode 100644 index 0000000..3723435 --- /dev/null +++ b/src/tui_model.c @@ -0,0 +1,679 @@ +#include +#include +#include +#include + +#include + +enum { + TUI_COMPACT_MINIMUM_ROWS = 15, + TUI_COMPACT_MINIMUM_COLUMNS = 60, + TUI_FULL_MINIMUM_ROWS = 30, + TUI_FULL_MINIMUM_COLUMNS = 100, +}; + +static void +copy_text(char *destination, size_t capacity, const char *source) +{ + if (!destination || capacity == 0) { + return; + } + (void)snprintf(destination, capacity, "%s", source ? source : ""); +} + +Lardon3DTuiViewport +lardon3d_tui_viewport_classify(int rows, int columns) +{ + if (rows < TUI_COMPACT_MINIMUM_ROWS + || columns < TUI_COMPACT_MINIMUM_COLUMNS) { + return LARDON3D_TUI_VIEWPORT_TOO_SMALL; + } + if (rows >= TUI_FULL_MINIMUM_ROWS + && columns >= TUI_FULL_MINIMUM_COLUMNS) { + return LARDON3D_TUI_VIEWPORT_FULL; + } + return LARDON3D_TUI_VIEWPORT_COMPACT; +} + +void +lardon3d_tui_palette_plan( + bool colors_supported, + int color_pairs, + Lardon3DTuiPalette *palette +) +{ + if (!palette) { + return; + } + *palette = (Lardon3DTuiPalette) {0}; + palette->attributes[LARDON3D_TUI_SEMANTIC_HEALTHY] = + LARDON3D_TUI_STYLE_BOLD; + palette->attributes[LARDON3D_TUI_SEMANTIC_WARNING] = + LARDON3D_TUI_STYLE_BOLD; + palette->attributes[LARDON3D_TUI_SEMANTIC_ERROR] = + LARDON3D_TUI_STYLE_BOLD; + palette->attributes[LARDON3D_TUI_SEMANTIC_GPU] = + LARDON3D_TUI_STYLE_BOLD; + palette->attributes[LARDON3D_TUI_SEMANTIC_CPU] = + LARDON3D_TUI_STYLE_BOLD; + palette->attributes[LARDON3D_TUI_SEMANTIC_SSD] = + LARDON3D_TUI_STYLE_BOLD; + palette->attributes[LARDON3D_TUI_SEMANTIC_DIM] = + LARDON3D_TUI_STYLE_DIM; + + if (!colors_supported || color_pairs <= 1) { + return; + } + + static const Lardon3DTuiSemantic colored[] = { + LARDON3D_TUI_SEMANTIC_HEALTHY, + LARDON3D_TUI_SEMANTIC_WARNING, + LARDON3D_TUI_SEMANTIC_ERROR, + LARDON3D_TUI_SEMANTIC_GPU, + LARDON3D_TUI_SEMANTIC_CPU, + LARDON3D_TUI_SEMANTIC_SSD, + }; + palette->color_enabled = true; + for (size_t index = 0; index < sizeof(colored) / sizeof(colored[0]); + ++index) { + int pair = (int)index + 1; + if (pair < color_pairs) { + palette->color_pair[colored[index]] = (short)pair; + } + } +} + +const char * +lardon3d_tui_stage_name(Lardon3DTuiStage stage) +{ + static const char *const names[LARDON3D_TUI_STAGE_COUNT] = { + "Acquisition", + "RAW", + "Quality", + "Features", + "Visual Index", + "Candidate", + "Matcher", + "GV", + "Tracks", + "Sparse SfM", + "Dense (future)", + }; + return stage >= LARDON3D_TUI_STAGE_ACQUISITION + && stage <= LARDON3D_TUI_STAGE_DENSE + ? names[stage] + : "Unknown"; +} + +const char * +lardon3d_tui_stage_state_name(Lardon3DTuiStageState state) +{ + switch (state) { + case LARDON3D_TUI_STAGE_NOT_READY: + return "NOT_READY"; + case LARDON3D_TUI_STAGE_READY: + return "READY"; + case LARDON3D_TUI_STAGE_QUEUED: + return "QUEUED"; + case LARDON3D_TUI_STAGE_RUNNING: + return "RUNNING"; + case LARDON3D_TUI_STAGE_THROTTLED: + return "THROTTLED"; + case LARDON3D_TUI_STAGE_BLOCKED: + return "BLOCKED"; + case LARDON3D_TUI_STAGE_COMPLETE: + return "COMPLETE"; + case LARDON3D_TUI_STAGE_FAILED: + return "FAILED"; + case LARDON3D_TUI_STAGE_NOT_APPLICABLE: + return "NOT_APPLICABLE"; + } + return "UNKNOWN"; +} + +Lardon3DTuiSemantic +lardon3d_tui_stage_semantic(Lardon3DTuiStageState state) +{ + switch (state) { + case LARDON3D_TUI_STAGE_READY: + case LARDON3D_TUI_STAGE_COMPLETE: + return LARDON3D_TUI_SEMANTIC_HEALTHY; + case LARDON3D_TUI_STAGE_QUEUED: + case LARDON3D_TUI_STAGE_RUNNING: + return LARDON3D_TUI_SEMANTIC_CPU; + case LARDON3D_TUI_STAGE_THROTTLED: + case LARDON3D_TUI_STAGE_BLOCKED: + return LARDON3D_TUI_SEMANTIC_WARNING; + case LARDON3D_TUI_STAGE_FAILED: + return LARDON3D_TUI_SEMANTIC_ERROR; + case LARDON3D_TUI_STAGE_NOT_READY: + case LARDON3D_TUI_STAGE_NOT_APPLICABLE: + return LARDON3D_TUI_SEMANTIC_DIM; + } + return LARDON3D_TUI_SEMANTIC_NORMAL; +} + +static bool +kind_to_stage( + const Lardon3DTaskObservation *task, + Lardon3DTuiStage *stage +) +{ + if (!task || !stage || !task->has_task_kind + || task->task_kind_version != 1) { + return false; + } + const char *kind = task->task_kind; + if (strcmp(kind, "import.images") == 0 + || strcmp(kind, "acquisition_campaign.run") == 0) { + *stage = LARDON3D_TUI_STAGE_ACQUISITION; + } else if (strcmp(kind, "raw.develop") == 0) { + *stage = LARDON3D_TUI_STAGE_RAW; + } else if (strcmp(kind, "photo_quality.triage") == 0) { + *stage = LARDON3D_TUI_STAGE_QUALITY; + } else if (strcmp(kind, "features.extract") == 0 + || strcmp(kind, "features.extract.sift") == 0 + || strcmp(kind, "features.extract.rootsift") == 0) { + *stage = LARDON3D_TUI_STAGE_FEATURES; + } else if (strcmp(kind, "visual_index.update") == 0) { + *stage = LARDON3D_TUI_STAGE_VISUAL_INDEX; + } else if (strcmp(kind, "candidate_pair.generate") == 0) { + *stage = LARDON3D_TUI_STAGE_CANDIDATE; + } else if (strcmp(kind, "matcher.run") == 0) { + *stage = LARDON3D_TUI_STAGE_MATCHER; + } else if (strcmp(kind, "geometric_verifier.run") == 0) { + *stage = LARDON3D_TUI_STAGE_GV; + } else if (strcmp(kind, "track_builder.run") == 0) { + *stage = LARDON3D_TUI_STAGE_TRACKS; + } else if (strcmp(kind, "sparse_sfm.run") == 0 + || strcmp(kind, "incremental_reconstruction.run") == 0) { + *stage = LARDON3D_TUI_STAGE_SPARSE_SFM; + } else { + return false; + } + return true; +} + +static Lardon3DTuiStageState +task_stage_state( + const Lardon3DTaskObservation *task, + Lardon3DResourcePressure pressure +) +{ + switch (task->state) { + case TASK_PENDING: + return pressure == LARDON3D_RESOURCE_PRESSURE_GREEN + ? LARDON3D_TUI_STAGE_QUEUED + : LARDON3D_TUI_STAGE_THROTTLED; + case TASK_RUNNING: + return LARDON3D_TUI_STAGE_RUNNING; + case TASK_PAUSED: + case TASK_CANCELLED: + return LARDON3D_TUI_STAGE_BLOCKED; + case TASK_FAILED: + return LARDON3D_TUI_STAGE_FAILED; + case TASK_COMPLETED: + if (task->durable_progress_known + && task->durable_completed < task->durable_total) { + return LARDON3D_TUI_STAGE_BLOCKED; + } + return LARDON3D_TUI_STAGE_COMPLETE; + } + return LARDON3D_TUI_STAGE_NOT_READY; +} + +static unsigned int +task_stage_relevance(Lardon3DTaskState state) +{ + /* Queue snapshots are newest-first, but a newly submitted pending Task + * must not hide an older Task that is actually executing in the same + * stage. Equal relevance retains the first/newest observation. */ + switch (state) { + case TASK_RUNNING: + return 3; + case TASK_PAUSED: + return 2; + case TASK_PENDING: + return 1; + case TASK_CANCELLED: + case TASK_FAILED: + case TASK_COMPLETED: + return 0; + } + return 0; +} + +void +lardon3d_tui_stage_views_build( + bool project_loaded, + const Lardon3DTaskObservation *tasks, + size_t task_count, + Lardon3DResourcePressure pressure, + Lardon3DTuiStageView stages[LARDON3D_TUI_STAGE_COUNT] +) +{ + if (!stages) { + return; + } + for (size_t index = 0; index < LARDON3D_TUI_STAGE_COUNT; ++index) { + stages[index] = (Lardon3DTuiStageView) { + .stage = (Lardon3DTuiStage)index, + .state = LARDON3D_TUI_STAGE_NOT_READY, + }; + copy_text(stages[index].reason, sizeof(stages[index].reason), + project_loaded ? "No eligible runtime Task observed" + : "Load a project first"); + } + stages[LARDON3D_TUI_STAGE_DENSE].state = + LARDON3D_TUI_STAGE_NOT_APPLICABLE; + copy_text(stages[LARDON3D_TUI_STAGE_DENSE].reason, + sizeof(stages[LARDON3D_TUI_STAGE_DENSE].reason), + "Future stage; no production execution is exposed"); + if (project_loaded) { + stages[LARDON3D_TUI_STAGE_ACQUISITION].state = + LARDON3D_TUI_STAGE_READY; + copy_text(stages[LARDON3D_TUI_STAGE_ACQUISITION].reason, + sizeof(stages[LARDON3D_TUI_STAGE_ACQUISITION].reason), + "Project is ready for acquisition/import work"); + } + if (!tasks || task_count == 0) { + return; + } + + bool observed[LARDON3D_TUI_STAGE_COUNT] = {0}; + unsigned int relevance[LARDON3D_TUI_STAGE_COUNT] = {0}; + for (size_t index = 0; index < task_count; ++index) { + Lardon3DTuiStage stage; + if (!kind_to_stage(&tasks[index], &stage)) { + continue; + } + unsigned int candidate_relevance = task_stage_relevance( + tasks[index].state); + if (observed[stage] && candidate_relevance <= relevance[stage]) { + continue; + } + observed[stage] = true; + relevance[stage] = candidate_relevance; + stages[stage].state = task_stage_state(&tasks[index], pressure); + if (tasks[index].state == TASK_COMPLETED + && tasks[index].durable_progress_known + && tasks[index].durable_completed < tasks[index].durable_total) { + (void)snprintf(stages[stage].reason, + sizeof(stages[stage].reason), + "Durable progress incomplete: %llu/%llu", + (unsigned long long)tasks[index].durable_completed, + (unsigned long long)tasks[index].durable_total); + } else if (tasks[index].state == TASK_COMPLETED + && !tasks[index].durable_progress_known) { + copy_text(stages[stage].reason, sizeof(stages[stage].reason), + "Lifecycle complete; scientific progress indeterminate"); + } else { + copy_text(stages[stage].reason, sizeof(stages[stage].reason), + tasks[index].message[0] ? tasks[index].message + : lardon3d_task_state_name(tasks[index].state)); + } + } + + /* READY is only a workflow hint after an observed successful predecessor; + * it never claims that scientific prerequisites or an absent Task exist. */ + for (size_t index = 0; index + 1 < LARDON3D_TUI_STAGE_DENSE; ++index) { + if (stages[index].state == LARDON3D_TUI_STAGE_COMPLETE + && !observed[index + 1]) { + stages[index + 1].state = LARDON3D_TUI_STAGE_READY; + copy_text(stages[index + 1].reason, + sizeof(stages[index + 1].reason), + "Previous observed stage completed; eligibility not inferred"); + } + } +} + +static unsigned int +percentage(uint64_t completed, uint64_t total) +{ + if (total == 0) { + return 0; + } + if (completed >= total) { + return 100; + } + uint64_t quotient = total / 100U; + uint64_t remainder = total % 100U; + for (unsigned int percent = 99; percent > 0; --percent) { + uint64_t threshold = quotient * percent; + uint64_t residual_product = remainder * percent; + threshold += residual_product / 100U; + if (residual_product % 100U != 0) { + ++threshold; + } + if (completed >= threshold) { + return percent; + } + } + return 0; +} + +static void +progress_tracker_reset( + Lardon3DTuiProgressTracker *tracker, + const Lardon3DTuiProgressSample *sample, + uint64_t completed +) +{ + *tracker = (Lardon3DTuiProgressTracker) { + .task_id = sample->task_id, + .prefix_completed = completed, + .previous_completed = completed, + .previous_sample_ns = sample->monotonic_ns, + .last_positive_ns = sample->monotonic_ns, + .previous_state = sample->task_state, + .initialized = true, + .using_durable_counts = sample->durable_counts_known, + }; +} + +bool +lardon3d_tui_progress_update( + Lardon3DTuiProgressTracker *tracker, + const Lardon3DTuiProgressSample *sample, + Lardon3DTuiProgressView *view +) +{ + if (view) { + *view = (Lardon3DTuiProgressView) {0}; + } + if (!tracker || !sample || !view || sample->task_id == 0 + || sample->progress_percent > 100 + || (sample->durable_counts_known + && (sample->durable_total == 0 + || sample->durable_completed > sample->durable_total))) { + return false; + } + + bool measurement_known = sample->durable_counts_known + || !sample->typed_task; + uint64_t completed = sample->durable_counts_known + ? sample->durable_completed + : sample->progress_percent; + uint64_t total = sample->durable_counts_known + ? sample->durable_total + : UINT64_C(100); + if (!measurement_known) { + *tracker = (Lardon3DTuiProgressTracker) {0}; + *view = (Lardon3DTuiProgressView) { + .present = true, + .task_id = sample->task_id, + .eta_state = LARDON3D_TUI_ETA_INDETERMINATE, + }; + return true; + } + if (!tracker->initialized || tracker->task_id != sample->task_id + || tracker->using_durable_counts != sample->durable_counts_known) { + progress_tracker_reset(tracker, sample, completed); + } else if (sample->task_state == TASK_RUNNING + && tracker->previous_state != TASK_RUNNING) { + /* Admission/resume starts a fresh rate interval. Pending or paused + * wall time is not processing throughput and must not inflate ETA. */ + tracker->previous_completed = completed; + tracker->previous_sample_ns = sample->monotonic_ns; + tracker->last_positive_ns = sample->monotonic_ns; + if (completed > tracker->prefix_completed) { + tracker->prefix_completed = completed; + } + tracker->ewma_units_per_second = 0.0; + tracker->positive_sample_count = 0; + } else if (completed < tracker->previous_completed + || sample->monotonic_ns < tracker->previous_sample_ns) { + /* A restored Task may expose an earlier durable prefix. Treat that as + * a new baseline; negative work must never create a rate or huge ETA. */ + progress_tracker_reset(tracker, sample, completed); + } else if (completed > tracker->previous_completed + && sample->monotonic_ns > tracker->previous_sample_ns) { + uint64_t delta = completed - tracker->previous_completed; + uint64_t elapsed_ns = sample->monotonic_ns + - tracker->previous_sample_ns; + double rate = (double)delta * 1000000000.0 / (double)elapsed_ns; + if (isfinite(rate) && rate > 0.0) { + tracker->ewma_units_per_second = + tracker->positive_sample_count == 0 + ? rate + : tracker->ewma_units_per_second * 0.65 + rate * 0.35; + if (tracker->positive_sample_count < UINT_MAX) { + ++tracker->positive_sample_count; + } + tracker->last_positive_ns = sample->monotonic_ns; + } + } + tracker->previous_completed = completed; + tracker->previous_sample_ns = sample->monotonic_ns; + tracker->previous_state = sample->task_state; + + *view = (Lardon3DTuiProgressView) { + .present = true, + .task_id = sample->task_id, + .durable_counts_known = sample->durable_counts_known, + .runtime_percentage = !sample->durable_counts_known, + .completed = sample->durable_counts_known ? completed : 0, + .total = sample->durable_counts_known ? total : 0, + .percentage_known = true, + .percentage = sample->durable_counts_known + ? percentage(completed, total) + : sample->progress_percent, + .throughput_known = tracker->positive_sample_count >= 2, + .units_per_second = tracker->ewma_units_per_second, + .eta_state = LARDON3D_TUI_ETA_CALCULATING, + .resumed_prefix_excluded = tracker->prefix_completed > 0, + }; + + if (sample->task_state == TASK_COMPLETED + && sample->durable_counts_known && completed < total) { + view->integrity_error = true; + view->eta_state = LARDON3D_TUI_ETA_INDETERMINATE; + return true; + } + if ((sample->durable_counts_known && completed == total) + || (!sample->typed_task && sample->task_state == TASK_COMPLETED)) { + view->percentage = 100; + view->eta_state = LARDON3D_TUI_ETA_COMPLETE; + view->eta_seconds = 0; + return true; + } + if (sample->pressure_limited || sample->task_state == TASK_PAUSED) { + view->eta_state = LARDON3D_TUI_ETA_THROTTLED; + return true; + } + if (sample->task_state != TASK_RUNNING) { + view->eta_state = LARDON3D_TUI_ETA_INDETERMINATE; + return true; + } + if (sample->monotonic_ns >= tracker->last_positive_ns + && sample->monotonic_ns - tracker->last_positive_ns + >= UINT64_C(5000000000)) { + view->eta_state = LARDON3D_TUI_ETA_STALLED; + return true; + } + if (tracker->positive_sample_count < 2 + || !(tracker->ewma_units_per_second > 0.0)) { + return true; + } + + uint64_t remaining = total - completed; + long double seconds = (long double)remaining + / (long double)tracker->ewma_units_per_second; + if (!isfinite((double)seconds) || seconds > (long double)UINT64_MAX) { + view->eta_state = LARDON3D_TUI_ETA_INDETERMINATE; + return true; + } + uint64_t rounded = (uint64_t)seconds; + if ((long double)rounded < seconds) { + ++rounded; + } + view->eta_state = LARDON3D_TUI_ETA_KNOWN; + view->eta_seconds = rounded; + return true; +} + +Lardon3DTuiKeyContract +lardon3d_tui_key_contract(Lardon3DTuiInteractionMode mode) +{ + switch (mode) { + case LARDON3D_TUI_INTERACTION_TEXT_INPUT: + return (Lardon3DTuiKeyContract) { + .enter = true, + .escape = true, + .f10 = true, + }; + case LARDON3D_TUI_INTERACTION_IMPORT_RUNNING: + return (Lardon3DTuiKeyContract) { + .f10 = true, + .cancel_import = true, + }; + case LARDON3D_TUI_INTERACTION_IDLE: + return (Lardon3DTuiKeyContract) { + .escape = true, + .f10 = true, + .quit = true, + .navigate = true, + }; + } + return (Lardon3DTuiKeyContract) {0}; +} + +const char * +lardon3d_tui_eta_state_name(Lardon3DTuiEtaState state) +{ + switch (state) { + case LARDON3D_TUI_ETA_INDETERMINATE: + return "indeterminate"; + case LARDON3D_TUI_ETA_CALCULATING: + return "calculating"; + case LARDON3D_TUI_ETA_KNOWN: + return "known"; + case LARDON3D_TUI_ETA_STALLED: + return "stalled"; + case LARDON3D_TUI_ETA_THROTTLED: + return "throttled"; + case LARDON3D_TUI_ETA_COMPLETE: + return "complete"; + } + return "unknown"; +} + +const char * +lardon3d_tui_gpu_backend_name(Lardon3DTuiGpuBackendStatus status) +{ + switch (status) { + case LARDON3D_TUI_GPU_BACKEND_UNKNOWN: + return "UNKNOWN"; + case LARDON3D_TUI_GPU_BACKEND_UNINITIALIZED: + return "UNINITIALIZED"; + case LARDON3D_TUI_GPU_BACKEND_AVAILABLE: + return "AVAILABLE"; + case LARDON3D_TUI_GPU_BACKEND_UNAVAILABLE: + return "UNAVAILABLE"; + case LARDON3D_TUI_GPU_BACKEND_CPU: + return "CPU"; + case LARDON3D_TUI_GPU_BACKEND_ORB_VULKAN: + return "ORB_VULKAN"; + case LARDON3D_TUI_GPU_BACKEND_MIXED: + return "MIXED"; + } + return "UNKNOWN"; +} + +const char * +lardon3d_tui_pressure_name(Lardon3DResourcePressure pressure) +{ + switch (pressure) { + case LARDON3D_RESOURCE_PRESSURE_GREEN: + return "GREEN"; + case LARDON3D_RESOURCE_PRESSURE_YELLOW: + return "YELLOW"; + case LARDON3D_RESOURCE_PRESSURE_RED: + return "RED"; + } + return "UNKNOWN"; +} + +Lardon3DTuiOpticsStatus +lardon3d_tui_optics_classify( + bool project_loaded, + bool assignment_found, + bool assignment_valid, + bool manual_lens, + size_t compatible_calibration_count, + bool selection_found, + bool selection_compatible +) +{ + (void)manual_lens; + if (!project_loaded) { + return LARDON3D_TUI_OPTICS_NO_PROJECT; + } + if (!assignment_found) { + return LARDON3D_TUI_OPTICS_UNRESOLVED; + } + if (!assignment_valid) { + return LARDON3D_TUI_OPTICS_CORRUPT; + } + if (selection_found && !selection_compatible) { + return LARDON3D_TUI_OPTICS_INCOMPATIBLE; + } + if (selection_found) { + return LARDON3D_TUI_OPTICS_SELECTED; + } + if (compatible_calibration_count == 0) { + return LARDON3D_TUI_OPTICS_CONFIGURATION_ONLY; + } + return LARDON3D_TUI_OPTICS_SELECTION_REQUIRED; +} + +const char * +lardon3d_tui_optics_status_name(Lardon3DTuiOpticsStatus status) +{ + switch (status) { + case LARDON3D_TUI_OPTICS_NO_PROJECT: + return "NO_PROJECT"; + case LARDON3D_TUI_OPTICS_UNRESOLVED: + return "UNRESOLVED"; + case LARDON3D_TUI_OPTICS_CONFIGURATION_ONLY: + return "MISSING_CALIBRATION"; + case LARDON3D_TUI_OPTICS_SELECTION_REQUIRED: + return "SELECTION_REQUIRED"; + case LARDON3D_TUI_OPTICS_SELECTED: + return "SELECTED"; + case LARDON3D_TUI_OPTICS_INCOMPATIBLE: + return "INCOMPATIBLE"; + case LARDON3D_TUI_OPTICS_CORRUPT: + return "CORRUPT"; + } + return "UNKNOWN"; +} + +const char * +lardon3d_tui_optics_status_explanation( + Lardon3DTuiOpticsStatus status, + bool manual_lens +) +{ + switch (status) { + case LARDON3D_TUI_OPTICS_NO_PROJECT: + return "Load a project to inspect optical assignments."; + case LARDON3D_TUI_OPTICS_UNRESOLVED: + return "No explicit optical configuration is assigned; nothing is guessed."; + case LARDON3D_TUI_OPTICS_CONFIGURATION_ONLY: + return manual_lens + ? "Manual/no-EXIF lens is valid; no compatible calibration exists." + : "Optical configuration is explicit, but no compatible calibration exists."; + case LARDON3D_TUI_OPTICS_SELECTION_REQUIRED: + return "Compatible calibration exists; select one explicitly."; + case LARDON3D_TUI_OPTICS_SELECTED: + return manual_lens + ? "Manual/no-EXIF configuration and exact calibration are selected." + : "Exact optical configuration and calibration are selected."; + case LARDON3D_TUI_OPTICS_INCOMPATIBLE: + return "Stored selection is incompatible with the Capture configuration."; + case LARDON3D_TUI_OPTICS_CORRUPT: + return "Malformed durable optical state; no mutation was attempted."; + } + return "Unknown optical status."; +} diff --git a/src/tui_optics.c b/src/tui_optics.c new file mode 100644 index 0000000..ccc0e21 --- /dev/null +++ b/src/tui_optics.c @@ -0,0 +1,717 @@ +#include +#include +#include + +#include + +struct Lardon3DTuiOptics { + Lardon3DProjectDb *database; + Lardon3DTuiOpticsSnapshot view; + uint64_t body_cursor; + uint64_t lens_cursor; + uint64_t configuration_cursor; + uint64_t calibration_cursor; +}; + +enum { + /* One extra row is a bounded look-ahead. A page of exactly sixteen must + * not advertise a nonexistent next page, and the seventeenth row is not + * retained until the caller explicitly advances the cursor. */ + TUI_OPTICS_FETCH_CAPACITY = LARDON3D_TUI_OPTICS_PAGE_CAPACITY + 1, +}; + +static void +set_message(Lardon3DTuiOptics *optics, const char *message) +{ + (void)snprintf(optics->view.message, sizeof(optics->view.message), "%s", + message ? message : ""); +} + +static const char * +result_name(Lardon3DProjectDbResult result) +{ + switch (result) { + case LARDON3D_PROJECT_DB_OK: + return "OK"; + case LARDON3D_PROJECT_DB_INVALID_ARGUMENT: + return "INVALID_ARGUMENT"; + case LARDON3D_PROJECT_DB_NOT_FOUND: + return "NOT_FOUND"; + case LARDON3D_PROJECT_DB_BUSY: + return "BUSY"; + case LARDON3D_PROJECT_DB_UNSUPPORTED_SCHEMA: + return "UNSUPPORTED_SCHEMA"; + case LARDON3D_PROJECT_DB_CORRUPT: + return "CORRUPT"; + case LARDON3D_PROJECT_DB_CONSTRAINT: + return "CONSTRAINT"; + case LARDON3D_PROJECT_DB_IO_ERROR: + return "IO_ERROR"; + } + return "UNKNOWN"; +} + +static bool +report_result( + Lardon3DTuiOptics *optics, + const char *operation, + Lardon3DProjectDbResult result +) +{ + if (result == LARDON3D_PROJECT_DB_OK) { + return true; + } + (void)snprintf(optics->view.message, sizeof(optics->view.message), + "%s: %s", operation, result_name(result)); + return false; +} + +static bool +load_bodies(Lardon3DTuiOptics *optics, uint64_t cursor) +{ + Lardon3DOpticalCameraBodyProfile + items[TUI_OPTICS_FETCH_CAPACITY] = {0}; + size_t count = 0; + uint64_t next = cursor; + Lardon3DProjectDbResult result = lardon3d_optical_camera_body_list( + optics->database, cursor, items, + TUI_OPTICS_FETCH_CAPACITY, &count, &next); + if (!report_result(optics, "Cannot list camera bodies", result)) { + return false; + } + memcpy(optics->view.bodies, items, sizeof(optics->view.bodies)); + optics->body_cursor = cursor; + optics->view.body_count = count > LARDON3D_TUI_OPTICS_PAGE_CAPACITY + ? LARDON3D_TUI_OPTICS_PAGE_CAPACITY : count; + optics->view.selected_body = 0; + optics->view.bodies_have_next = + count > LARDON3D_TUI_OPTICS_PAGE_CAPACITY && next > cursor; + return true; +} + +static bool +load_lenses(Lardon3DTuiOptics *optics, uint64_t cursor) +{ + Lardon3DOpticalLensProfile items[TUI_OPTICS_FETCH_CAPACITY] = {0}; + size_t count = 0; + uint64_t next = cursor; + Lardon3DProjectDbResult result = lardon3d_optical_lens_list( + optics->database, cursor, items, + TUI_OPTICS_FETCH_CAPACITY, &count, &next); + if (!report_result(optics, "Cannot list lenses", result)) { + return false; + } + memcpy(optics->view.lenses, items, sizeof(optics->view.lenses)); + optics->lens_cursor = cursor; + optics->view.lens_count = count > LARDON3D_TUI_OPTICS_PAGE_CAPACITY + ? LARDON3D_TUI_OPTICS_PAGE_CAPACITY : count; + optics->view.selected_lens = 0; + optics->view.lenses_have_next = + count > LARDON3D_TUI_OPTICS_PAGE_CAPACITY && next > cursor; + return true; +} + +static bool +load_configurations(Lardon3DTuiOptics *optics, uint64_t cursor) +{ + Lardon3DOpticalConfiguration + items[TUI_OPTICS_FETCH_CAPACITY] = {0}; + size_t count = 0; + uint64_t next = cursor; + Lardon3DProjectDbResult result = lardon3d_optical_configuration_list( + optics->database, cursor, items, + TUI_OPTICS_FETCH_CAPACITY, &count, &next); + if (!report_result(optics, "Cannot list optical configurations", result)) { + return false; + } + memcpy(optics->view.configurations, items, + sizeof(optics->view.configurations)); + optics->configuration_cursor = cursor; + optics->view.configuration_count = + count > LARDON3D_TUI_OPTICS_PAGE_CAPACITY + ? LARDON3D_TUI_OPTICS_PAGE_CAPACITY : count; + optics->view.selected_configuration = 0; + optics->view.configurations_have_next = + count > LARDON3D_TUI_OPTICS_PAGE_CAPACITY && next > cursor; + return true; +} + +static bool +load_calibrations(Lardon3DTuiOptics *optics, uint64_t cursor) +{ + if (!optics->view.capture_configuration_found) { + set_message(optics, + "Inspect a configured Capture before paging calibrations."); + return false; + } + Lardon3DOpticalCalibrationProfile + items[TUI_OPTICS_FETCH_CAPACITY] = {0}; + size_t count = 0; + uint64_t next = cursor; + Lardon3DProjectDbResult result = + lardon3d_optical_calibration_profile_list_compatible( + optics->database, + optics->view.capture_configuration.optical_configuration_id, + cursor, items, TUI_OPTICS_FETCH_CAPACITY, + &count, &next); + if (!report_result(optics, "Cannot list compatible calibrations", result)) { + return false; + } + memcpy(optics->view.calibrations, items, + sizeof(optics->view.calibrations)); + optics->calibration_cursor = cursor; + optics->view.calibration_count = + count > LARDON3D_TUI_OPTICS_PAGE_CAPACITY + ? LARDON3D_TUI_OPTICS_PAGE_CAPACITY : count; + optics->view.selected_calibration = 0; + optics->view.calibrations_have_next = + count > LARDON3D_TUI_OPTICS_PAGE_CAPACITY && next > cursor; + return true; +} + +Lardon3DTuiOptics * +lardon3d_tui_optics_create(void) +{ + Lardon3DTuiOptics *optics = calloc(1, sizeof(*optics)); + if (optics) { + optics->view.capture_status = LARDON3D_TUI_OPTICS_NO_PROJECT; + set_message(optics, "Load a project to inspect optical profiles."); + } + return optics; +} + +void +lardon3d_tui_optics_destroy(Lardon3DTuiOptics *optics) +{ + free(optics); +} + +void +lardon3d_tui_optics_unbind(Lardon3DTuiOptics *optics) +{ + if (!optics) { + return; + } + *optics = (Lardon3DTuiOptics) {0}; + optics->view.capture_status = LARDON3D_TUI_OPTICS_NO_PROJECT; + set_message(optics, "Load a project to inspect optical profiles."); +} + +bool +lardon3d_tui_optics_bind( + Lardon3DTuiOptics *optics, + Lardon3DProjectDb *database +) +{ + if (!optics || !database) { + return false; + } + lardon3d_tui_optics_unbind(optics); + optics->database = database; + optics->view.project_bound = true; + optics->view.capture_status = LARDON3D_TUI_OPTICS_UNRESOLVED; + if (!load_bodies(optics, 0) || !load_lenses(optics, 0) + || !load_configurations(optics, 0)) { + optics->database = NULL; + optics->view.project_bound = false; + optics->view.capture_status = LARDON3D_TUI_OPTICS_CORRUPT; + return false; + } + set_message(optics, + "Profiles are immutable; edit by creating and selecting a new version."); + return true; +} + +bool +lardon3d_tui_optics_snapshot( + const Lardon3DTuiOptics *optics, + Lardon3DTuiOpticsSnapshot *snapshot +) +{ + if (snapshot) { + *snapshot = (Lardon3DTuiOpticsSnapshot) {0}; + } + if (!optics || !snapshot) { + return false; + } + *snapshot = optics->view; + return true; +} + +bool +lardon3d_tui_optics_select_pane( + Lardon3DTuiOptics *optics, + Lardon3DTuiOpticsPane pane +) +{ + if (!optics || pane < LARDON3D_TUI_OPTICS_PANE_BODY + || pane > LARDON3D_TUI_OPTICS_PANE_CALIBRATION) { + return false; + } + optics->view.active_pane = pane; + return true; +} + +bool +lardon3d_tui_optics_move_selection( + Lardon3DTuiOptics *optics, + int direction +) +{ + if (!optics || (direction != -1 && direction != 1)) { + return false; + } + size_t *selection = NULL; + size_t count = 0; + switch (optics->view.active_pane) { + case LARDON3D_TUI_OPTICS_PANE_BODY: + selection = &optics->view.selected_body; + count = optics->view.body_count; + break; + case LARDON3D_TUI_OPTICS_PANE_LENS: + selection = &optics->view.selected_lens; + count = optics->view.lens_count; + break; + case LARDON3D_TUI_OPTICS_PANE_CONFIGURATION: + selection = &optics->view.selected_configuration; + count = optics->view.configuration_count; + break; + case LARDON3D_TUI_OPTICS_PANE_CALIBRATION: + selection = &optics->view.selected_calibration; + count = optics->view.calibration_count; + break; + } + if (!selection || count == 0) { + return false; + } + if (direction < 0) { + if (*selection == 0) { + return false; + } + --*selection; + } else { + if (*selection + 1 >= count) { + return false; + } + ++*selection; + } + return true; +} + +bool +lardon3d_tui_optics_page( + Lardon3DTuiOptics *optics, + Lardon3DTuiOpticsPane pane, + bool next +) +{ + if (!optics || !optics->database) { + return false; + } + uint64_t cursor = 0; + if (next) { + switch (pane) { + case LARDON3D_TUI_OPTICS_PANE_BODY: + if (!optics->view.bodies_have_next || optics->view.body_count == 0) + return false; + cursor = optics->view.bodies[optics->view.body_count - 1] + .camera_body_profile_id; + break; + case LARDON3D_TUI_OPTICS_PANE_LENS: + if (!optics->view.lenses_have_next || optics->view.lens_count == 0) + return false; + cursor = optics->view.lenses[optics->view.lens_count - 1] + .lens_profile_id; + break; + case LARDON3D_TUI_OPTICS_PANE_CONFIGURATION: + if (!optics->view.configurations_have_next + || optics->view.configuration_count == 0) return false; + cursor = optics->view.configurations[ + optics->view.configuration_count - 1].optical_configuration_id; + break; + case LARDON3D_TUI_OPTICS_PANE_CALIBRATION: + if (!optics->view.calibrations_have_next + || optics->view.calibration_count == 0) return false; + cursor = optics->view.calibrations[ + optics->view.calibration_count - 1].calibration_profile_id; + break; + } + } + switch (pane) { + case LARDON3D_TUI_OPTICS_PANE_BODY: + return load_bodies(optics, cursor); + case LARDON3D_TUI_OPTICS_PANE_LENS: + return load_lenses(optics, cursor); + case LARDON3D_TUI_OPTICS_PANE_CONFIGURATION: + return load_configurations(optics, cursor); + case LARDON3D_TUI_OPTICS_PANE_CALIBRATION: + return load_calibrations(optics, cursor); + } + return false; +} + +bool +lardon3d_tui_optics_create_body( + Lardon3DTuiOptics *optics, + const Lardon3DOpticalCameraBodyProfile *input +) +{ + if (!optics || !optics->database || !input) { + return false; + } + Lardon3DOpticalCameraBodyProfile output; + Lardon3DProjectDbResult result = lardon3d_optical_camera_body_create( + optics->database, input, &output); + if (!report_result(optics, "Cannot create immutable camera body", result)) { + return false; + } + /* The create result is already a validated caller-owned copy. Keep that + * exact immutable identity selected even when its ID lies beyond the + * first bounded list page; `[` remains the explicit return-to-first-page. */ + optics->view.bodies[0] = output; + optics->view.body_count = 1; + optics->view.selected_body = 0; + optics->view.bodies_have_next = false; + optics->body_cursor = output.camera_body_profile_id > 0 + ? output.camera_body_profile_id - 1 : 0; + (void)snprintf(optics->view.message, sizeof(optics->view.message), + "Immutable camera body #%llu selected/created.", + (unsigned long long)output.camera_body_profile_id); + return true; +} + +bool +lardon3d_tui_optics_create_lens( + Lardon3DTuiOptics *optics, + const Lardon3DOpticalLensProfile *input +) +{ + if (!optics || !optics->database || !input) { + return false; + } + Lardon3DOpticalLensProfile output; + Lardon3DProjectDbResult result = lardon3d_optical_lens_create( + optics->database, input, &output); + if (!report_result(optics, "Cannot create immutable lens", result)) { + return false; + } + optics->view.lenses[0] = output; + optics->view.lens_count = 1; + optics->view.selected_lens = 0; + optics->view.lenses_have_next = false; + optics->lens_cursor = output.lens_profile_id > 0 + ? output.lens_profile_id - 1 : 0; + (void)snprintf(optics->view.message, sizeof(optics->view.message), + "Immutable lens #%llu selected/created%s.", + (unsigned long long)output.lens_profile_id, + output.interface_kind == LARDON3D_OPTICAL_LENS_MANUAL + ? " (manual/no EXIF is normal)" : ""); + return true; +} + +static bool +selected_body_lens( + const Lardon3DTuiOptics *optics, + uint64_t *body_id, + uint64_t *lens_id +) +{ + if (!optics || !body_id || !lens_id + || optics->view.selected_body >= optics->view.body_count + || optics->view.selected_lens >= optics->view.lens_count) { + return false; + } + *body_id = optics->view.bodies[optics->view.selected_body] + .camera_body_profile_id; + *lens_id = optics->view.lenses[optics->view.selected_lens] + .lens_profile_id; + return *body_id != 0 && *lens_id != 0; +} + +static bool +selected_configuration( + const Lardon3DTuiOptics *optics, + uint64_t *configuration_id +) +{ + if (!optics || !configuration_id + || optics->view.selected_configuration + >= optics->view.configuration_count) { + return false; + } + *configuration_id = optics->view.configurations[ + optics->view.selected_configuration].optical_configuration_id; + return *configuration_id != 0; +} + +bool +lardon3d_tui_optics_create_configuration( + Lardon3DTuiOptics *optics, + bool has_focal_length, + uint32_t focal_length_um +) +{ + uint64_t body_id; + uint64_t lens_id; + if (!optics || !optics->database + || !selected_body_lens(optics, &body_id, &lens_id)) { + if (optics) set_message(optics, "Select an existing body and lens first."); + return false; + } + Lardon3DOpticalConfiguration input = { + .camera_body_profile_id = body_id, + .lens_profile_id = lens_id, + .has_focal_length = has_focal_length, + .focal_length_um = focal_length_um, + }; + Lardon3DOpticalConfiguration output; + Lardon3DProjectDbResult result = lardon3d_optical_configuration_create( + optics->database, &input, &output); + if (!report_result(optics, "Cannot create immutable configuration", result)) { + return false; + } + optics->view.configurations[0] = output; + optics->view.configuration_count = 1; + optics->view.selected_configuration = 0; + optics->view.configurations_have_next = false; + optics->configuration_cursor = output.optical_configuration_id > 0 + ? output.optical_configuration_id - 1 : 0; + (void)snprintf(optics->view.message, sizeof(optics->view.message), + "Immutable optical configuration #%llu selected/created.", + (unsigned long long)output.optical_configuration_id); + return true; +} + +bool +lardon3d_tui_optics_assign_campaign_group( + Lardon3DTuiOptics *optics, + uint64_t campaign_task_id, + uint32_t group_id +) +{ + uint64_t configuration_id; + if (!optics || !optics->database + || !selected_configuration(optics, &configuration_id)) { + if (optics) set_message(optics, "Select an optical configuration first."); + return false; + } + Lardon3DProjectDbResult result = lardon3d_optical_campaign_group_assign( + optics->database, campaign_task_id, group_id, configuration_id); + if (!report_result(optics, "Cannot assign campaign group", result)) { + return false; + } + (void)snprintf(optics->view.message, sizeof(optics->view.message), + "Campaign Task #%llu group %u assigned to configuration #%llu.", + (unsigned long long)campaign_task_id, group_id, + (unsigned long long)configuration_id); + return true; +} + +bool +lardon3d_tui_optics_assign_capture( + Lardon3DTuiOptics *optics, + uint64_t capture_id +) +{ + uint64_t configuration_id; + if (!optics || !optics->database + || !selected_configuration(optics, &configuration_id)) { + if (optics) set_message(optics, "Select an optical configuration first."); + return false; + } + Lardon3DProjectDbResult result = + lardon3d_optical_capture_assign_explicit( + optics->database, capture_id, configuration_id); + if (!report_result(optics, "Cannot assign Capture", result)) { + return false; + } + return lardon3d_tui_optics_inspect_capture(optics, capture_id); +} + +static void +clear_capture_view(Lardon3DTuiOptics *optics, uint64_t capture_id) +{ + optics->view.capture_inspected = true; + optics->view.capture_id = capture_id; + optics->view.capture_status = LARDON3D_TUI_OPTICS_UNRESOLVED; + optics->view.capture_assignment_found = false; + optics->view.capture_assignment = (Lardon3DOpticalCaptureAssignment) {0}; + optics->view.capture_configuration_found = false; + optics->view.capture_configuration = (Lardon3DOpticalConfiguration) {0}; + optics->view.capture_body_found = false; + optics->view.capture_body = (Lardon3DOpticalCameraBodyProfile) {0}; + optics->view.capture_lens_found = false; + optics->view.capture_lens = (Lardon3DOpticalLensProfile) {0}; + optics->view.capture_selection_found = false; + optics->view.capture_selection = + (Lardon3DOpticalCaptureCalibrationSelection) {0}; + optics->view.calibration_count = 0; + optics->view.selected_calibration = 0; + optics->view.calibrations_have_next = false; + optics->calibration_cursor = 0; +} + +bool +lardon3d_tui_optics_inspect_capture( + Lardon3DTuiOptics *optics, + uint64_t capture_id +) +{ + if (!optics || !optics->database || capture_id == 0) { + return false; + } + clear_capture_view(optics, capture_id); + Lardon3DProjectDbCapture capture; + Lardon3DProjectDbResult result = lardon3d_project_db_load_capture( + optics->database, capture_id, &capture); + if (!report_result(optics, "Cannot inspect Capture identity", result)) { + optics->view.capture_status = result == LARDON3D_PROJECT_DB_CORRUPT + ? LARDON3D_TUI_OPTICS_CORRUPT + : LARDON3D_TUI_OPTICS_UNRESOLVED; + return false; + } + result = + lardon3d_optical_capture_assignment_load( + optics->database, capture_id, &optics->view.capture_assignment); + if (result == LARDON3D_PROJECT_DB_NOT_FOUND) { + set_message(optics, lardon3d_tui_optics_status_explanation( + LARDON3D_TUI_OPTICS_UNRESOLVED, false)); + return true; + } + if (!report_result(optics, "Cannot inspect Capture assignment", result)) { + optics->view.capture_status = result == LARDON3D_PROJECT_DB_CORRUPT + ? LARDON3D_TUI_OPTICS_CORRUPT + : LARDON3D_TUI_OPTICS_UNRESOLVED; + return false; + } + optics->view.capture_assignment_found = true; + result = lardon3d_optical_configuration_load( + optics->database, + optics->view.capture_assignment.optical_configuration_id, + &optics->view.capture_configuration); + if (!report_result(optics, "Cannot load Capture configuration", result)) { + optics->view.capture_status = LARDON3D_TUI_OPTICS_CORRUPT; + return false; + } + optics->view.capture_configuration_found = true; + result = lardon3d_optical_camera_body_load( + optics->database, + optics->view.capture_configuration.camera_body_profile_id, + &optics->view.capture_body); + if (!report_result(optics, "Cannot load Capture camera body", result)) { + optics->view.capture_status = LARDON3D_TUI_OPTICS_CORRUPT; + return false; + } + optics->view.capture_body_found = true; + result = lardon3d_optical_lens_load( + optics->database, optics->view.capture_configuration.lens_profile_id, + &optics->view.capture_lens); + if (!report_result(optics, "Cannot load Capture lens", result)) { + optics->view.capture_status = LARDON3D_TUI_OPTICS_CORRUPT; + return false; + } + optics->view.capture_lens_found = true; + + if (!load_calibrations(optics, 0)) { + optics->view.capture_status = LARDON3D_TUI_OPTICS_CORRUPT; + return false; + } + result = lardon3d_optical_capture_calibration_selection_load( + optics->database, capture_id, &optics->view.capture_selection); + bool selection_compatible = false; + if (result == LARDON3D_PROJECT_DB_OK) { + optics->view.capture_selection_found = true; + selection_compatible = + optics->view.capture_selection.optical_configuration_id + == optics->view.capture_assignment.optical_configuration_id; + } else if (result != LARDON3D_PROJECT_DB_NOT_FOUND) { + report_result(optics, "Cannot load Capture calibration selection", result); + optics->view.capture_status = result == LARDON3D_PROJECT_DB_CORRUPT + ? LARDON3D_TUI_OPTICS_CORRUPT + : LARDON3D_TUI_OPTICS_INCOMPATIBLE; + return false; + } + bool manual = optics->view.capture_lens.interface_kind + == LARDON3D_OPTICAL_LENS_MANUAL; + optics->view.capture_status = lardon3d_tui_optics_classify( + true, true, true, manual, optics->view.calibration_count, + optics->view.capture_selection_found, selection_compatible); + set_message(optics, lardon3d_tui_optics_status_explanation( + optics->view.capture_status, manual)); + return true; +} + +bool +lardon3d_tui_optics_select_capture_calibration( + Lardon3DTuiOptics *optics +) +{ + if (!optics || !optics->database || !optics->view.capture_inspected + || optics->view.selected_calibration + >= optics->view.calibration_count) { + if (optics) set_message(optics, + "Inspect a configured Capture and select a compatible calibration."); + return false; + } + uint64_t profile_id = optics->view.calibrations[ + optics->view.selected_calibration].calibration_profile_id; + Lardon3DProjectDbResult result = + lardon3d_optical_capture_calibration_select( + optics->database, optics->view.capture_id, profile_id); + if (!report_result(optics, "Cannot select Capture calibration", result)) { + return false; + } + return lardon3d_tui_optics_inspect_capture( + optics, optics->view.capture_id); +} + +bool +lardon3d_tui_optics_lookup_exact_metadata( + Lardon3DTuiOptics *optics, + const char *body_make, + const char *body_model, + const char *lens_make, + const char *lens_model +) +{ + if (!optics || !optics->database || !body_make || !body_model + || !lens_make || !lens_model) { + return false; + } + optics->view.metadata_lookup_performed = true; + optics->view.metadata_body_found = false; + optics->view.metadata_lens_found = false; + optics->view.metadata_body = (Lardon3DOpticalCameraBodyProfile) {0}; + optics->view.metadata_lens = (Lardon3DOpticalLensProfile) {0}; + + Lardon3DProjectDbResult body_result = LARDON3D_PROJECT_DB_NOT_FOUND; + if (body_make[0] && body_model[0]) { + body_result = lardon3d_optical_camera_body_find_exact_alias( + optics->database, body_make, body_model, + &optics->view.metadata_body); + if (body_result == LARDON3D_PROJECT_DB_OK) { + optics->view.metadata_body_found = true; + } else if (body_result != LARDON3D_PROJECT_DB_NOT_FOUND) { + return report_result(optics, "Camera metadata lookup failed", + body_result); + } + } + Lardon3DProjectDbResult lens_result = LARDON3D_PROJECT_DB_NOT_FOUND; + if (lens_model[0]) { + lens_result = lardon3d_optical_lens_find_exact_alias( + optics->database, lens_make, lens_model, + &optics->view.metadata_lens); + if (lens_result == LARDON3D_PROJECT_DB_OK) { + optics->view.metadata_lens_found = true; + } else if (lens_result != LARDON3D_PROJECT_DB_NOT_FOUND) { + return report_result(optics, "Lens metadata lookup failed", + lens_result); + } + } + (void)snprintf(optics->view.message, sizeof(optics->view.message), + "Exact metadata: body %s, lens %s%s.", + optics->view.metadata_body_found ? "MATCH" : "UNRESOLVED", + optics->view.metadata_lens_found ? "MATCH" : "UNRESOLVED", + lens_model[0] ? "" : " (no lens electronics is normal)"); + return true; +} diff --git a/src/tui_ssd_async.c b/src/tui_ssd_async.c new file mode 100644 index 0000000..eaf6f76 --- /dev/null +++ b/src/tui_ssd_async.c @@ -0,0 +1,693 @@ +#include +#include +#include +#include +#include +#include + +#include + +#include "tui_ssd_async_internal.h" + +struct Lardon3DTuiSsdAsync { + pthread_mutex_t mutex; + pthread_cond_t condition; + pthread_t thread; + bool thread_started; + bool running; + bool stopping; + Lardon3DTuiSsdAction action; + bool result_known; + Lardon3DSsdControlResult result; + uint64_t generation; + bool refresh_attempt_known; + uint64_t refresh_attempt_ns; + bool controller_snapshot_known; + bool controller_snapshot_actionable; + Lardon3DSsdSnapshot controller_snapshot; + char reason[LARDON3D_SSD_REASON_CAPACITY]; + Lardon3DTuiSsdAsyncProvider provider; + Lardon3DResourceGovernor *governor; + Lardon3DSsdController *controller_identity; + bool external_storage_registered; +}; + +enum { + SSD_TELEMETRY_INTERVAL_NS = 1000000000ULL, +}; + +static bool +production_now(void *context, uint64_t *now_ns) +{ + (void)context; + struct timespec now; + if (!now_ns || clock_gettime(CLOCK_MONOTONIC, &now) != 0 + || now.tv_sec < 0 || now.tv_nsec < 0 || now.tv_nsec >= 1000000000L + || (uint64_t)now.tv_sec > UINT64_MAX / UINT64_C(1000000000)) { + return false; + } + uint64_t seconds = (uint64_t)now.tv_sec * UINT64_C(1000000000); + if (seconds > UINT64_MAX - (uint64_t)now.tv_nsec) { + return false; + } + *now_ns = seconds + (uint64_t)now.tv_nsec; + return true; +} + +static bool +production_snapshot(void *context, Lardon3DSsdSnapshot *snapshot) +{ + return lardon3d_ssd_controller_get_snapshot(context, snapshot); +} + +static Lardon3DSsdControlResult +production_enable(void *context) +{ + return lardon3d_ssd_controller_enable(context); +} + +static Lardon3DSsdControlResult +production_disable(void *context) +{ + return lardon3d_ssd_controller_disable(context); +} + +static bool +production_cancel(void *context) +{ + return lardon3d_ssd_controller_cancel_drain(context); +} + +static const Lardon3DTuiSsdAsyncProviderOps production_ops = { + .monotonic_now_ns = production_now, + .snapshot = production_snapshot, + .enable = production_enable, + .disable = production_disable, + .cancel_drain = production_cancel, +}; + +static bool +valid_controller_snapshot(const Lardon3DSsdSnapshot *snapshot) +{ + Lardon3DResourceExternalStorage storage; + return lardon3d_resource_external_storage_from_ssd_snapshot( + snapshot, &storage); +} + +static Lardon3DSsdSnapshot +invalid_snapshot_error(void) +{ + Lardon3DSsdSnapshot snapshot = {.state = LARDON3D_SSD_ERROR}; + snapshot.scratch_lease_capacity = LARDON3D_SSD_MAX_SCRATCH_LEASES; + (void)snprintf(snapshot.model, sizeof(snapshot.model), "UNKNOWN"); + (void)snprintf(snapshot.serial, sizeof(snapshot.serial), "UNKNOWN"); + (void)snprintf(snapshot.drive_identity, + sizeof(snapshot.drive_identity), "UNKNOWN"); + (void)snprintf(snapshot.swap_uuid, sizeof(snapshot.swap_uuid), "UNKNOWN"); + (void)snprintf(snapshot.scratch_uuid, + sizeof(snapshot.scratch_uuid), "UNKNOWN"); + (void)snprintf(snapshot.swap_device, + sizeof(snapshot.swap_device), "UNKNOWN"); + (void)snprintf(snapshot.scratch_device, + sizeof(snapshot.scratch_device), "UNKNOWN"); + (void)snprintf(snapshot.scratch_mount_path, + sizeof(snapshot.scratch_mount_path), "UNKNOWN"); + (void)snprintf(snapshot.reason, sizeof(snapshot.reason), + "Malformed SSD telemetry; control disabled"); + return snapshot; +} + +static bool +publish_external_error( + Lardon3DTuiSsdAsync *operation, + uint64_t source_generation, + const char *reason +) +{ + if (!operation->external_storage_registered) { + return true; + } + Lardon3DResourceExternalStorage storage; + if (!lardon3d_resource_governor_get_external_storage( + operation->governor, &storage)) { + return false; + } + if (source_generation > storage.generation) { + storage.generation = source_generation; + } + storage.status = LARDON3D_RESOURCE_EXTERNAL_STORAGE_ERROR; + storage.new_scratch_allocations_allowed = false; + (void)snprintf(storage.reason, sizeof(storage.reason), "%s", + reason ? reason : "Malformed SSD telemetry; scratch allocation blocked"); + return lardon3d_resource_governor_update_external_storage( + operation->governor, operation->controller_identity, &storage); +} + +static bool +publish_external_snapshot( + Lardon3DTuiSsdAsync *operation, + const Lardon3DSsdSnapshot *snapshot +) +{ + if (!operation->external_storage_registered) { + return true; + } + Lardon3DResourceExternalStorage storage; + if (!lardon3d_resource_external_storage_from_ssd_snapshot( + snapshot, &storage)) { + return publish_external_error(operation, + snapshot ? snapshot->generation : 0, + "Malformed SSD telemetry; scratch allocation blocked"); + } + if (lardon3d_resource_governor_update_external_storage( + operation->governor, operation->controller_identity, &storage)) { + return true; + } + Lardon3DResourceExternalStorage current; + if (lardon3d_resource_governor_get_external_storage( + operation->governor, ¤t) + && storage.generation < current.generation) { + /* Another serialized controller operation already published stronger + * evidence; a stale worker copy must not revoke or replace it. */ + return true; + } + return publish_external_error(operation, storage.generation, + "Inconsistent SSD generation; scratch allocation blocked"); +} + +const char * +lardon3d_tui_ssd_action_name(Lardon3DTuiSsdAction action) +{ + switch (action) { + case LARDON3D_TUI_SSD_ACTION_NONE: + return "NONE"; + case LARDON3D_TUI_SSD_ACTION_OBSERVE: + return "OBSERVE"; + case LARDON3D_TUI_SSD_ACTION_ENABLE: + return "ENABLE"; + case LARDON3D_TUI_SSD_ACTION_DRAIN: + return "DRAIN"; + case LARDON3D_TUI_SSD_ACTION_CANCEL_DRAIN: + return "CANCEL_DRAIN"; + } + return "UNKNOWN"; +} + +static bool +valid_control_action(Lardon3DTuiSsdAction action) +{ + return action >= LARDON3D_TUI_SSD_ACTION_ENABLE + && action <= LARDON3D_TUI_SSD_ACTION_CANCEL_DRAIN; +} + +static bool +snapshot_authorizes( + const Lardon3DSsdSnapshot *snapshot, + Lardon3DTuiSsdAction action +) +{ + return snapshot + && ((action == LARDON3D_TUI_SSD_ACTION_ENABLE + && snapshot->can_enable) + || (action == LARDON3D_TUI_SSD_ACTION_DRAIN + && snapshot->can_disable) + || (action == LARDON3D_TUI_SSD_ACTION_CANCEL_DRAIN + && snapshot->can_cancel_drain)); +} + +static void +set_result_reason(Lardon3DTuiSsdAsync *operation) +{ + const char *result = operation->result == LARDON3D_SSD_CONTROL_OK + ? "OK" + : (operation->result == LARDON3D_SSD_CONTROL_PENDING + ? "PENDING" : "ERROR"); + (void)snprintf(operation->reason, sizeof(operation->reason), "%s: %s", + lardon3d_tui_ssd_action_name(operation->action), result); +} + +static void * +run_operation(void *userdata) +{ + Lardon3DTuiSsdAsync *operation = userdata; + Lardon3DTuiSsdAction action; + Lardon3DTuiSsdAsyncProvider provider; + (void)pthread_mutex_lock(&operation->mutex); + action = operation->action; + provider = operation->provider; + (void)pthread_mutex_unlock(&operation->mutex); + + Lardon3DSsdControlResult result = LARDON3D_SSD_CONTROL_OK; + if (action == LARDON3D_TUI_SSD_ACTION_ENABLE) { + result = provider.ops->enable(provider.context); + } else if (action == LARDON3D_TUI_SSD_ACTION_DRAIN) { + result = provider.ops->disable(provider.context); + } else if (action == LARDON3D_TUI_SSD_ACTION_CANCEL_DRAIN) { + result = provider.ops->cancel_drain(provider.context) + ? LARDON3D_SSD_CONTROL_OK + : LARDON3D_SSD_CONTROL_ERROR; + } + Lardon3DSsdSnapshot controller_snapshot = {0}; + bool snapshot_result = provider.ops->snapshot( + provider.context, &controller_snapshot); + bool snapshot_valid = valid_controller_snapshot(&controller_snapshot); + bool snapshot_known = snapshot_valid && (snapshot_result + || controller_snapshot.state == LARDON3D_SSD_ERROR); + bool snapshot_actionable = snapshot_known; + if (!snapshot_valid + || (!snapshot_result + && controller_snapshot.state != LARDON3D_SSD_ERROR)) { + controller_snapshot = invalid_snapshot_error(); + snapshot_known = true; + snapshot_actionable = false; + } + /* Provider/controller calls have returned and hold no lock here. Publish + * before advertising worker completion so the next F10/render observes a + * registry state at least as recent as this outcome. */ + if (!publish_external_snapshot(operation, &controller_snapshot)) { + controller_snapshot = invalid_snapshot_error(); + snapshot_known = true; + snapshot_actionable = false; + result = LARDON3D_SSD_CONTROL_ERROR; + } + + (void)pthread_mutex_lock(&operation->mutex); + operation->result = result; + operation->result_known = action != LARDON3D_TUI_SSD_ACTION_OBSERVE; + if (snapshot_known) { + operation->controller_snapshot = controller_snapshot; + operation->controller_snapshot_known = true; + operation->controller_snapshot_actionable = snapshot_actionable; + } + operation->running = false; + operation->generation = operation->generation == UINT64_MAX + ? UINT64_MAX : operation->generation + 1; + if (action == LARDON3D_TUI_SSD_ACTION_OBSERVE) { + if (!snapshot_known) { + (void)snprintf(operation->reason, sizeof(operation->reason), + "SSD telemetry refresh failed"); + } else { + operation->reason[0] = '\0'; + } + } else { + set_result_reason(operation); + } + (void)pthread_cond_broadcast(&operation->condition); + (void)pthread_mutex_unlock(&operation->mutex); + return NULL; +} + +static bool +join_if_complete(Lardon3DTuiSsdAsync *operation) +{ + pthread_t thread; + bool join = false; + (void)pthread_mutex_lock(&operation->mutex); + if (operation->thread_started && !operation->running) { + thread = operation->thread; + operation->thread_started = false; + join = true; + } + (void)pthread_mutex_unlock(&operation->mutex); + return !join || pthread_join(thread, NULL) == 0; +} + +static Lardon3DTuiSsdAsync * +create_with_provider_and_binding( + Lardon3DTuiSsdAsyncProvider provider, + Lardon3DResourceGovernor *governor, + Lardon3DSsdController *controller_identity +) +{ + if (!provider.ops || !provider.ops->monotonic_now_ns + || !provider.ops->snapshot || !provider.ops->enable + || !provider.ops->disable || !provider.ops->cancel_drain + || ((governor == NULL) != (controller_identity == NULL))) { + return NULL; + } + Lardon3DTuiSsdAsync *operation = calloc(1, sizeof(*operation)); + if (!operation || pthread_mutex_init(&operation->mutex, NULL) != 0) { + free(operation); + return NULL; + } + pthread_condattr_t attributes; + bool attributes_ready = pthread_condattr_init(&attributes) == 0; + bool clock_ready = attributes_ready + && pthread_condattr_setclock(&attributes, CLOCK_MONOTONIC) == 0; + if (!attributes_ready || !clock_ready + || pthread_cond_init(&operation->condition, &attributes) != 0) { + if (attributes_ready) { + (void)pthread_condattr_destroy(&attributes); + } + (void)pthread_mutex_destroy(&operation->mutex); + free(operation); + return NULL; + } + (void)pthread_condattr_destroy(&attributes); + operation->provider = provider; + operation->governor = governor; + operation->controller_identity = controller_identity; + if (governor) { + Lardon3DResourceExternalStorage storage = { + .status = LARDON3D_RESOURCE_EXTERNAL_STORAGE_ERROR, + }; + (void)snprintf(storage.stable_identity, + sizeof(storage.stable_identity), "UNKNOWN"); + (void)snprintf(storage.reason, sizeof(storage.reason), + "SSD telemetry has not yet been validated; scratch allocation blocked"); + if (!lardon3d_resource_governor_register_external_storage( + governor, controller_identity, &storage)) { + (void)pthread_cond_destroy(&operation->condition); + (void)pthread_mutex_destroy(&operation->mutex); + free(operation); + return NULL; + } + operation->external_storage_registered = true; + } + (void)snprintf(operation->reason, sizeof(operation->reason), + "No SSD control operation requested"); + return operation; +} + +Lardon3DTuiSsdAsync * +lardon3d_tui_ssd_async_create_with_provider( + Lardon3DTuiSsdAsyncProvider provider +) +{ + return create_with_provider_and_binding(provider, NULL, NULL); +} + +Lardon3DTuiSsdAsync * +lardon3d_tui_ssd_async_internal_create_with_provider_and_governor( + Lardon3DTuiSsdAsyncProvider provider, + Lardon3DResourceGovernor *governor, + Lardon3DSsdController *controller_identity +) +{ + return create_with_provider_and_binding( + provider, governor, controller_identity); +} + +Lardon3DTuiSsdAsync * +lardon3d_tui_ssd_async_create(Lardon3DSsdController *controller) +{ + if (!controller) { + return NULL; + } + return lardon3d_tui_ssd_async_create_with_provider( + (Lardon3DTuiSsdAsyncProvider) { + .ops = &production_ops, + .context = controller, + }); +} + +Lardon3DTuiSsdAsync * +lardon3d_tui_ssd_async_create_with_governor( + Lardon3DSsdController *controller, + Lardon3DResourceGovernor *governor +) +{ + if (!controller || !governor) { + return NULL; + } + return create_with_provider_and_binding( + (Lardon3DTuiSsdAsyncProvider) { + .ops = &production_ops, + .context = controller, + }, + governor, + controller); +} + +bool +lardon3d_tui_ssd_async_destroy_checked(Lardon3DTuiSsdAsync **owned_operation) +{ + if (!owned_operation || !*owned_operation) { + return false; + } + Lardon3DTuiSsdAsync *operation = *owned_operation; + pthread_t thread; + bool join = false; + (void)pthread_mutex_lock(&operation->mutex); + operation->stopping = true; + if (operation->thread_started) { + thread = operation->thread; + operation->thread_started = false; + join = true; + } + (void)pthread_mutex_unlock(&operation->mutex); + if (join && pthread_join(thread, NULL) != 0) { + return false; + } + if (operation->external_storage_registered) { + /* INVARIANT: a failed unregister leaves every owner and the exact + * controller token alive. The caller retains this stopped object and + * can retry after releasing its Governor-managed leases. */ + if (!lardon3d_resource_governor_unregister_external_storage( + operation->governor, operation->controller_identity)) { + return false; + } + operation->external_storage_registered = false; + } + if (operation->provider.ops->destroy) { + operation->provider.ops->destroy(operation->provider.context); + } + (void)pthread_cond_destroy(&operation->condition); + (void)pthread_mutex_destroy(&operation->mutex); + free(operation); + *owned_operation = NULL; + return true; +} + +void +lardon3d_tui_ssd_async_destroy(Lardon3DTuiSsdAsync *operation) +{ + if (!operation) { + return; + } + Lardon3DTuiSsdAsync *owned = operation; + (void)lardon3d_tui_ssd_async_destroy_checked(&owned); +} + +bool +lardon3d_tui_ssd_async_request( + Lardon3DTuiSsdAsync *operation, + Lardon3DTuiSsdAction action +) +{ + if (!operation || !valid_control_action(action) + || !join_if_complete(operation)) { + return false; + } + uint64_t now_ns; + bool now_known = operation->provider.ops->monotonic_now_ns( + operation->provider.context, &now_ns); + (void)pthread_mutex_lock(&operation->mutex); + if (operation->stopping || operation->running + || operation->thread_started + || !operation->controller_snapshot_known + || !operation->controller_snapshot_actionable + || !snapshot_authorizes(&operation->controller_snapshot, action)) { + (void)pthread_mutex_unlock(&operation->mutex); + return false; + } + operation->action = action; + operation->running = true; + operation->result_known = false; + operation->reason[0] = '\0'; + if (now_known) { + operation->refresh_attempt_known = true; + operation->refresh_attempt_ns = now_ns; + } + int created = pthread_create( + &operation->thread, NULL, run_operation, operation); + if (created != 0) { + operation->running = false; + operation->action = LARDON3D_TUI_SSD_ACTION_NONE; + (void)snprintf(operation->reason, sizeof(operation->reason), + "Cannot start bounded SSD operation thread"); + (void)pthread_mutex_unlock(&operation->mutex); + return false; + } + operation->thread_started = true; + (void)pthread_mutex_unlock(&operation->mutex); + return true; +} + +bool +lardon3d_tui_ssd_async_refresh(Lardon3DTuiSsdAsync *operation) +{ + if (!operation || !join_if_complete(operation)) { + return false; + } + (void)pthread_mutex_lock(&operation->mutex); + if (operation->stopping) { + (void)pthread_mutex_unlock(&operation->mutex); + return false; + } + if (operation->running || operation->thread_started) { + (void)pthread_mutex_unlock(&operation->mutex); + return true; + } + (void)pthread_mutex_unlock(&operation->mutex); + + uint64_t now_ns; + if (!operation->provider.ops->monotonic_now_ns( + operation->provider.context, &now_ns)) { + return false; + } + (void)pthread_mutex_lock(&operation->mutex); + if (operation->stopping) { + (void)pthread_mutex_unlock(&operation->mutex); + return false; + } + if (operation->running || operation->thread_started) { + (void)pthread_mutex_unlock(&operation->mutex); + return true; + } + if (operation->refresh_attempt_known + && now_ns >= operation->refresh_attempt_ns + && now_ns - operation->refresh_attempt_ns + < SSD_TELEMETRY_INTERVAL_NS) { + (void)pthread_mutex_unlock(&operation->mutex); + return true; + } + operation->refresh_attempt_known = true; + operation->refresh_attempt_ns = now_ns; + operation->action = LARDON3D_TUI_SSD_ACTION_OBSERVE; + operation->running = true; + operation->result_known = false; + operation->reason[0] = '\0'; + int created = pthread_create( + &operation->thread, NULL, run_operation, operation); + if (created != 0) { + operation->running = false; + operation->action = LARDON3D_TUI_SSD_ACTION_NONE; + (void)snprintf(operation->reason, sizeof(operation->reason), + "Cannot start bounded SSD telemetry thread"); + (void)pthread_mutex_unlock(&operation->mutex); + return false; + } + operation->thread_started = true; + (void)pthread_mutex_unlock(&operation->mutex); + return true; +} + +bool +lardon3d_tui_ssd_async_poll( + Lardon3DTuiSsdAsync *operation, + Lardon3DTuiSsdAsyncSnapshot *snapshot +) +{ + if (snapshot) { + *snapshot = (Lardon3DTuiSsdAsyncSnapshot) {0}; + } + if (!operation || !snapshot || !join_if_complete(operation)) { + return false; + } + (void)pthread_mutex_lock(&operation->mutex); + *snapshot = (Lardon3DTuiSsdAsyncSnapshot) { + .running = operation->running, + .action = operation->action, + .result_known = operation->result_known, + .result = operation->result, + .generation = operation->generation, + .controller_snapshot_known = operation->controller_snapshot_known, + .controller_snapshot_actionable = + operation->controller_snapshot_actionable, + .controller_snapshot = operation->controller_snapshot, + }; + (void)snprintf(snapshot->reason, sizeof(snapshot->reason), "%s", + operation->reason); + (void)pthread_mutex_unlock(&operation->mutex); + return true; +} + +static bool +deadline_after(uint64_t timeout_ns, struct timespec *deadline) +{ + if (!deadline || clock_gettime(CLOCK_MONOTONIC, deadline) != 0 + || deadline->tv_sec < 0 || deadline->tv_nsec < 0 + || deadline->tv_nsec >= 1000000000L) { + return false; + } + uint64_t seconds = timeout_ns / UINT64_C(1000000000); + uint64_t nanoseconds = timeout_ns % UINT64_C(1000000000); + if (seconds > (uint64_t)INT64_MAX + || deadline->tv_sec > (time_t)(INT64_MAX - (int64_t)seconds)) { + return false; + } + deadline->tv_sec += (time_t)seconds; + deadline->tv_nsec += (long)nanoseconds; + if (deadline->tv_nsec >= 1000000000L) { + if (deadline->tv_sec == (time_t)INT64_MAX) { + return false; + } + ++deadline->tv_sec; + deadline->tv_nsec -= 1000000000L; + } + return true; +} + +bool +lardon3d_tui_ssd_async_wait_idle( + Lardon3DTuiSsdAsync *operation, + uint64_t timeout_ns +) +{ + if (!operation) { + return false; + } + struct timespec deadline; + if (!deadline_after(timeout_ns, &deadline)) { + return false; + } + (void)pthread_mutex_lock(&operation->mutex); + while (operation->running) { + int result = pthread_cond_timedwait( + &operation->condition, &operation->mutex, &deadline); + if (result == ETIMEDOUT) { + (void)pthread_mutex_unlock(&operation->mutex); + return false; + } + if (result != 0) { + (void)pthread_mutex_unlock(&operation->mutex); + return false; + } + } + (void)pthread_mutex_unlock(&operation->mutex); + return join_if_complete(operation); +} + +bool +lardon3d_tui_ssd_action_for_snapshot( + const Lardon3DSsdSnapshot *snapshot, + Lardon3DTuiSsdAction *action +) +{ + if (action) { + *action = LARDON3D_TUI_SSD_ACTION_NONE; + } + if (!snapshot || !action || !valid_controller_snapshot(snapshot)) { + return false; + } + unsigned int action_count = (unsigned int)snapshot->can_enable + + (unsigned int)snapshot->can_disable + + (unsigned int)snapshot->can_cancel_drain; + if (action_count != 1) { + return false; + } + if (snapshot->can_enable) { + *action = LARDON3D_TUI_SSD_ACTION_ENABLE; + return true; + } + if (snapshot->can_disable) { + *action = LARDON3D_TUI_SSD_ACTION_DRAIN; + return true; + } + if (snapshot->can_cancel_drain) { + *action = LARDON3D_TUI_SSD_ACTION_CANCEL_DRAIN; + return true; + } + return false; +} diff --git a/src/tui_ssd_async_internal.h b/src/tui_ssd_async_internal.h new file mode 100644 index 0000000..b6a1b62 --- /dev/null +++ b/src/tui_ssd_async_internal.h @@ -0,0 +1,17 @@ +#ifndef LARDON3D_TUI_SSD_ASYNC_INTERNAL_H +#define LARDON3D_TUI_SSD_ASYNC_INTERNAL_H + +#include + +/* Deterministic provider seam for testing Governor publication without a real + * D-Bus controller. `controller_identity` is an opaque borrowed registry token + * and is never dereferenced by this object. Production uses the public bound + * constructor with the real controller object. */ +Lardon3DTuiSsdAsync * +lardon3d_tui_ssd_async_internal_create_with_provider_and_governor( + Lardon3DTuiSsdAsyncProvider provider, + Lardon3DResourceGovernor *governor, + Lardon3DSsdController *controller_identity +); + +#endif diff --git a/src/visual_index.c b/src/visual_index.c index 6b5abf9..ededa47 100644 --- a/src/visual_index.c +++ b/src/visual_index.c @@ -24,6 +24,7 @@ enum { SEGMENT_POSTING_MAX = LARDON3D_VISUAL_INDEX_SEGMENT_FEATURE_SET_MAX * 1024 * LARDON3D_VISUAL_INDEX_TABLE_COUNT, FEATURE_SET_POSTING_MAX = 1024 * LARDON3D_VISUAL_INDEX_TABLE_COUNT, + VISUAL_INDEX_CHILD_STACK_BYTES = 512 * 1024, }; typedef struct { @@ -249,6 +250,17 @@ static Lardon3DVisualIndexResult build_postings( PostingBuildJob jobs[LARDON3D_VISUAL_INDEX_SEGMENT_FEATURE_SET_MAX]; pthread_t children[LARDON3D_VISUAL_INDEX_SEGMENT_FEATURE_SET_MAX - 1]; bool created[LARDON3D_VISUAL_INDEX_SEGMENT_FEATURE_SET_MAX - 1] = {false}; + pthread_attr_t attributes; + if (pthread_attr_init(&attributes) != 0) { + return LARDON3D_VISUAL_INDEX_IO_ERROR; + } + /* RESOURCE: each child only reads one immutable Feature File slice. Bound + * its stack explicitly so the Task's existing 2 MiB per-member reservation + * accounts for the complete 16-participant segment on every host. */ + if (pthread_attr_setstacksize(&attributes, VISUAL_INDEX_CHILD_STACK_BYTES) != 0) { + (void)pthread_attr_destroy(&attributes); + return LARDON3D_VISUAL_INDEX_IO_ERROR; + } for (size_t participant = 0; participant < participants; ++participant) { jobs[participant] = (PostingBuildJob){.project_path = project_path, .sets = sets, @@ -262,9 +274,10 @@ static Lardon3DVisualIndexResult build_postings( } for (size_t participant = 1; participant < participants; ++participant) { created[participant - 1] = - pthread_create(&children[participant - 1], NULL, build_posting_slices, + pthread_create(&children[participant - 1], &attributes, build_posting_slices, &jobs[participant]) == 0; } + bool attributes_released = pthread_attr_destroy(&attributes) == 0; (void)build_posting_slices(&jobs[0]); bool joined = true; for (size_t participant = 1; participant < participants; ++participant) { @@ -272,7 +285,7 @@ static Lardon3DVisualIndexResult build_postings( joined = false; } } - if (!joined) { + if (!joined || !attributes_released) { return LARDON3D_VISUAL_INDEX_IO_ERROR; } /* A failed pthread_create does not change results: the owner computes only diff --git a/src/visual_index_task.c b/src/visual_index_task.c index aa22ba7..3bc7fdf 100644 --- a/src/visual_index_task.c +++ b/src/visual_index_task.c @@ -11,10 +11,8 @@ #include #include -#include "visual_index_internal.h" #include "task_internal.h" - -enum { VISUAL_INDEX_TASK_CPU_THREADS = 12 }; +#include "visual_index_internal.h" typedef struct { char project_path[PATH_MAX]; @@ -70,7 +68,8 @@ static bool run(Lardon3DTask *task, void *userdata) { Lardon3DTaskExecutionContract contract; if (!lardon3d_task_execution_contract(task, &contract) || contract.batch_size == 0 || contract.batch_size > LARDON3D_VISUAL_INDEX_SEGMENT_FEATURE_SET_MAX || - contract.cpu_threads == 0 || contract.cpu_threads > VISUAL_INDEX_TASK_CPU_THREADS) { + contract.cpu_threads == 0 || + contract.cpu_threads > LARDON3D_VISUAL_INDEX_SEGMENT_FEATURE_SET_MAX) { return lardon3d_task_fail(task, "Contrat Visual Index invalide."); } struct timespec begin = {0}; @@ -98,10 +97,11 @@ static bool run(Lardon3DTask *task, void *userdata) { context->governor, LARDON3D_RESOURCE_TASK_CPU, durable_indexed, duration_ns, 0); } - /* Visual Index already consumes the immutable admitted CPU count. A - * segment whose directory publication is not durable remains visible for - * restart semantics but is zero operational work, so it cannot train the - * next sequence's 1/2/4/8/12 CPU trial. */ + /* ALGORITHMIC: one participant owns one disjoint Feature Set slice, so the + * immutable admitted CPU count cannot exceed the 16-member segment bound. + * A segment whose directory publication is not durable remains visible for + * restart semantics but is zero operational work and cannot train the next + * sequence's portable CPU trial. */ if (duration_ns > 0) { (void)lardon3d_task_internal_record_sequence( task, duration_ns, durable_indexed); @@ -206,10 +206,16 @@ Lardon3DTask *lardon3d_project_create_visual_index_update_task( return NULL; } Lardon3DResourceEstimate estimate = {.memory_fixed_bytes = 8ULL * 1024 * 1024, + /* RESOURCE: includes each bounded 512 KiB reader stack and + * its per-Feature-Set staging/reader state. */ .memory_bytes_per_item = 2ULL * 1024 * 1024, .minimum_batch_size = 1, - .maximum_batch_size = 16, - .desired_cpu_threads = VISUAL_INDEX_TASK_CPU_THREADS, + .maximum_batch_size = + LARDON3D_VISUAL_INDEX_SEGMENT_FEATURE_SET_MAX, + /* ALGORITHMIC: no segment contains more + * independent work than this bound. */ + .desired_cpu_threads = + LARDON3D_VISUAL_INDEX_SEGMENT_FEATURE_SET_MAX, .desired_io_slots = 1, .task_class = LARDON3D_RESOURCE_TASK_CPU}; Lardon3DTask *task = lardon3d_task_create_typed( diff --git a/tests/pre_sfm_real_execution.cpp b/tests/pre_sfm_real_execution.cpp index 1577ff8..60ca825 100644 --- a/tests/pre_sfm_real_execution.cpp +++ b/tests/pre_sfm_real_execution.cpp @@ -1184,11 +1184,14 @@ bool end_geometric_evidence(Runtime &runtime) { const bool contract_valid = last_known && last.backend == LARDON3D_RESOURCE_BACKEND_FIXED && last.actual_backend == LARDON3D_RESOURCE_BACKEND_FIXED && - !last.backend_fallback && last.cpu_threads == 1 && + !last.backend_fallback && last.cpu_threads >= 1 && + last.cpu_threads <= + LARDON3D_GEOMETRIC_VERIFIER_TASK_VALIDATED_USEFUL_CPU_THREADS && last.gpu_slots == 0 && last.io_slots == 1 && last.batch_size >= LARDON3D_GEOMETRIC_VERIFIER_TASK_MINIMUM_BATCH && last.batch_size <= LARDON3D_GEOMETRIC_VERIFIER_TASK_MAXIMUM_BATCH && - last.memory_bytes == UINT64_C(4) * 1024 * 1024 && + last.memory_bytes == + UINT64_C(8) * 1024 * 1024 * last.batch_size && last.gpu_memory_bytes == 0; const bool admission_valid = aggregate_known && !aggregate.saturated && diff --git a/tests/test_acquisition_campaign_task.cpp b/tests/test_acquisition_campaign_task.cpp index 2736de0..a00aee6 100644 --- a/tests/test_acquisition_campaign_task.cpp +++ b/tests/test_acquisition_campaign_task.cpp @@ -1,18 +1,28 @@ #include #include #include +#include +#include #include #include +#include +#include + extern "C" { #include #include +#include #include "../src/task_internal.h" } extern "C" bool lardon3d_acquisition_campaign_task_internal_configure_restored( Lardon3DTask *, void *); +extern "C" bool lardon3d_acquisition_campaign_task_test_configure_execution( + void *, const uint64_t *, size_t, uint32_t, unsigned int); +extern "C" bool lardon3d_acquisition_campaign_task_test_observed_threads( + void *, unsigned int *, size_t, size_t *); #define CHECK(x) \ do { \ @@ -27,6 +37,42 @@ lardon3d_task_kind_registry_production(void) { return nullptr; } +static bool completed_task_callback(Lardon3DTask *, void *) { return true; } + +static bool query_int64(sqlite3 *database, const std::string &sql, + sqlite3_int64 *value) { + sqlite3_stmt *statement = nullptr; + if (!database || !value || + sqlite3_prepare_v2(database, sql.c_str(), -1, &statement, nullptr) != + SQLITE_OK) + return false; + const int code = sqlite3_step(statement); + const bool valid = code == SQLITE_ROW && + sqlite3_column_type(statement, 0) == SQLITE_INTEGER; + if (valid) + *value = sqlite3_column_int64(statement, 0); + return sqlite3_finalize(statement) == SQLITE_OK && valid; +} + +static bool query_text(sqlite3 *database, const std::string &sql, + std::string *value) { + sqlite3_stmt *statement = nullptr; + if (!database || !value || + sqlite3_prepare_v2(database, sql.c_str(), -1, &statement, nullptr) != + SQLITE_OK) + return false; + const int code = sqlite3_step(statement); + const unsigned char *text = code == SQLITE_ROW + ? sqlite3_column_text(statement, 0) + : nullptr; + const int bytes = code == SQLITE_ROW ? sqlite3_column_bytes(statement, 0) : 0; + const bool valid = code == SQLITE_ROW && text && bytes >= 0; + if (valid) + value->assign(reinterpret_cast(text), + static_cast(bytes)); + return sqlite3_finalize(statement) == SQLITE_OK && valid; +} + int main() { Lardon3DAcquisitionCampaignSource sources[2]{}; std::snprintf(sources[0].path, sizeof(sources[0].path), "/input/a.arw"); @@ -53,6 +99,11 @@ int main() { input.ingest_options.select_representation = 1; input.ingest_options.imported_at = 123; input.ingest_options.max_source_bytes = 456; + Lardon3DAcquisitionCampaignPlan input_plan{}; + CHECK(lardon3d_acquisition_campaign_plan( + sources, 2, &confirmation, 1, &input_plan) == + LARDON3D_ACQUISITION_CAMPAIGN_OK && + input_plan.group_count == 1); size_t size = 0; CHECK( @@ -61,6 +112,21 @@ int main() { std::vector encoded(size); CHECK(lardon3d_acquisition_campaign_request_encode(&input, encoded.data(), encoded.size(), &size)); + size_t required_size = 777; + std::vector insufficient(encoded.size() - 1u); + CHECK(!lardon3d_acquisition_campaign_request_encode( + &input, insufficient.data(), insufficient.size(), &required_size) && + required_size == encoded.size()); + required_size = 777; + CHECK(!lardon3d_acquisition_campaign_request_encode( + &input, nullptr, 1, &required_size) && + required_size == encoded.size()); + required_size = 777; + CHECK(!lardon3d_acquisition_campaign_request_encode( + nullptr, nullptr, 0, &required_size) && + required_size == 0); + CHECK(!lardon3d_acquisition_campaign_request_encode( + &input, nullptr, 0, nullptr)); Lardon3DAcquisitionCampaignSource decoded_sources[2]{}; Lardon3DAcquisitionCampaignConfirmation decoded_confirmations[2]{}; Lardon3DAcquisitionCampaignTaskRequest decoded{}; @@ -93,8 +159,10 @@ int main() { static_cast(uint64_t(123) >> (8u * i)); input.ingest_options.imported_at = -1; - CHECK(!lardon3d_acquisition_campaign_request_encode(&input, nullptr, 0, - &size)); + required_size = 777; + CHECK(!lardon3d_acquisition_campaign_request_encode( + &input, nullptr, 0, &required_size) && + required_size == 0); input.ingest_options.imported_at = 123; input.ingest_options.max_source_bytes = 0; CHECK(!lardon3d_acquisition_campaign_request_encode(&input, nullptr, 0, @@ -126,16 +194,33 @@ int main() { LARDON3D_PROJECT_DB_OK); CHECK(lardon3d_project_db_create_scanset(database, "other", &other) == LARDON3D_PROJECT_DB_OK); + uint64_t allocated_task_id = 0; + CHECK(lardon3d_project_db_allocate_task_id(database, &allocated_task_id) == + LARDON3D_PROJECT_DB_OK && + allocated_task_id == 1); Lardon3DTaskDurableSnapshot snapshot{}; snapshot.id = 1; std::snprintf(snapshot.name, sizeof(snapshot.name), "campaign"); snapshot.saved_state = snapshot.recovery_state = TASK_PENDING; Lardon3DProjectDbAcquisitionCampaignTask durable{ - 1, target.scanset_id, 0, 2, encoded.data(), encoded.size()}; + 1, target.scanset_id, 0, 1, encoded.data(), encoded.size()}; CHECK(lardon3d_project_db_record_acquisition_campaign_task( database, &snapshot, LARDON3D_ACQUISITION_CAMPAIGN_TASK_KIND, LARDON3D_ACQUISITION_CAMPAIGN_TASK_KIND_VERSION, nullptr, &durable, 1) == LARDON3D_PROJECT_DB_OK); + uint64_t mismatched_task_id = 0; + CHECK(lardon3d_project_db_allocate_task_id(database, &mismatched_task_id) == + LARDON3D_PROJECT_DB_OK && + mismatched_task_id == 2); + Lardon3DTaskDurableSnapshot mismatched_snapshot = snapshot; + mismatched_snapshot.id = mismatched_task_id; + Lardon3DProjectDbAcquisitionCampaignTask mismatched_durable = durable; + mismatched_durable.task_id = mismatched_task_id; + /* Typed business persistence must never be recorded under another generic + * dispatch identity, even when every other field is valid. */ + CHECK(lardon3d_project_db_record_acquisition_campaign_task( + database, &mismatched_snapshot, "photo_quality.triage", 1, nullptr, + &mismatched_durable, 1) == LARDON3D_PROJECT_DB_INVALID_ARGUMENT); std::vector changed = encoded; changed.back() ^= 1u; snapshot.progress = 50; @@ -170,17 +255,37 @@ int main() { CHECK(lardon3d_project_db_retain_acquisition_campaign_capture( database, 1, 1, second.capture_id, 1) == LARDON3D_PROJECT_DB_CONSTRAINT); + Lardon3DProjectDbAcquisitionCampaignTask measured_task{}; + CHECK(lardon3d_project_db_load_acquisition_campaign_task( + database, 1, nullptr, 0, &measured_task) == LARDON3D_PROJECT_DB_OK); + CHECK(measured_task.request == nullptr && + measured_task.request_size == encoded.size()); std::vector loaded(encoded.size()); Lardon3DProjectDbAcquisitionCampaignTask loaded_task{}; CHECK(lardon3d_project_db_load_acquisition_campaign_task( database, 1, loaded.data(), loaded.size(), &loaded_task) == LARDON3D_PROJECT_DB_OK); - CHECK(loaded_task.next_group_id == 1 && loaded_task.group_count == 2 && + CHECK(loaded_task.next_group_id == 1 && loaded_task.group_count == 1 && loaded_task.request_size == encoded.size() && std::memcmp(loaded.data(), encoded.data(), encoded.size()) == 0); - snapshot.estimate = Lardon3DResourceEstimate{ - 256 * 1024, 0, 64 * 1024, 0, 1, 1, 1, 0, 1, - LARDON3D_RESOURCE_TASK_IMPORT}; + std::vector short_request(encoded.size() - 1u); + Lardon3DProjectDbAcquisitionCampaignTask short_task{}; + CHECK(lardon3d_project_db_load_acquisition_campaign_task( + database, 1, short_request.data(), short_request.size(), + &short_task) == LARDON3D_PROJECT_DB_CONSTRAINT); + CHECK(short_task.task_id == 0 && short_task.request == nullptr && + short_task.request_size == 0); + Lardon3DProjectDbAcquisitionCampaignCapture loaded_capture{}; + CHECK(lardon3d_project_db_load_acquisition_campaign_capture( + database, 1, 1, &loaded_capture) == LARDON3D_PROJECT_DB_OK && + loaded_capture.capture_id == first.capture_id); + CHECK(lardon3d_project_db_load_acquisition_campaign_capture( + database, 1, 2, &loaded_capture) == + LARDON3D_PROJECT_DB_CONSTRAINT); + CHECK(lardon3d_project_db_load_acquisition_campaign_capture( + database, 1, 0, &loaded_capture) == + LARDON3D_PROJECT_DB_INVALID_ARGUMENT); + Lardon3DHardwareProfile profile{16, 4096, UINT64_MAX, false, 0, false, false, 0, "test", ""}; Lardon3DResourcePolicy policy{}; @@ -190,8 +295,309 @@ int main() { Lardon3DResourceGovernor *governor = lardon3d_resource_governor_create(&profile, &policy); CHECK(governor != nullptr); - Lardon3DTaskReconstructionContext valid_reconstruction{ + + sqlite3 *corruptor = nullptr; + CHECK(sqlite3_open(database_path, &corruptor) == SQLITE_OK); + CHECK(sqlite3_exec(corruptor, "PRAGMA ignore_check_constraints=ON", nullptr, + nullptr, nullptr) == SQLITE_OK); + const auto execute_sql = [&](const std::string &sql) { + return sqlite3_exec(corruptor, sql.c_str(), nullptr, nullptr, nullptr) == + SQLITE_OK; + }; + const auto campaign_sql = [&](const std::string &assignment) { + const std::string sql = "UPDATE acquisition_campaign_tasks SET " + + assignment + " WHERE task_id=1"; + return execute_sql(sql); + }; + const auto generic_sql = [&](const std::string &assignment) { + return execute_sql("UPDATE tasks SET " + assignment + " WHERE task_id=1"); + }; + const std::string valid_campaign_empty = + "scanset_id=" + std::to_string(target.scanset_id) + + ",next_group_id=0,group_count=1,request=CAST(request AS BLOB)"; + const auto restore_empty_prefix = [&]() { + return execute_sql( + "DELETE FROM acquisition_campaign_captures WHERE task_id=1") && + campaign_sql(valid_campaign_empty) && + generic_sql("task_kind='acquisition_campaign.run'," + "task_kind_version=1"); + }; + const auto restore_valid_prefix = [&]() { + return restore_empty_prefix() && + execute_sql( + "INSERT INTO acquisition_campaign_captures" + "(task_id,group_id,capture_id) VALUES(1,1," + + std::to_string(first.capture_id) + ")") && + campaign_sql("next_group_id=1"); + }; + + Lardon3DTaskReconstructionContext count_reconstruction{ "/tmp", database, governor, nullptr}; + Lardon3DTaskKindBinding count_binding{}; + Lardon3DProjectDbAcquisitionCampaignTask corrupt_task{}; + + /* SQLite dynamic types and signed-wide values must be rejected before the + * retention transaction inserts a mapping or advances the cursor. */ + const std::vector campaign_corruptions{ + "scanset_id=-1", + "scanset_id=9223372036854775807", + "scanset_id=1.5", + "scanset_id='1x'", + "next_group_id=-1", + "next_group_id=9223372036854775807", + "next_group_id=1.5", + "next_group_id='0x'", + "group_count=-1", + "group_count=0", + "group_count=4097", + "group_count=9223372036854775807", + "group_count=1.5", + "group_count='1x'", + "request=CAST(request AS TEXT)", + }; + for (const std::string &assignment : campaign_corruptions) { + CHECK(restore_empty_prefix() && campaign_sql(assignment)); + std::string cursor_before, cursor_after; + sqlite3_int64 mapping_count_before = -1; + sqlite3_int64 mapping_count_after = -1; + CHECK(query_text(corruptor, + "SELECT quote(next_group_id) FROM " + "acquisition_campaign_tasks WHERE task_id=1", + &cursor_before)); + CHECK(query_int64(corruptor, + "SELECT COUNT(*) FROM acquisition_campaign_captures " + "WHERE task_id=1", + &mapping_count_before)); + CHECK(lardon3d_project_db_load_acquisition_campaign_task( + database, 1, loaded.data(), loaded.size(), &corrupt_task) == + LARDON3D_PROJECT_DB_CORRUPT); + CHECK(lardon3d_project_db_retain_acquisition_campaign_capture( + database, 1, 1, first.capture_id, 1) == + LARDON3D_PROJECT_DB_CORRUPT); + CHECK(query_text(corruptor, + "SELECT quote(next_group_id) FROM " + "acquisition_campaign_tasks WHERE task_id=1", + &cursor_after)); + CHECK(query_int64(corruptor, + "SELECT COUNT(*) FROM acquisition_campaign_captures " + "WHERE task_id=1", + &mapping_count_after)); + CHECK(cursor_after == cursor_before && mapping_count_before == 0 && + mapping_count_after == 0); + } + + const std::vector generic_corruptions{ + "task_kind='wrong'", + "task_kind=1", + "task_kind=1.5", + "task_kind=task_kind||char(0)||'tail'", + "task_kind_version=-1", + "task_kind_version=2", + "task_kind_version=9223372036854775807", + "task_kind_version=1.5", + "task_kind_version='1x'", + }; + for (const std::string &assignment : generic_corruptions) { + CHECK(restore_empty_prefix() && generic_sql(assignment)); + sqlite3_int64 mapping_count = -1; + CHECK(lardon3d_project_db_load_acquisition_campaign_task( + database, 1, loaded.data(), loaded.size(), &corrupt_task) == + LARDON3D_PROJECT_DB_CORRUPT); + loaded_capture = {99, 99}; + CHECK(lardon3d_project_db_load_acquisition_campaign_capture( + database, 1, 1, &loaded_capture) == + LARDON3D_PROJECT_DB_CORRUPT && + loaded_capture.group_id == 0 && loaded_capture.capture_id == 0); + CHECK(lardon3d_project_db_retain_acquisition_campaign_capture( + database, 1, 1, first.capture_id, 1) == + LARDON3D_PROJECT_DB_CORRUPT); + CHECK(query_int64(corruptor, + "SELECT COUNT(*) FROM acquisition_campaign_captures " + "WHERE task_id=1", + &mapping_count) && + mapping_count == 0); + } + + /* A missing prefix and an ahead mapping are corruption, not resumable + * identity. Mapping output stays zero and reconstruction installs nothing. */ + CHECK(restore_empty_prefix() && campaign_sql("next_group_id=1")); + CHECK(lardon3d_project_db_load_acquisition_campaign_task( + database, 1, loaded.data(), loaded.size(), &corrupt_task) == + LARDON3D_PROJECT_DB_CORRUPT); + loaded_capture = {99, 99}; + CHECK(lardon3d_project_db_load_acquisition_campaign_capture( + database, 1, 1, &loaded_capture) == + LARDON3D_PROJECT_DB_CORRUPT && + loaded_capture.group_id == 0 && loaded_capture.capture_id == 0); + CHECK(lardon3d_project_db_retain_acquisition_campaign_capture( + database, 1, 1, wrong.capture_id, 1) == + LARDON3D_PROJECT_DB_CORRUPT); + sqlite3_int64 missing_mapping_count = -1; + CHECK(query_int64(corruptor, + "SELECT COUNT(*) FROM acquisition_campaign_captures " + "WHERE task_id=1", + &missing_mapping_count) && + missing_mapping_count == 0); + count_binding = {}; + CHECK(!lardon3d_acquisition_campaign_task_reconstruct( + &snapshot, &count_reconstruction, &count_binding)); + + CHECK(restore_valid_prefix() && campaign_sql("next_group_id=0")); + CHECK(lardon3d_project_db_load_acquisition_campaign_task( + database, 1, loaded.data(), loaded.size(), &corrupt_task) == + LARDON3D_PROJECT_DB_CORRUPT); + loaded_capture = {99, 99}; + CHECK(lardon3d_project_db_load_acquisition_campaign_capture( + database, 1, 1, &loaded_capture) == + LARDON3D_PROJECT_DB_CORRUPT && + loaded_capture.capture_id == 0); + + CHECK(restore_valid_prefix() && + execute_sql( + "INSERT INTO acquisition_campaign_captures" + "(task_id,group_id,capture_id) VALUES(1,2," + + std::to_string(second.capture_id) + ")")); + loaded_capture = {99, 99}; + CHECK(lardon3d_project_db_load_acquisition_campaign_capture( + database, 1, 1, &loaded_capture) == + LARDON3D_PROJECT_DB_CORRUPT && + loaded_capture.capture_id == 0); + + CHECK(restore_valid_prefix() && + execute_sql("UPDATE acquisition_campaign_captures SET capture_id=" + + std::to_string(wrong.capture_id) + + " WHERE task_id=1 AND group_id=1")); + CHECK(lardon3d_project_db_load_acquisition_campaign_task( + database, 1, loaded.data(), loaded.size(), &corrupt_task) == + LARDON3D_PROJECT_DB_CORRUPT); + loaded_capture = {99, 99}; + CHECK(lardon3d_project_db_load_acquisition_campaign_capture( + database, 1, 1, &loaded_capture) == + LARDON3D_PROJECT_DB_CORRUPT && + loaded_capture.capture_id == 0); + CHECK(lardon3d_project_db_retain_acquisition_campaign_capture( + database, 1, 1, first.capture_id, 1) == + LARDON3D_PROJECT_DB_CORRUPT); + std::string retained_capture; + CHECK(query_text(corruptor, + "SELECT quote(capture_id) FROM " + "acquisition_campaign_captures WHERE task_id=1", + &retained_capture) && + retained_capture == std::to_string(wrong.capture_id)); + + const std::vector mapping_corruptions{ + "group_id=0", + "group_id=-1", + "group_id=1.5", + "group_id='bad'", + "capture_id=-1", + "capture_id=1.5", + "capture_id='bad'", + "capture_id=9223372036854775807", + }; + for (const std::string &assignment : mapping_corruptions) { + CHECK(restore_valid_prefix() && + execute_sql("UPDATE acquisition_campaign_captures SET " + + assignment + " WHERE task_id=1")); + std::string mapping_before, mapping_after; + CHECK(query_text( + corruptor, + "SELECT quote(group_id)||':'||quote(capture_id) FROM " + "acquisition_campaign_captures WHERE task_id=1", + &mapping_before)); + CHECK(lardon3d_project_db_load_acquisition_campaign_task( + database, 1, loaded.data(), loaded.size(), &corrupt_task) == + LARDON3D_PROJECT_DB_CORRUPT); + loaded_capture = {99, 99}; + CHECK(lardon3d_project_db_load_acquisition_campaign_capture( + database, 1, 1, &loaded_capture) == + LARDON3D_PROJECT_DB_CORRUPT && + loaded_capture.capture_id == 0); + CHECK(lardon3d_project_db_retain_acquisition_campaign_capture( + database, 1, 1, first.capture_id, 1) == + LARDON3D_PROJECT_DB_CORRUPT); + CHECK(query_text( + corruptor, + "SELECT quote(group_id)||':'||quote(capture_id) FROM " + "acquisition_campaign_captures WHERE task_id=1", + &mapping_after)); + CHECK(mapping_after == mapping_before); + } + + /* A row-level count can be within v20 bounds yet disagree with the plan + * deterministically decoded from the immutable request. Recovery must reject + * that relation before installing callback ownership. */ + CHECK(restore_valid_prefix() && campaign_sql("group_count=2")); + CHECK(lardon3d_project_db_load_acquisition_campaign_task( + database, 1, loaded.data(), loaded.size(), &corrupt_task) == + LARDON3D_PROJECT_DB_OK); + count_binding = {}; + CHECK(!lardon3d_acquisition_campaign_task_reconstruct( + &snapshot, &count_reconstruction, &count_binding)); + CHECK(restore_valid_prefix()); + CHECK(sqlite3_close(corruptor) == SQLITE_OK); + char project_directory[] = "/tmp/lardon3d-campaign-project-XXXXXX"; + CHECK(mkdtemp(project_directory) != nullptr); + CHECK(std::filesystem::create_directories( + std::string(project_directory) + "/.lardon3d/checkpoints")); + Lardon3DAppState app_state{}; + lardon3d_app_state_init(&app_state); + app_state.project_loaded = true; + app_state.project_db = database; + app_state.resource_governor = governor; + std::snprintf(app_state.project_path, sizeof(app_state.project_path), "%s", + project_directory); + uint64_t invalid_enqueue_id = UINT64_MAX; + CHECK(!lardon3d_project_enqueue_acquisition_campaign( + &app_state, target.scanset_id, &input, &invalid_enqueue_id) && + invalid_enqueue_id == 0); + invalid_enqueue_id = UINT64_MAX; + CHECK(!lardon3d_project_enqueue_acquisition_campaign( + nullptr, target.scanset_id, &input, &invalid_enqueue_id) && + invalid_enqueue_id == 0); + uint64_t raw_task_id = 0; + Lardon3DTask *raw_task = + lardon3d_project_create_acquisition_campaign_task( + &app_state, target.scanset_id, &input, &raw_task_id); + CHECK(raw_task != nullptr && raw_task_id == 3); + Lardon3DResourceEstimate raw_estimate{}; + CHECK(lardon3d_task_resource_estimate(raw_task, &raw_estimate)); + constexpr uint64_t raw_working_bytes = + UINT64_C(2) * 1024u * 1024u * 1024u; + CHECK(raw_estimate.memory_fixed_bytes > raw_working_bytes + encoded.size() && + raw_estimate.memory_bytes_per_item == 64u * 1024u && + raw_estimate.desired_cpu_threads == 1 && + raw_estimate.task_class == LARDON3D_RESOURCE_TASK_MIXED); + lardon3d_task_destroy(raw_task); + + input.ingest_options.representation = + LARDON3D_ACQUISITION_SELECT_JPEG_SOURCE; + uint64_t jpeg_task_id = 0; + Lardon3DTask *jpeg_task = + lardon3d_project_create_acquisition_campaign_task( + &app_state, target.scanset_id, &input, &jpeg_task_id); + CHECK(jpeg_task != nullptr && jpeg_task_id == 4); + Lardon3DResourceEstimate jpeg_estimate{}; + CHECK(lardon3d_task_resource_estimate(jpeg_task, &jpeg_estimate)); + Lardon3DTaskDurableSnapshot jpeg_snapshot{}; + CHECK(lardon3d_task_durable_snapshot(jpeg_task, &jpeg_snapshot)); + CHECK(jpeg_estimate.memory_fixed_bytes < raw_working_bytes && + raw_estimate.memory_fixed_bytes == + jpeg_estimate.memory_fixed_bytes + raw_working_bytes && + jpeg_estimate.task_class == LARDON3D_RESOURCE_TASK_IMPORT); + lardon3d_task_destroy(jpeg_task); + input.ingest_options.representation = + LARDON3D_ACQUISITION_SELECT_DEVELOP_RAW; + + /* Existing v22 Tasks carry the old operational signature. It is accepted + * exactly, normalized only for admission, and never rewritten as scientific + * or generic Task identity. */ + Lardon3DResourceEstimate legacy_estimate = raw_estimate; + legacy_estimate.memory_fixed_bytes -= raw_working_bytes + encoded.size(); + legacy_estimate.task_class = LARDON3D_RESOURCE_TASK_IMPORT; + snapshot.estimate = legacy_estimate; + Lardon3DTaskReconstructionContext valid_reconstruction{ + project_directory, database, governor, nullptr}; Lardon3DTaskKindBinding recovered{}; CHECK(lardon3d_acquisition_campaign_task_reconstruct( &snapshot, &valid_reconstruction, &recovered)); @@ -209,23 +615,248 @@ int main() { Lardon3DResourceReservationInfo admitted{}; CHECK(reservation && lardon3d_resource_reservation_get_active( governor, reservation, &admitted)); - CHECK(admitted.memory_bytes > 320 * 1024); + CHECK(admitted.memory_bytes > raw_working_bytes); Lardon3DTaskDurableSnapshot unchanged{}; CHECK(lardon3d_task_durable_snapshot(restored, &unchanged)); - CHECK(unchanged.estimate.memory_fixed_bytes == 256 * 1024 && - unchanged.estimate.memory_bytes_per_item == 64 * 1024); + CHECK(unchanged.estimate.memory_fixed_bytes == + legacy_estimate.memory_fixed_bytes && + unchanged.estimate.memory_bytes_per_item == + legacy_estimate.memory_bytes_per_item && + unchanged.estimate.task_class == legacy_estimate.task_class); CHECK(lardon3d_resource_governor_release(governor, reservation)); lardon3d_task_destroy(restored); - lardon3d_resource_governor_destroy(governor); + + Lardon3DProjectDbAcquisitionCampaignTask jpeg_persisted{}; + CHECK(lardon3d_project_db_load_acquisition_campaign_task( + database, jpeg_task_id, nullptr, 0, &jpeg_persisted) == + LARDON3D_PROJECT_DB_OK); + CHECK(jpeg_estimate.memory_fixed_bytes > jpeg_persisted.request_size); + jpeg_snapshot.estimate = jpeg_estimate; + jpeg_snapshot.estimate.memory_fixed_bytes -= jpeg_persisted.request_size; + recovered = {}; + CHECK(lardon3d_acquisition_campaign_task_reconstruct( + &jpeg_snapshot, &valid_reconstruction, &recovered)); + restored = lardon3d_task_restore_typed( + &jpeg_snapshot, LARDON3D_ACQUISITION_CAMPAIGN_TASK_KIND, + LARDON3D_ACQUISITION_CAMPAIGN_TASK_KIND_VERSION, recovered.callback, + recovered.userdata, recovered.userdata_destroy); + CHECK(restored && + lardon3d_acquisition_campaign_task_internal_configure_restored( + restored, recovered.userdata)); + unchanged = {}; + CHECK(lardon3d_task_durable_snapshot(restored, &unchanged) && + unchanged.estimate.memory_fixed_bytes == + jpeg_snapshot.estimate.memory_fixed_bytes && + unchanged.estimate.task_class == LARDON3D_RESOURCE_TASK_IMPORT); + lardon3d_task_destroy(restored); + + snapshot.estimate = raw_estimate; + ++snapshot.estimate.memory_fixed_bytes; + recovered = {}; + CHECK(lardon3d_acquisition_campaign_task_reconstruct( + &snapshot, &valid_reconstruction, &recovered)); + restored = lardon3d_task_restore_typed( + &snapshot, LARDON3D_ACQUISITION_CAMPAIGN_TASK_KIND, + LARDON3D_ACQUISITION_CAMPAIGN_TASK_KIND_VERSION, recovered.callback, + recovered.userdata, recovered.userdata_destroy); + CHECK(restored && + !lardon3d_acquisition_campaign_task_internal_configure_restored( + restored, recovered.userdata)); + lardon3d_task_destroy(restored); + snapshot.estimate = raw_estimate; + + Lardon3DAcquisitionCampaignSource execution_sources[2]{}; + std::snprintf(execution_sources[0].path, + sizeof(execution_sources[0].path), "/input/group-a.jpg"); + std::snprintf(execution_sources[1].path, + sizeof(execution_sources[1].path), "/input/group-b.jpg"); + for (auto &source : execution_sources) { + source.source_kind = source.metadata.source_kind = + LARDON3D_ACQUISITION_SOURCE_JPEG; + source.metadata_result = LARDON3D_ACQUISITION_OK; + source.metadata.policy_version = + LARDON3D_ACQUISITION_PAIRING_POLICY_VERSION; + } + Lardon3DAcquisitionCampaignTaskRequest execution_request{}; + execution_request.sources = execution_sources; + execution_request.source_count = 2; + execution_request.ingest_options.representation = + LARDON3D_ACQUISITION_SELECT_JPEG_SOURCE; + execution_request.ingest_options.select_representation = 1; + execution_request.ingest_options.imported_at = 321; + execution_request.ingest_options.max_source_bytes = 1024; + Lardon3DAcquisitionCampaignPlan execution_plan{}; + CHECK(lardon3d_acquisition_campaign_plan( + execution_sources, 2, nullptr, 0, &execution_plan) == + LARDON3D_ACQUISITION_CAMPAIGN_OK && + execution_plan.group_count == 2); + const uint64_t fixture_captures[2]{first.capture_id, second.capture_id}; + const int previous_opencv_threads = cv::getNumThreads(); + cv::setNumThreads(3); + CHECK(cv::getNumThreads() == 3); + + uint64_t execution_task_id = 0; + Lardon3DTask *execution_task = + lardon3d_project_create_acquisition_campaign_task( + &app_state, target.scanset_id, &execution_request, + &execution_task_id); + CHECK(execution_task && execution_task_id != 0); + Lardon3DTaskDurableSnapshot execution_snapshot{}; + CHECK(lardon3d_task_durable_snapshot(execution_task, &execution_snapshot)); + lardon3d_task_destroy(execution_task); + recovered = {}; + CHECK(lardon3d_acquisition_campaign_task_reconstruct( + &execution_snapshot, &valid_reconstruction, &recovered)); + void *execution_userdata = recovered.userdata; + restored = lardon3d_task_restore_typed( + &execution_snapshot, LARDON3D_ACQUISITION_CAMPAIGN_TASK_KIND, + LARDON3D_ACQUISITION_CAMPAIGN_TASK_KIND_VERSION, recovered.callback, + recovered.userdata, recovered.userdata_destroy); + CHECK(restored && + lardon3d_acquisition_campaign_task_internal_configure_restored( + restored, execution_userdata) && + lardon3d_acquisition_campaign_task_test_configure_execution( + execution_userdata, fixture_captures, 2, 0, 3)); + Lardon3DResourceDecision execution_decision{}; + Lardon3DResourceReservation *execution_reservation = nullptr; + CHECK(lardon3d_task_internal_reserve_available( + restored, governor, &execution_decision, &execution_reservation)); + Lardon3DResourceReservationInfo execution_admission{}; + CHECK(execution_reservation && + lardon3d_resource_reservation_get_active( + governor, execution_reservation, &execution_admission) && + execution_admission.cpu_threads == 1); + CHECK(lardon3d_task_start(restored, governor, execution_reservation)); + Lardon3DTaskObservation execution_completed{}; + CHECK(lardon3d_task_observation(restored, &execution_completed) && + execution_completed.state == TASK_COMPLETED && + execution_completed.durable_progress_known && + execution_completed.durable_completed == 2 && + execution_completed.durable_total == 2 && + lardon3d_task_sequence_count(restored) == 1 && + cv::getNumThreads() == 3); + unsigned int observed_threads[2]{}; + size_t observed_count = 0; + CHECK(lardon3d_acquisition_campaign_task_test_observed_threads( + execution_userdata, observed_threads, 2, &observed_count) && + observed_count == 2 && observed_threads[0] == 1 && + observed_threads[1] == 1); + Lardon3DProjectDbAcquisitionCampaignTask executed_persisted{}; + CHECK(lardon3d_project_db_load_acquisition_campaign_task( + database, execution_task_id, nullptr, 0, &executed_persisted) == + LARDON3D_PROJECT_DB_OK && + executed_persisted.next_group_id == 2 && + executed_persisted.group_count == 2); + Lardon3DProjectDbAcquisitionCampaignCapture execution_capture{}; + CHECK(lardon3d_project_db_load_acquisition_campaign_capture( + database, execution_task_id, 1, &execution_capture) == + LARDON3D_PROJECT_DB_OK && + execution_capture.capture_id == first.capture_id); + CHECK(lardon3d_project_db_load_acquisition_campaign_capture( + database, execution_task_id, 2, &execution_capture) == + LARDON3D_PROJECT_DB_OK && + execution_capture.capture_id == second.capture_id); + lardon3d_task_destroy(restored); + + uint64_t failure_task_id = 0; + execution_task = lardon3d_project_create_acquisition_campaign_task( + &app_state, target.scanset_id, &execution_request, &failure_task_id); + CHECK(execution_task && failure_task_id != 0 && + failure_task_id != execution_task_id); + Lardon3DTaskDurableSnapshot failure_snapshot{}; + CHECK(lardon3d_task_durable_snapshot(execution_task, &failure_snapshot)); + lardon3d_task_destroy(execution_task); + recovered = {}; + CHECK(lardon3d_acquisition_campaign_task_reconstruct( + &failure_snapshot, &valid_reconstruction, &recovered)); + void *failure_userdata = recovered.userdata; + restored = lardon3d_task_restore_typed( + &failure_snapshot, LARDON3D_ACQUISITION_CAMPAIGN_TASK_KIND, + LARDON3D_ACQUISITION_CAMPAIGN_TASK_KIND_VERSION, recovered.callback, + recovered.userdata, recovered.userdata_destroy); + CHECK(restored && + lardon3d_acquisition_campaign_task_internal_configure_restored( + restored, failure_userdata) && + lardon3d_acquisition_campaign_task_test_configure_execution( + failure_userdata, fixture_captures, 2, 2, 3)); + execution_reservation = nullptr; + CHECK(lardon3d_task_internal_reserve_available( + restored, governor, &execution_decision, &execution_reservation)); + CHECK(lardon3d_task_start(restored, governor, execution_reservation)); + CHECK(lardon3d_task_observation(restored, &execution_completed) && + execution_completed.state == TASK_FAILED && + execution_completed.durable_progress_known && + execution_completed.durable_completed == 1 && + execution_completed.durable_total == 2 && + lardon3d_task_sequence_count(restored) == 1 && + cv::getNumThreads() == 3); + observed_count = 0; + observed_threads[0] = observed_threads[1] = 0; + CHECK(lardon3d_acquisition_campaign_task_test_observed_threads( + failure_userdata, observed_threads, 2, &observed_count) && + observed_count == 2 && observed_threads[0] == 1 && + observed_threads[1] == 1); + CHECK(lardon3d_project_db_load_acquisition_campaign_task( + database, failure_task_id, nullptr, 0, &executed_persisted) == + LARDON3D_PROJECT_DB_OK && + executed_persisted.next_group_id == 1); + CHECK(lardon3d_project_db_load_acquisition_campaign_capture( + database, failure_task_id, 1, &execution_capture) == + LARDON3D_PROJECT_DB_OK && + execution_capture.capture_id == first.capture_id); + execution_capture = {}; + CHECK(lardon3d_project_db_load_acquisition_campaign_capture( + database, failure_task_id, 2, &execution_capture) == + LARDON3D_PROJECT_DB_NOT_FOUND && + execution_capture.capture_id == 0); + lardon3d_task_destroy(restored); + cv::setNumThreads(previous_opencv_threads > 0 ? previous_opencv_threads : 1); + + sqlite3 *id_reader = nullptr; + CHECK(sqlite3_open(database_path, &id_reader) == SQLITE_OK); + sqlite3_int64 collision_id = 0; + CHECK(query_int64(id_reader, + "SELECT value FROM metadata WHERE key='next_task_id'", + &collision_id) && + collision_id > 0 && sqlite3_close(id_reader) == SQLITE_OK); + Lardon3DResourceEstimate collision_estimate{ + 1024, 0, 0, 0, 1, 1, 1, 0, 1, LARDON3D_RESOURCE_TASK_IMPORT}; + Lardon3DTask *collision_task = lardon3d_task_create( + "queue collision", &collision_estimate, completed_task_callback, nullptr); + CHECK(collision_task && + lardon3d_task_assign_id(collision_task, + static_cast(collision_id))); + app_state.task_queue = lardon3d_task_queue_create(governor, 4); + CHECK(app_state.task_queue && + lardon3d_task_queue_add(app_state.task_queue, collision_task, nullptr)); + uint64_t rejected_enqueue_id = UINT64_MAX; + CHECK(!lardon3d_project_enqueue_acquisition_campaign( + &app_state, UINT64_MAX, &input, &rejected_enqueue_id) && + rejected_enqueue_id == 0); + uint64_t durable_transfer_failure_id = UINT64_MAX; + CHECK(!lardon3d_project_enqueue_acquisition_campaign( + &app_state, target.scanset_id, &input, + &durable_transfer_failure_id) && + durable_transfer_failure_id == static_cast(collision_id)); + Lardon3DProjectDbAcquisitionCampaignTask transfer_failure{}; + CHECK(lardon3d_project_db_load_acquisition_campaign_task( + database, durable_transfer_failure_id, nullptr, 0, + &transfer_failure) == LARDON3D_PROJECT_DB_OK && + transfer_failure.next_group_id == 0); + lardon3d_task_queue_destroy(app_state.task_queue); + app_state.task_queue = nullptr; + std::vector overlong_project_path(LARDON3D_APP_STATE_PATH_CAPACITY + 1, 'x'); overlong_project_path.back() = '\0'; Lardon3DTaskReconstructionContext reconstruction{ - overlong_project_path.data(), database, nullptr, nullptr}; + overlong_project_path.data(), database, governor, nullptr}; Lardon3DTaskKindBinding binding{}; CHECK(!lardon3d_acquisition_campaign_task_reconstruct( &snapshot, &reconstruction, &binding)); + lardon3d_resource_governor_destroy(governor); lardon3d_project_db_close(database); CHECK(unlink(database_path) == 0); + CHECK(std::filesystem::remove_all(project_directory) > 0); return 0; } diff --git a/tests/test_candidate_pair_gen.c b/tests/test_candidate_pair_gen.c index 3eaeac7..6f20832 100644 --- a/tests/test_candidate_pair_gen.c +++ b/tests/test_candidate_pair_gen.c @@ -289,6 +289,23 @@ static bool run_test(void) { lardon3d_candidate_pair_generation_fingerprint(index_id, feature_sets[0].feature_set_id, &options, fp2); CHECK(memcmp(fp1, fp2, 32) == 0); + static const unsigned char canonical_fingerprint[32] = { + 0x60, 0x61, 0x4b, 0x22, 0xa6, 0x2f, 0xe5, 0x10, + 0x8a, 0x63, 0x07, 0xb1, 0xa1, 0x69, 0x11, 0x29, + 0xd2, 0xee, 0xc4, 0xa4, 0x0e, 0x4f, 0x6c, 0x50, + 0x83, 0x78, 0xc0, 0xfa, 0x23, 0x6d, 0x7b, 0x09, + }; + Lardon3DVisualIndexQueryOptions canonical_options = { + .top_k = 4, + .minimum_evidence_count = 3, + .scanset_filter = LARDON3D_VISUAL_INDEX_OTHER_SCANSETS, + .exclude_same_asset = true, + }; + lardon3d_candidate_pair_generation_fingerprint( + 1, 2, &canonical_options, fp2); + /* This vector equals the acquired x86 v1 output and now also proves that + * native integer/enum/bool representation cannot enter scientific identity. */ + CHECK(memcmp(fp2, canonical_fingerprint, sizeof(fp2)) == 0); options.top_k = 8; lardon3d_candidate_pair_generation_fingerprint(index_id, feature_sets[0].feature_set_id, &options, fp2); diff --git a/tests/test_candidate_pair_task.c b/tests/test_candidate_pair_task.c index 7e13bfe..d3898b7 100644 --- a/tests/test_candidate_pair_task.c +++ b/tests/test_candidate_pair_task.c @@ -159,6 +159,79 @@ static bool same_pair_order(const Lardon3DProjectDbCandidatePair *a, size_t a_co return true; } +static bool same_computation(const Lardon3DCandidatePairComputation *left, + const Lardon3DCandidatePairComputation *right) { + if (left->source_feature_set_id != right->source_feature_set_id || + left->queried_count != right->queried_count || + left->proposal_count != right->proposal_count) { + return false; + } + for (size_t i = 0; i < left->proposal_count; ++i) { + if (left->proposals[i].image_id_a != right->proposals[i].image_id_a || + left->proposals[i].image_id_b != right->proposals[i].image_id_b) { + return false; + } + } + return true; +} + +static bool run_portable_compute_contracts( + Lardon3DAppState *state, uint64_t visual_index_id, + const Lardon3DVisualIndexQueryOptions *options, + const uint64_t fixture_source_ids[3]) { + enum { SOURCE_COUNT = 64 }; + uint64_t sources[SOURCE_COUNT]; + Lardon3DCandidatePairComputation baseline[SOURCE_COUNT]; + Lardon3DCandidatePairComputation parallel[SOURCE_COUNT]; + Lardon3DVisualIndexResult baseline_results[SOURCE_COUNT]; + Lardon3DVisualIndexResult parallel_results[SOURCE_COUNT]; + for (size_t i = 0; i < SOURCE_COUNT; ++i) { + sources[i] = fixture_source_ids[i % 3]; + } + + lardon3d_candidate_pair_task_test_fail_thread_create_after(SIZE_MAX); + lardon3d_candidate_pair_task_test_reset_parallel_counters(); + CHECK(lardon3d_candidate_pair_task_test_compute_window( + state->project_path, state->project_db, visual_index_id, options, + sources, SOURCE_COUNT, 1, baseline, baseline_results) && + lardon3d_candidate_pair_task_test_started_participants() == 1 && + lardon3d_candidate_pair_task_test_computed_work_items() == SOURCE_COUNT && + lardon3d_candidate_pair_task_test_active_private_databases() == 0); + + const unsigned int portable_counts[] = {16, 32, 64}; + for (size_t run = 0; + run < sizeof(portable_counts) / sizeof(portable_counts[0]); ++run) { + lardon3d_candidate_pair_task_test_reset_parallel_counters(); + CHECK(lardon3d_candidate_pair_task_test_compute_window( + state->project_path, state->project_db, visual_index_id, + options, sources, SOURCE_COUNT, portable_counts[run], + parallel, parallel_results) && + lardon3d_candidate_pair_task_test_started_participants() == + portable_counts[run] && + lardon3d_candidate_pair_task_test_computed_work_items() == + SOURCE_COUNT && + lardon3d_candidate_pair_task_test_active_private_databases() == 0); + for (size_t i = 0; i < SOURCE_COUNT; ++i) { + CHECK(baseline_results[i] == LARDON3D_VISUAL_INDEX_OK && + parallel_results[i] == baseline_results[i] && + same_computation(¶llel[i], &baseline[i])); + } + } + + /* Fail after two children have started. The Queue-owner participant and + * those children may compute, but every child is joined and every one of + * the 16 preopened private DB handles is closed before failure returns. */ + lardon3d_candidate_pair_task_test_reset_parallel_counters(); + lardon3d_candidate_pair_task_test_fail_thread_create_after(2); + CHECK(!lardon3d_candidate_pair_task_test_compute_window( + state->project_path, state->project_db, visual_index_id, options, + sources, SOURCE_COUNT, 16, parallel, parallel_results) && + lardon3d_candidate_pair_task_test_started_participants() == 3 && + lardon3d_candidate_pair_task_test_active_private_databases() == 0); + lardon3d_candidate_pair_task_test_fail_thread_create_after(SIZE_MAX); + return true; +} + static bool wait_saved_cursor(Lardon3DProjectDb *database, uint64_t task_id, uint64_t expected) { struct timespec deadline; @@ -315,13 +388,17 @@ static bool run_test(void) { state.task_queue = lardon3d_task_queue_create(state.resource_governor, 4); CHECK(state.task_queue != NULL); - /* --- Test 1: déterminisme exact à 1/2/4 threads admis --- */ + /* --- Test 1: déterminisme exact jusqu'au plafond portable de 64 --- */ Lardon3DVisualIndexQueryOptions qopts = { .top_k = 16, .minimum_evidence_count = 10, .scanset_filter = LARDON3D_VISUAL_INDEX_ANY_SCANSET, .exclude_same_asset = false, }; + const uint64_t portable_fixture_sources[3] = { + fs_a.feature_set_id, fs_b.feature_set_id, fs_c.feature_set_id}; + CHECK(run_portable_compute_contracts(&state, visual_index_id, &qopts, + portable_fixture_sources)); /* A healthy Governor admits two bounded sources per sequence. The six * durable memberships create three productive windows: initial admission @@ -355,11 +432,11 @@ static bool run_test(void) { CHECK(unsetenv("LARDON3D_TEST_CANDIDATE_PAIR_MAXIMUM_BATCH") == 0 && unsetenv("LARDON3D_TEST_CANDIDATE_PAIR_THREADS") == 0); - /* CPU admission does not authorize extra work. With twelve available - * CPUs but an admitted one-item batch, each of the six sequence breaks + /* CPU admission does not authorize extra work. With 64 CPUs requested but + * an admitted one-item batch, each of the six sequence breaks * has exactly one independent source and therefore one useful worker. */ CHECK(reset_candidate_pairs(state.project_db) && - setenv("LARDON3D_TEST_CANDIDATE_PAIR_THREADS", "12", 1) == 0 && + setenv("LARDON3D_TEST_CANDIDATE_PAIR_THREADS", "64", 1) == 0 && setenv("LARDON3D_TEST_CANDIDATE_PAIR_MAXIMUM_BATCH", "1", 1) == 0); lardon3d_candidate_pair_task_test_reset_parallel_counters(); uint64_t pressure_task_id = 0; @@ -375,11 +452,12 @@ static bool run_test(void) { CHECK(unsetenv("LARDON3D_TEST_CANDIDATE_PAIR_MAXIMUM_BATCH") == 0 && unsetenv("LARDON3D_TEST_CANDIDATE_PAIR_THREADS") == 0); - const char *thread_counts[] = {"1", "2", "4"}; + const char *thread_counts[] = {"1", "2", "4", "16", "32", "64"}; uint64_t cp_task_id = 0; Lardon3DProjectDbCandidatePair baseline[32]; size_t baseline_count = 0; - for (size_t run = 0; run < 3; ++run) { + for (size_t run = 0; + run < sizeof(thread_counts) / sizeof(thread_counts[0]); ++run) { CHECK(reset_candidate_pairs(state.project_db) && setenv("LARDON3D_TEST_CANDIDATE_PAIR_THREADS", thread_counts[run], 1) == 0); uint64_t current_task_id = 0; @@ -553,6 +631,30 @@ static bool run_test(void) { }; Lardon3DTask *recovered = NULL; + /* The immediately preceding parallel form used fixed256/per-item64KiB + * with CPU12. It is independent from the oldest fixed128/CPU1 signature + * below and both normalize to the portable, fully accounted estimate. */ + Lardon3DTaskDurableSnapshot historical_parallel = + candidate_recovery->snapshot; + historical_parallel.estimate.memory_fixed_bytes = 256 * 1024; + historical_parallel.estimate.memory_bytes_per_item = 64 * 1024; + historical_parallel.estimate.desired_cpu_threads = 12; + Lardon3DTask *historical_parallel_task = NULL; + CHECK(lardon3d_task_kind_registry_restore( + lardon3d_task_kind_registry_production(), + candidate_recovery->task_kind, + candidate_recovery->task_kind_version, &historical_parallel, + &reconstruction, &historical_parallel_task) == + LARDON3D_TASK_KIND_OK && + historical_parallel_task); + Lardon3DResourceEstimate historical_parallel_effective; + CHECK(lardon3d_task_resource_estimate( + historical_parallel_task, &historical_parallel_effective) && + historical_parallel_effective.memory_bytes_per_item == + 8 * 1024 * 1024 && + historical_parallel_effective.desired_cpu_threads == 64); + lardon3d_task_destroy(historical_parallel_task); + /* Every field belongs to the historical signature. Neighboring estimates * are malformed rather than evidence for legacy operational policy. */ Lardon3DTaskDurableSnapshot near_snapshots[4]; @@ -582,11 +684,11 @@ static bool run_test(void) { const Lardon3DResourceEstimate current_estimate = { .memory_fixed_bytes = 256 * 1024, .gpu_memory_fixed_bytes = 0, - .memory_bytes_per_item = 64 * 1024, + .memory_bytes_per_item = 8 * 1024 * 1024, .gpu_memory_bytes_per_item = 0, .minimum_batch_size = 1, .maximum_batch_size = 64, - .desired_cpu_threads = 12, + .desired_cpu_threads = 64, .desired_gpu_slots = 0, .desired_io_slots = 1, .task_class = LARDON3D_RESOURCE_TASK_CPU, @@ -604,7 +706,7 @@ static bool run_test(void) { lardon3d_resource_governor_reserve( state.resource_governor, &resources, &recovered_estimate, &decision, &reservation) && - decision.cpu_threads == 12 && decision.batch_size >= 1 && + decision.cpu_threads >= 1 && decision.cpu_threads <= 64 && decision.batch_size <= 64 && reservation); lardon3d_resource_governor_release(state.resource_governor, reservation); diff --git a/tests/test_feature_task.c b/tests/test_feature_task.c index 93f8616..9c717bf 100644 --- a/tests/test_feature_task.c +++ b/tests/test_feature_task.c @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -153,7 +154,7 @@ typedef struct { static bool adaptive_extract_callback(Lardon3DTask *task, void *userdata) { AdaptiveExtractWork *work = userdata; Lardon3DOpenCvTaskThreadControl control; - if (!lardon3d_opencv_task_threads_begin(task, 12, &control) || + if (!lardon3d_opencv_task_threads_begin(task, INT_MAX, &control) || lardon3d_feature_opencv_thread_count() != work->expected_threads) { return false; } @@ -175,6 +176,95 @@ static bool adaptive_extract_callback(Lardon3DTask *task, void *userdata) { lardon3d_task_set_progress(task, 100, "Extraction adaptative testée."); } +static bool retry_failed_opencv_restoration(void) { + unsigned int original = lardon3d_feature_opencv_thread_count(); + Lardon3DOpenCvTaskThreadControl control = { + .previous = original, + .restore_required = true, + }; + unsigned int temporary = original == 1 ? 2 : 1; + if (!lardon3d_feature_opencv_configure_threads(temporary)) return false; + char failed_threads[32]; + int written = snprintf(failed_threads, sizeof(failed_threads), "%u", original); + bool ok = written > 0 && (size_t)written < sizeof(failed_threads) && + setenv("LARDON3D_TEST_OPENCV_CONFIGURE_FAILURE_THREADS", failed_threads, 1) == 0; + /* The injected failure occurs after setNumThreads(), so verification must + * retain the restoration obligation even though the observed value changed. */ + ok = ok && !lardon3d_opencv_task_threads_end(&control) && control.restore_required; + ok = unsetenv("LARDON3D_TEST_OPENCV_CONFIGURE_FAILURE_THREADS") == 0 && ok; + ok = lardon3d_opencv_task_threads_end(&control) && !control.restore_required && + lardon3d_feature_opencv_thread_count() == original && ok; + return ok; +} + +static Lardon3DResourceSnapshot deterministic_resource_snapshot(double cpu_load_1m) { + return (Lardon3DResourceSnapshot) { + .memory_available_bytes = UINT64_C(12) * 1024 * 1024 * 1024, + .memory_free_bytes = UINT64_C(8) * 1024 * 1024 * 1024, + .cpu_load_1m = cpu_load_1m, + .cpu_pressure_known = true, + .cpu_pressure_avg10 = 0.0, + .memory_pressure_known = true, + .memory_pressure_avg10 = 0.0, + .io_pressure_known = true, + .io_pressure_avg10 = 0.0, + .swap_activity_known = true, + .swap_pages_in = 0, + .swap_pages_out = 0, + }; +} + +static bool deterministic_capture_preserves_load_gate(void) { + Lardon3DHardwareProfile profile = { + .logical_cpu_count = 5, + .page_size_bytes = 4096, + .memory_total_bytes = UINT64_C(16) * 1024 * 1024 * 1024, + .cpu_architecture = "test", + }; + Lardon3DResourcePolicy policy = { + .system_cpu_reserve = 4, + .maximum_cpu_load_ratio = 1.0, + .maximum_io_pressure_avg10 = 100.0, + .io_slot_capacity = 1, + }; + Lardon3DResourceGovernor *governor = + lardon3d_resource_governor_create(&profile, &policy); + if (!governor) return false; + Lardon3DResourceEstimate estimate = { + .minimum_batch_size = 1, + .maximum_batch_size = 1, + .desired_cpu_threads = 1, + .task_class = LARDON3D_RESOURCE_TASK_CPU, + }; + Lardon3DResourceSnapshot snapshot = deterministic_resource_snapshot(5.0); + Lardon3DResourceDecision decision; + Lardon3DResourceReservation *reservation = NULL; + /* Equality is intentional: production rejects load >= logical CPUs times + * the configured ratio. The seam controls telemetry, not policy. */ + bool ok = lardon3d_resource_governor_internal_set_capture_snapshot( + governor, &snapshot) + && lardon3d_resource_governor_reserve_available( + governor, &estimate, &decision, &reservation) + && decision.kind == LARDON3D_RESOURCE_WAIT + && reservation == NULL + && strcmp(decision.reason, "Charge CPU trop élevée.") == 0; + snapshot = deterministic_resource_snapshot(0.0); + ok = lardon3d_resource_governor_internal_set_capture_snapshot( + governor, &snapshot) + && lardon3d_resource_governor_reserve_available( + governor, &estimate, &decision, &reservation) + && decision.kind == LARDON3D_RESOURCE_START + && reservation != NULL + && ok; + if (reservation) { + ok = lardon3d_resource_governor_release(governor, reservation) && ok; + } + ok = lardon3d_resource_governor_internal_set_capture_snapshot( + governor, NULL) && ok; + lardon3d_resource_governor_destroy(governor); + return ok; +} + static bool run_adaptive_output_equivalence(const char *path) { cpu_set_t allowed_mask; CPU_ZERO(&allowed_mask); @@ -190,7 +280,9 @@ static bool run_adaptive_output_equivalence(const char *path) { } } if (allowed_count == 0) return true; - const unsigned int requested[] = {1, 2, 4, 8, 12}; + /* Counts above the historical host ceiling execute only when the process + * affinity genuinely exposes them; unavailable CPUs are never fabricated. */ + const unsigned int requested[] = {1, 2, 4, 8, 12, 16, 32, 64}; Lardon3DExtractedFeatures baseline[TEST_EXTRACT_COUNT] = {0}; bool have_baseline[TEST_EXTRACT_COUNT] = {false}; unsigned int previous_threads = lardon3d_feature_opencv_thread_count(); @@ -222,10 +314,13 @@ static bool run_adaptive_output_equivalence(const char *path) { for (unsigned int index = 0; index < threads; ++index) { topology.allowed_cpu_ids[index] = allowed_ids[index]; } + Lardon3DResourceSnapshot resources = deterministic_resource_snapshot(0.0); Lardon3DTaskQueue *queue = NULL; if (!governor || !lardon3d_resource_governor_internal_configure_cpu_topology( governor, &topology) || + !lardon3d_resource_governor_internal_set_capture_snapshot( + governor, &resources) || !(queue = lardon3d_task_queue_create(governor, TEST_EXTRACT_COUNT))) { lardon3d_task_queue_destroy(queue); lardon3d_resource_governor_destroy(governor); @@ -237,7 +332,7 @@ static bool run_adaptive_output_equivalence(const char *path) { Lardon3DResourceEstimate estimate = { .minimum_batch_size = 1, .maximum_batch_size = 1, - .desired_cpu_threads = 12, + .desired_cpu_threads = INT_MAX, .task_class = LARDON3D_RESOURCE_TASK_CPU, }; static const char *const task_kinds[TEST_EXTRACT_COUNT] = { @@ -286,6 +381,10 @@ static bool run_adaptive_output_equivalence(const char *path) { lardon3d_extracted_features_destroy(&work[kind].output); } lardon3d_task_queue_destroy(queue); + if (!lardon3d_resource_governor_internal_set_capture_snapshot( + governor, NULL)) { + ok = false; + } lardon3d_resource_governor_destroy(governor); } for (size_t kind = 0; kind < TEST_EXTRACT_COUNT; ++kind) { @@ -301,15 +400,29 @@ static bool runtime(Lardon3DAppState *s) { Lardon3DResourcePolicy p = { .maximum_cpu_load_ratio = 1, .maximum_io_pressure_avg10 = 100, .io_slot_capacity = 2}; s->resource_governor = lardon3d_resource_governor_create(&s->hardware_profile, &p); - s->task_queue = s->resource_governor ? lardon3d_task_queue_create(s->resource_governor, 4) : NULL; - return s->task_queue != NULL; + if (!s->resource_governor) return false; + Lardon3DResourceSnapshot resources = deterministic_resource_snapshot(0.0); + if (!lardon3d_resource_governor_internal_set_capture_snapshot( + s->resource_governor, &resources)) { + lardon3d_resource_governor_destroy(s->resource_governor); + s->resource_governor = NULL; + s->task_queue = NULL; + return false; + } + s->task_queue = lardon3d_task_queue_create(s->resource_governor, 4); + if (!s->task_queue) { + lardon3d_resource_governor_destroy(s->resource_governor); + s->resource_governor = NULL; + return false; + } + return true; } static bool wait_state(Lardon3DTaskQueue *q, uint64_t id, Lardon3DTaskState wanted, Lardon3DTaskSnapshot *out) { struct timespec deadline; if (clock_gettime(CLOCK_MONOTONIC, &deadline) != 0) return false; - /* The equality fixture deliberately executes ORB/SIFT/RootSIFT at five CPU - * contracts. Instrumented OpenCV is slower than the normal build; keep the + /* The equality fixture executes every genuinely available requested CPU + * contract. Instrumented OpenCV is slower than the normal build; keep the * wait bounded without mistaking sanitizer overhead for scientific drift. */ deadline.tv_sec += 30; for (;;) { @@ -354,10 +467,12 @@ static void *consolidate_thread(void *userdata) { static bool run_test(void) { char root[] = "/tmp/lardon3d-feature-task-XXXXXX"; + CHECK(deterministic_capture_preserves_load_gate()); CHECK(mkdtemp(root) && setenv("LARDON3D_PROJECTS_ROOT", root, 1) == 0); char source[PATH_MAX]; CHECK(join_path(source, root, "source.pgm") && write_pgm(source)); CHECK(run_adaptive_output_equivalence(source)); + CHECK(retry_failed_opencv_restoration()); Lardon3DAppState state; lardon3d_app_state_init(&state); CHECK(runtime(&state) && lardon3d_project_create(&state, "Features")); @@ -577,8 +692,8 @@ static bool run_test(void) { wait_state(state.task_queue, nondurable_rootsift_task, TASK_COMPLETED, &snapshot) && last_sequence_is_no_work(&state, LARDON3D_ROOTSIFT_EXTRACT_TASK_KIND) && unsetenv("LARDON3D_TEST_FEATURE_FAIL_DIRECTORY_SYNC") == 0); - /* SIFT and RootSIFT CPU1 checkpoints are exact historical operational - * signatures. Recovery uses CPU12 in memory without publishing an + /* SIFT and RootSIFT CPU12/CPU1 checkpoints are exact historical operational + * signatures. Recovery uses the portable OpenCV ceiling in memory without publishing an * estimate-only checkpoint; a neighboring CPU2 shape is corruption. */ const uint64_t precision_task_ids[2] = {sift_task_id, rootsift_task_id}; const char *precision_kinds[2] = {LARDON3D_SIFT_EXTRACT_TASK_KIND, @@ -589,6 +704,53 @@ static bool run_test(void) { .resource_governor = state.resource_governor, .orb_vulkan_backend = state.orb_vulkan_backend, }; + char orb_checkpoint[PATH_MAX]; + CHECK(snprintf(orb_checkpoint, sizeof(orb_checkpoint), + "%s/.lardon3d/checkpoints/%lu.chk", state.project_path, + (unsigned long)task_id) > 0); + Lardon3DTaskDurableSnapshot current_orb; + CHECK(lardon3d_task_checkpoint_load(orb_checkpoint, ¤t_orb, NULL) == + LARDON3D_TASK_CHECKPOINT_OK && + current_orb.estimate.desired_cpu_threads == INT_MAX); + const unsigned int legacy_orb_counts[] = {12, 1}; + for (size_t legacy = 0; + legacy < sizeof(legacy_orb_counts) / sizeof(legacy_orb_counts[0]); + ++legacy) { + Lardon3DTaskDurableSnapshot historical_orb = current_orb; + historical_orb.estimate.desired_cpu_threads = legacy_orb_counts[legacy]; + historical_orb.progress = 50; + historical_orb.saved_state = TASK_RUNNING; + historical_orb.recovery_state = TASK_PENDING; + historical_orb.finished_at = (struct timespec){0}; + Lardon3DTask *restored_orb = NULL; + CHECK(lardon3d_task_kind_registry_restore( + lardon3d_task_kind_registry_production(), + LARDON3D_FEATURE_EXTRACT_TASK_KIND, + LARDON3D_FEATURE_EXTRACT_TASK_KIND_VERSION, &historical_orb, + &precision_reconstruction, &restored_orb) == + LARDON3D_TASK_KIND_OK && + restored_orb); + Lardon3DResourceEstimate effective_orb; + CHECK(lardon3d_task_resource_estimate(restored_orb, &effective_orb) && + effective_orb.desired_cpu_threads == INT_MAX); + lardon3d_task_destroy(restored_orb); + } + Lardon3DTaskDurableSnapshot malformed_orb = current_orb; + malformed_orb.estimate.desired_cpu_threads = 2; + Lardon3DTask *restored_orb = NULL; + CHECK(lardon3d_task_kind_registry_restore( + lardon3d_task_kind_registry_production(), + LARDON3D_FEATURE_EXTRACT_TASK_KIND, + LARDON3D_FEATURE_EXTRACT_TASK_KIND_VERSION, &malformed_orb, + &precision_reconstruction, &restored_orb) == + LARDON3D_TASK_KIND_RECONSTRUCTION_FAILED && + restored_orb == NULL); + Lardon3DTaskDurableSnapshot still_current_orb; + CHECK(lardon3d_task_checkpoint_load(orb_checkpoint, &still_current_orb, + NULL) == + LARDON3D_TASK_CHECKPOINT_OK && + still_current_orb.estimate.desired_cpu_threads == INT_MAX); + for (size_t precision_index = 0; precision_index < 2; ++precision_index) { char precision_checkpoint[PATH_MAX]; char precision_staged[PATH_MAX]; @@ -601,6 +763,25 @@ static bool run_test(void) { CHECK(lardon3d_task_checkpoint_load(precision_checkpoint, ¤t_precision, NULL) == LARDON3D_TASK_CHECKPOINT_OK); + Lardon3DTaskDurableSnapshot cpu12_precision = current_precision; + cpu12_precision.estimate.desired_cpu_threads = 12; + cpu12_precision.progress = 50; + cpu12_precision.saved_state = TASK_RUNNING; + cpu12_precision.recovery_state = TASK_PENDING; + cpu12_precision.finished_at = (struct timespec){0}; + Lardon3DTask *cpu12_restored = NULL; + CHECK(lardon3d_task_kind_registry_restore( + lardon3d_task_kind_registry_production(), + precision_kinds[precision_index], + LARDON3D_SIFT_EXTRACT_TASK_KIND_VERSION, &cpu12_precision, + &precision_reconstruction, &cpu12_restored) == + LARDON3D_TASK_KIND_OK && + cpu12_restored); + Lardon3DResourceEstimate cpu12_effective; + CHECK(lardon3d_task_resource_estimate(cpu12_restored, + &cpu12_effective) && + cpu12_effective.desired_cpu_threads == INT_MAX); + lardon3d_task_destroy(cpu12_restored); Lardon3DTaskDurableSnapshot historical_precision = current_precision; historical_precision.estimate.desired_cpu_threads = 1; historical_precision.progress = 50; @@ -627,7 +808,7 @@ static bool run_test(void) { Lardon3DResourceEstimate effective_precision; CHECK(lardon3d_task_resource_estimate(restored_precision, &effective_precision) && - effective_precision.desired_cpu_threads == 12); + effective_precision.desired_cpu_threads == INT_MAX); lardon3d_task_destroy(restored_precision); Lardon3DTaskDurableSnapshot durable_precision; CHECK(lardon3d_task_checkpoint_load(precision_checkpoint, @@ -831,6 +1012,19 @@ static bool run_test(void) { uint64_t visual_task_id = 0; CHECK(lardon3d_project_enqueue_visual_index_update(&state, visual_index_id, &visual_task_id)); CHECK(wait_state(state.task_queue, visual_task_id, TASK_PAUSED, &snapshot)); + char visual_checkpoint[PATH_MAX]; + Lardon3DTaskDurableSnapshot historical_visual; + CHECK(snprintf(visual_checkpoint, sizeof(visual_checkpoint), + "%s/.lardon3d/checkpoints/%lu.chk", state.project_path, + (unsigned long)visual_task_id) > 0 && + lardon3d_task_checkpoint_load(visual_checkpoint, &historical_visual, + NULL) == + LARDON3D_TASK_CHECKPOINT_OK && + historical_visual.estimate.desired_cpu_threads == 16); + historical_visual.estimate.desired_cpu_threads = 12; + CHECK(lardon3d_task_checkpoint_save(visual_checkpoint, + &historical_visual) == + LARDON3D_TASK_CHECKPOINT_OK); lardon3d_task_queue_destroy(state.task_queue); state.task_queue = NULL; lardon3d_project_close(&state); diff --git a/tests/test_geometric_verification.c b/tests/test_geometric_verification.c index bb7ac8d..582bcc0 100644 --- a/tests/test_geometric_verification.c +++ b/tests/test_geometric_verification.c @@ -172,6 +172,16 @@ static bool create_v11_database(const char *path) { lardon3d_project_db_close(database); return execute_sql(path, "PRAGMA foreign_keys=OFF;BEGIN IMMEDIATE;" + "DROP TABLE IF EXISTS capture_calibration_selections;" + "DROP TABLE IF EXISTS optical_calibration_profiles;" + "DROP TABLE IF EXISTS capture_optical_configurations;" + "DROP INDEX IF EXISTS acquisition_campaign_capture_identity_v23;" + "DROP TABLE IF EXISTS acquisition_campaign_group_optics;" + "DROP TABLE IF EXISTS optical_configurations;" + "DROP TABLE IF EXISTS lens_profile_aliases;" + "DROP TABLE IF EXISTS lens_profiles;" + "DROP TABLE IF EXISTS camera_body_aliases;" + "DROP TABLE IF EXISTS camera_body_profiles;" "DROP TABLE IF EXISTS asset_derivations;" "DROP TABLE IF EXISTS capture_selections;" "DROP TABLE IF EXISTS capture_assets;" diff --git a/tests/test_geometric_verifier_core.cpp b/tests/test_geometric_verifier_core.cpp index b08a5ac..f516898 100644 --- a/tests/test_geometric_verifier_core.cpp +++ b/tests/test_geometric_verifier_core.cpp @@ -1,3 +1,4 @@ +#include #include #include #include @@ -10,6 +11,7 @@ #include #include #include +#include #include #include @@ -20,6 +22,8 @@ extern "C" { #include } +#include "../src/geometric_verifier_internal.h" + static int failures; struct Fixture { @@ -285,6 +289,97 @@ static std::string mask_bits(uint32_t count, uint32_t inliers, return bits; } +static bool same_scientific_result( + const Lardon3DProjectDbGeometricVerificationResult &left, + const Lardon3DProjectDbGeometricVerificationResult &right) { + return left.match_result_id == right.match_result_id && + left.verifier_kind == right.verifier_kind && + left.verifier_version == right.verifier_version && + std::memcmp(left.parameter_fingerprint, right.parameter_fingerprint, + sizeof(left.parameter_fingerprint)) == 0 && + left.status == right.status && + left.inlier_count == right.inlier_count && + left.inlier_mask_size == right.inlier_mask_size && + std::memcmp(left.inlier_mask, right.inlier_mask, + left.inlier_mask_size) == 0 && + left.has_model == right.has_model && + (!left.has_model || + std::memcmp(left.model, right.model, sizeof(left.model)) == 0); +} + +static void test_parallel_prepare_and_owner_order(Fixture *fixture) { + constexpr size_t parent_count = 16; + std::vector parent_ids(parent_count); + for (size_t index = 0; index < parent_count; ++index) + CHECK(create_parent(fixture, 32, &parent_ids[index])); + const Lardon3DGeometricVerifierParameters parameters = + lardon3d_geometric_verifier_default_parameters(); + std::vector baseline; + for (size_t width : {1U, 2U, 4U, 8U, 12U, 16U}) { + char sql[256]; + int length = std::snprintf( + sql, sizeof(sql), + "DELETE FROM geometric_verification_results WHERE match_result_id " + "BETWEEN %llu AND %llu", + static_cast(parent_ids.front()), + static_cast(parent_ids.back())); + CHECK(length > 0 && length < static_cast(sizeof(sql)) && + execute_sql(fixture->database_path, sql)); + std::vector prepared(parent_count, + nullptr); + std::vector results( + parent_count); + std::vector statuses( + parent_count, LARDON3D_GEOMETRIC_VERIFIER_INVALID_ARGUMENT); + std::vector reused(parent_count, 0); + std::atomic next{0}; + lardon3d_geometric_verifier_test_reset_estimator_calls(); + auto worker = [&]() { + for (;;) { + size_t index = next.fetch_add(1, std::memory_order_relaxed); + if (index >= parent_count) + return; + bool was_reused = false; + statuses[index] = + lardon3d_geometric_verifier_internal_prepare_version( + fixture->root, fixture->state.project_db, parent_ids[index], + ¶meters, LARDON3D_GEOMETRIC_VERIFIER_VERSION_V3, + &prepared[index], &results[index], &was_reused); + reused[index] = was_reused ? 1U : 0U; + } + }; + std::vector children; + for (size_t index = 1; index < width; ++index) + children.emplace_back(worker); + worker(); + for (std::thread &child : children) + child.join(); + CHECK(lardon3d_geometric_verifier_test_estimator_calls() == parent_count); + uint64_t previous_result_id = 0; + for (size_t index = 0; index < parent_count; ++index) { + CHECK(statuses[index] == LARDON3D_GEOMETRIC_VERIFIER_OK && + prepared[index] && reused[index] == 0); + bool was_reused = false; + statuses[index] = + lardon3d_geometric_verifier_internal_publish_prepared( + fixture->state.project_db, prepared[index], &results[index], + &was_reused); + lardon3d_geometric_verifier_internal_prepared_destroy(prepared[index]); + prepared[index] = nullptr; + CHECK(statuses[index] == LARDON3D_GEOMETRIC_VERIFIER_OK && !was_reused && + results[index].geometric_verification_result_id > + previous_result_id); + previous_result_id = results[index].geometric_verification_result_id; + } + if (baseline.empty()) { + baseline = results; + } else { + for (size_t index = 0; index < parent_count; ++index) + CHECK(same_scientific_result(baseline[index], results[index])); + } + } +} + static void test_parameters_and_fingerprint() { Lardon3DGeometricVerifierParameters parameters = lardon3d_geometric_verifier_default_parameters(); @@ -982,6 +1077,7 @@ int main() { test_failures(&fixture); test_parent_and_asset_failures(&fixture); test_real_estimator_and_reopen(&fixture); + test_parallel_prepare_and_owner_order(&fixture); test_feature_failures(&fixture); } fixture_destroy(&fixture); diff --git a/tests/test_geometric_verifier_task.c b/tests/test_geometric_verifier_task.c index 786477b..0d7f4e3 100644 --- a/tests/test_geometric_verifier_task.c +++ b/tests/test_geometric_verifier_task.c @@ -1,5 +1,8 @@ #include #include +#include +#include +#include #include #include #include @@ -10,7 +13,9 @@ #include #include +#include #include +#include #include #include @@ -61,6 +66,24 @@ static bool remove_tree(const char *path) { return closedir(directory) == 0 && rmdir(path) == 0 && ok; } +static bool fixture_join_path(char *output, size_t capacity, const char *left, + const char *right) { + if (!output || capacity == 0 || !left || !right) { + return false; + } + size_t left_size = strlen(left); + size_t right_size = strlen(right); + if (left_size == 0 || right_size == 0 || right_size > SIZE_MAX - 2 || + left_size > SIZE_MAX - right_size - 2 || + left_size + right_size + 2 > capacity) { + return false; + } + memcpy(output, left, left_size); + output[left_size] = '/'; + memcpy(output + left_size + 1, right, right_size + 1); + return true; +} + static Lardon3DResourcePolicy policy(void) { return (Lardon3DResourcePolicy){ .system_memory_reserve_bytes = 4ULL * 1024 * 1024 * 1024, @@ -125,6 +148,197 @@ static bool query_integer(const char *path, const char *sql, return sqlite3_close(db) == SQLITE_OK && ok; } +typedef struct { + uint64_t first_match_result_id; + uint64_t last_match_result_id; + char second_match_asset_path[PATH_MAX]; +} FreshParentFixture; + +static void fixture_image_asset_path(const unsigned char hash[32], + char path[PATH_MAX]) { + static const char digits[] = "0123456789abcdef"; + char hex[65]; + for (size_t index = 0; index < 32; ++index) { + hex[2 * index] = digits[hash[index] >> 4U]; + hex[2 * index + 1] = digits[hash[index] & 15U]; + } + hex[64] = '\0'; + (void)snprintf(path, PATH_MAX, "assets/images/%c%c/%s", hex[0], hex[1], + hex); +} + +static bool fixture_register_image(Lardon3DAppState *state, uint64_t scanset_id, + unsigned char seed, + Lardon3DProjectDbImage *image) { + unsigned char hash[32]; + memset(hash, seed, sizeof(hash)); + char relative[PATH_MAX]; + fixture_image_asset_path(hash, relative); + Lardon3DProjectDbImageRegisterStatus status; + return lardon3d_project_db_register_image( + state->project_db, scanset_id, hash, relative, 1, "fixture.bin", + "/fixture.bin", 0, (int64_t)seed, &status, image) == + LARDON3D_PROJECT_DB_OK; +} + +static bool fixture_publish_features(Lardon3DAppState *state, + const Lardon3DProjectDbImage *image, + unsigned char salt, + Lardon3DProjectDbFeatureSet *set) { + enum { FEATURE_COUNT = 16, DESCRIPTOR_DIMENSION = 32 }; + Lardon3DFeatureKeypoint keypoints[FEATURE_COUNT]; + unsigned char descriptors[FEATURE_COUNT * DESCRIPTOR_DIMENSION]; + memset(keypoints, 0, sizeof(keypoints)); + memset(descriptors, salt, sizeof(descriptors)); + for (uint32_t index = 0; index < FEATURE_COUNT; ++index) { + keypoints[index].x = 10.0F + (float)index; + keypoints[index].y = 20.0F + (float)index; + keypoints[index].size = 1.0F; + } + Lardon3DExtractedFeatures features = { + .image_width = 256, + .image_height = 256, + .feature_count = FEATURE_COUNT, + .keypoints = keypoints, + .descriptors = descriptors, + .descriptor_bytes = sizeof(descriptors), + }; + unsigned char fingerprint[32]; + memset(fingerprint, salt, sizeof(fingerprint)); + return lardon3d_feature_store_publish_v2( + state, image->image_id, 0, "orb", 1, fingerprint, + LARDON3D_FEATURE_DESCRIPTOR_U8, DESCRIPTOR_DIMENSION, 0, &features, + set) == LARDON3D_FEATURE_STORE_OK; +} + +static bool fixture_sha256_file(const char *path, unsigned char output[32], + uint64_t *size) { + int descriptor = open(path, O_RDONLY | O_CLOEXEC); + if (descriptor < 0) { + return false; + } + EVP_MD_CTX *context = EVP_MD_CTX_new(); + bool ok = context && EVP_DigestInit_ex(context, EVP_sha256(), NULL) == 1; + unsigned char bytes[4096]; + uint64_t total = 0; + for (;;) { + ssize_t count = read(descriptor, bytes, sizeof(bytes)); + if (count < 0) { + ok = false; + break; + } + if (count == 0) { + break; + } + if (total > UINT64_MAX - (uint64_t)count || + !context || + EVP_DigestUpdate(context, bytes, (size_t)count) != 1) { + ok = false; + break; + } + total += (uint64_t)count; + } + unsigned int digest_size = 0; + ok = ok && context && + EVP_DigestFinal_ex(context, output, &digest_size) == 1 && + digest_size == 32; + EVP_MD_CTX_free(context); + if (close(descriptor) != 0) { + ok = false; + } + if (ok) { + *size = total; + } + return ok; +} + +static bool seed_fresh_parents(Lardon3DAppState *state, size_t count, + FreshParentFixture *fixture) { + if (!state || !state->project_db || !fixture || count < 2 || count > 64) { + return false; + } + memset(fixture, 0, sizeof(*fixture)); + Lardon3DProjectDbScanSet scanset; + Lardon3DProjectDbImage image_a; + Lardon3DProjectDbImage image_b; + Lardon3DProjectDbFeatureSet set_a; + Lardon3DProjectDbFeatureSet set_b; + Lardon3DProjectDbCandidatePair pair; + if (lardon3d_project_db_create_scanset(state->project_db, "fresh", &scanset) != + LARDON3D_PROJECT_DB_OK || + !fixture_register_image(state, scanset.scanset_id, 1, &image_a) || + !fixture_register_image(state, scanset.scanset_id, 2, &image_b) || + !fixture_publish_features(state, &image_a, 3, &set_a) || + !fixture_publish_features(state, &image_b, 4, &set_b) || + lardon3d_project_db_create_candidate_pair( + state->project_db, image_a.image_id, image_b.image_id, 1, &pair) != + LARDON3D_PROJECT_DB_OK) { + return false; + } + + Lardon3DMatchFileEntry entries[6]; + for (uint32_t index = 0; index < 6; ++index) { + entries[index] = (Lardon3DMatchFileEntry){ + .feature_index_a = index, + .feature_index_b = index, + .distance = (float)(index + 1U), + }; + } + for (size_t index = 0; index < count; ++index) { + char relative[64]; + char full[PATH_MAX]; + int relative_length = + snprintf(relative, sizeof(relative), "matches-%zu.bin", index + 1); + int full_length = snprintf(full, sizeof(full), "%s/%s", state->project_path, + relative); + if (relative_length <= 0 || (size_t)relative_length >= sizeof(relative) || + full_length <= 0 || (size_t)full_length >= sizeof(full)) { + return false; + } + int descriptor = + open(full, O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC, 0600); + if (descriptor < 0) { + return false; + } + bool written = lardon3d_match_file_write( + descriptor, LARDON3D_FEATURE_DESCRIPTOR_U8, 32, + set_a.feature_set_id, set_b.feature_set_id, entries, 6) == + LARDON3D_MATCH_FILE_OK; + if (close(descriptor) != 0) { + written = false; + } + unsigned char asset_sha256[32]; + uint64_t asset_size = 0; + if (!written || + !fixture_sha256_file(full, asset_sha256, &asset_size)) { + return false; + } + unsigned char matcher_fingerprint[32]; + memset(matcher_fingerprint, (int)(index + 1), + sizeof(matcher_fingerprint)); + Lardon3DProjectDbMatchResult parent; + if (lardon3d_project_db_create_match_result( + state->project_db, pair.candidate_pair_id, set_a.feature_set_id, + set_b.feature_set_id, "fixture", 1, matcher_fingerprint, + LARDON3D_MATCH_RESULT_STATUS_MATCHED, 6, asset_sha256, relative, + asset_size, (int64_t)(index + 1), &parent) != + LARDON3D_PROJECT_DB_OK) { + return false; + } + if (index == 0) { + fixture->first_match_result_id = parent.match_result_id; + } + fixture->last_match_result_id = parent.match_result_id; + if (index == 1) { + (void)snprintf(fixture->second_match_asset_path, + sizeof(fixture->second_match_asset_path), "%s", full); + } + } + return fixture->first_match_result_id != 0 && + fixture->last_match_result_id >= fixture->first_match_result_id && + fixture->second_match_asset_path[0] != '\0'; +} + static bool seed_reusable_parents(const char *path, const unsigned char fingerprint[32]) { sqlite3 *db = NULL; @@ -250,10 +464,11 @@ static bool run_task_mid_batch_failure_regression_test(void) { char internal[4096]; char checkpoints[4096]; char database_path[4096]; - (void)snprintf(internal, sizeof(internal), "%s/.lardon3d", root); - (void)snprintf(checkpoints, sizeof(checkpoints), "%s/checkpoints", internal); - (void)snprintf(database_path, sizeof(database_path), "%s/project.sqlite3", - internal); + CHECK(fixture_join_path(internal, sizeof(internal), root, ".lardon3d") && + fixture_join_path(checkpoints, sizeof(checkpoints), internal, + "checkpoints") && + fixture_join_path(database_path, sizeof(database_path), internal, + "project.sqlite3")); CHECK(mkdir(internal, 0700) == 0 && mkdir(checkpoints, 0700) == 0); Lardon3DAppState state; @@ -278,50 +493,61 @@ static bool run_task_mid_batch_failure_regression_test(void) { Lardon3DGeometricVerifierTaskConfiguration configuration = { .verifier = lardon3d_geometric_verifier_default_parameters(), }; - unsigned char fingerprint[32]; - lardon3d_geometric_verifier_fingerprint(&configuration.verifier, fingerprint); - CHECK(setenv("LARDON3D_TEST_GEOMETRIC_PARENT_COUNT", "12", 1) == 0); - CHECK(seed_reusable_parents(database_path, fingerprint)); - CHECK(unsetenv("LARDON3D_TEST_GEOMETRIC_PARENT_COUNT") == 0); - - CHECK(exec_sql( - database_path, - "DELETE FROM geometric_verification_results WHERE " - "match_result_id != (SELECT MIN(match_result_id) FROM " - "geometric_verification_results);")); - - CHECK(setenv("LARDON3D_TEST_GEOMETRIC_ESTIMATOR", "error", 1) == 0); + uint64_t invalid_task_id = 99; + CHECK(!lardon3d_project_enqueue_geometric_verifier_task( + NULL, &configuration, &invalid_task_id) && + invalid_task_id == 0); + FreshParentFixture fresh; + CHECK(seed_fresh_parents(&state, 16, &fresh)); + CHECK(fresh.first_match_result_id == 1 && fresh.last_match_result_id == 16); + CHECK(query_integer(database_path, + "SELECT COUNT(*) FROM geometric_verification_results", + 0)); + /* Fail after fourteen children at the independently safe CPU16 bound. Every + * created participant must be joined and every fresh opaque stage destroyed + * before callback-owned batch storage is released; owner publication and + * cursor mutation stay at zero. ASan supplies the exact lifetime oracle. */ + CHECK(setenv("LARDON3D_TEST_GEOMETRIC_FORCE_PARTICIPANTS", "16", 1) == 0); + CHECK(setenv("LARDON3D_TEST_GEOMETRIC_THREAD_FAIL_AFTER", "14", 1) == 0); uint64_t task_id = 0; CHECK(lardon3d_project_enqueue_geometric_verifier_task(&state, &configuration, - &task_id)); + &task_id)); Lardon3DTaskSnapshot snapshot; CHECK(wait_state(state.task_queue, task_id, TASK_FAILED, &snapshot)); + CHECK(wait_durable(state.project_db, task_id, TASK_FAILED)); + CHECK(query_integer(database_path, + "SELECT after_match_result_id FROM " + "geometric_verifier_tasks WHERE task_id=(SELECT " + "MAX(task_id) FROM geometric_verifier_tasks)", + 0)); + CHECK(query_integer(database_path, + "SELECT COUNT(*) FROM geometric_verification_results", + 0)); + CHECK(unsetenv("LARDON3D_TEST_GEOMETRIC_THREAD_FAIL_AFTER") == 0); + CHECK(unsetenv("LARDON3D_TEST_GEOMETRIC_FORCE_PARTICIPANTS") == 0); + + /* Index zero is a genuinely fresh, valid preparation. Removing only index + * one's immutable Match asset makes a later slot CORRUPT; the all-slot + * preflight must discard every successful stage before publishing index zero. */ + CHECK(unlink(fresh.second_match_asset_path) == 0); + + task_id = 0; + CHECK(lardon3d_project_enqueue_geometric_verifier_task(&state, &configuration, + &task_id)); + CHECK(wait_state(state.task_queue, task_id, TASK_FAILED, &snapshot)); + CHECK(wait_durable(state.project_db, task_id, TASK_FAILED)); CHECK(snapshot.message[0] != '\0' && strstr(snapshot.message, "Contrat de lot") == NULL); - for (size_t attempt = 0; attempt < 2000000; ++attempt) { - if (query_integer(database_path, - "SELECT after_match_result_id FROM " - "geometric_verifier_tasks", - 1)) { - break; - } - sched_yield(); - } CHECK(query_integer(database_path, - "SELECT after_match_result_id FROM " - "geometric_verifier_tasks", - 1)); + "SELECT after_match_result_id FROM geometric_verifier_tasks " + "WHERE task_id=(SELECT MAX(task_id) FROM " + "geometric_verifier_tasks)", + 0)); CHECK(query_integer(database_path, - "SELECT COUNT(*) FROM geometric_verification_results", 1)); - CHECK(query_integer( - database_path, - "SELECT COUNT(*) FROM geometric_verification_results WHERE " - "match_result_id > 1", - 0)); - CHECK(unsetenv("LARDON3D_TEST_GEOMETRIC_ESTIMATOR") == 0); + "SELECT COUNT(*) FROM geometric_verification_results", 0)); lardon3d_task_queue_destroy(state.task_queue); lardon3d_resource_governor_destroy(state.resource_governor); @@ -330,16 +556,133 @@ static bool run_task_mid_batch_failure_regression_test(void) { return true; } +static bool run_engaged_control_case(bool cancel, size_t parent_count) { + char root[] = "/tmp/lardon3d-geometric-task-control-XXXXXX"; + CHECK(mkdtemp(root) != NULL); + char internal[PATH_MAX]; + char checkpoints[PATH_MAX]; + char database_path[PATH_MAX]; + CHECK(fixture_join_path(internal, sizeof(internal), root, ".lardon3d") && + fixture_join_path(checkpoints, sizeof(checkpoints), internal, + "checkpoints") && + fixture_join_path(database_path, sizeof(database_path), internal, + "project.sqlite3")); + CHECK(mkdir(internal, 0700) == 0 && mkdir(checkpoints, 0700) == 0); + + Lardon3DAppState state; + lardon3d_app_state_init(&state); + char error[LARDON3D_PROJECT_DB_ERROR_CAPACITY]; + CHECK(lardon3d_project_db_open(database_path, &state.project_db, error) == + LARDON3D_PROJECT_DB_OK); + state.project_loaded = true; + (void)snprintf(state.project_path, sizeof(state.project_path), "%s", root); + state.hardware_profile = (Lardon3DHardwareProfile){ + .logical_cpu_count = 16, + .page_size_bytes = 4096, + .memory_total_bytes = 16ULL * 1024 * 1024 * 1024, + .cpu_architecture = "test", + }; + Lardon3DResourcePolicy resource_policy = policy(); + state.resource_governor = lardon3d_resource_governor_create( + &state.hardware_profile, &resource_policy); + state.task_queue = lardon3d_task_queue_create(state.resource_governor, 16); + CHECK(state.resource_governor && state.task_queue); + + FreshParentFixture fresh; + CHECK(seed_fresh_parents(&state, parent_count, &fresh)); + CHECK(fresh.first_match_result_id == 1 && + fresh.last_match_result_id == parent_count); + Lardon3DGeometricVerifierTaskConfiguration configuration = { + .verifier = lardon3d_geometric_verifier_default_parameters(), + }; + lardon3d_geometric_verifier_task_test_arm_prepublication_barrier(); + uint64_t task_id = 0; + CHECK(lardon3d_project_enqueue_geometric_verifier_task(&state, &configuration, + &task_id)); + bool reached = + lardon3d_geometric_verifier_task_test_wait_prepublication_barrier(); + if (!reached) { + lardon3d_geometric_verifier_task_test_release_prepublication_barrier(); + } + CHECK(reached); + bool requested = cancel + ? lardon3d_task_queue_cancel(state.task_queue, task_id) + : lardon3d_task_queue_pause(state.task_queue, task_id); + lardon3d_geometric_verifier_task_test_release_prepublication_barrier(); + CHECK(requested); + + Lardon3DTaskSnapshot snapshot; + Lardon3DTaskState expected_state = cancel ? TASK_CANCELLED : TASK_PAUSED; + CHECK(wait_state(state.task_queue, task_id, expected_state, &snapshot)); + if (cancel) { + CHECK(wait_durable(state.project_db, task_id, TASK_CANCELLED)); + } + const uint64_t engaged_cursor = fresh.first_match_result_id + 15; + Lardon3DProjectDbGeometricVerifierTask durable; + CHECK(lardon3d_project_db_load_geometric_verifier_task( + state.project_db, task_id, &durable) == LARDON3D_PROJECT_DB_OK && + durable.after_match_result_id == engaged_cursor); + CHECK(query_integer(database_path, + "SELECT COUNT(*) FROM geometric_verification_results", + 16)); + char later_results_sql[256]; + int sql_length = snprintf( + later_results_sql, sizeof(later_results_sql), + "SELECT COUNT(*) FROM geometric_verification_results WHERE " + "match_result_id>%llu", + (unsigned long long)engaged_cursor); + CHECK(sql_length > 0 && (size_t)sql_length < sizeof(later_results_sql) && + query_integer(database_path, later_results_sql, 0)); + + Lardon3DProjectDbTask generic; + CHECK(lardon3d_project_db_load_task(state.project_db, task_id, &generic) == + LARDON3D_PROJECT_DB_OK); + unsigned int engaged_progress = parent_count == 16 ? 100U : 99U; + CHECK(generic.progress == engaged_progress); + if (cancel) { + CHECK(generic.saved_state == TASK_CANCELLED); + } else { + /* The durable RUNNING snapshot is the post-batch checkpoint. Only after it + * commits does the in-memory Task honor the pending pause. */ + CHECK(generic.saved_state == TASK_RUNNING); + CHECK(lardon3d_task_queue_resume(state.task_queue, task_id)); + CHECK(wait_durable(state.project_db, task_id, TASK_COMPLETED)); + CHECK(lardon3d_project_db_load_geometric_verifier_task( + state.project_db, task_id, &durable) == LARDON3D_PROJECT_DB_OK && + durable.after_match_result_id == fresh.last_match_result_id); + CHECK(query_integer(database_path, + "SELECT COUNT(*) FROM geometric_verification_results", + (sqlite3_int64)parent_count)); + } + + lardon3d_task_queue_destroy(state.task_queue); + lardon3d_resource_governor_destroy(state.resource_governor); + lardon3d_project_db_close(state.project_db); + CHECK(remove_tree(root)); + return true; +} + +static bool run_engaged_control_regression_tests(void) { + /* Sixteen exercises the explicit exhausted-batch checkpoint. Seventeen + * leaves one undispatched parent, proving pause/cancel cannot start a second + * batch before the engaged prefix becomes the control boundary. */ + return run_engaged_control_case(false, 16) && + run_engaged_control_case(true, 16) && + run_engaged_control_case(false, 17) && + run_engaged_control_case(true, 17); +} + static bool run_task_test(void) { char root[] = "/tmp/lardon3d-geometric-task-XXXXXX"; CHECK(mkdtemp(root) != NULL); char internal[4096]; char checkpoints[4096]; char database_path[4096]; - (void)snprintf(internal, sizeof(internal), "%s/.lardon3d", root); - (void)snprintf(checkpoints, sizeof(checkpoints), "%s/checkpoints", internal); - (void)snprintf(database_path, sizeof(database_path), "%s/project.sqlite3", - internal); + CHECK(fixture_join_path(internal, sizeof(internal), root, ".lardon3d") && + fixture_join_path(checkpoints, sizeof(checkpoints), internal, + "checkpoints") && + fixture_join_path(database_path, sizeof(database_path), internal, + "project.sqlite3")); CHECK(mkdir(internal, 0700) == 0 && mkdir(checkpoints, 0700) == 0); Lardon3DAppState state; @@ -366,6 +709,7 @@ static bool run_task_test(void) { }; unsigned char fingerprint[32]; lardon3d_geometric_verifier_fingerprint(&configuration.verifier, fingerprint); + CHECK(setenv("LARDON3D_TEST_GEOMETRIC_PARENT_COUNT", "32", 1) == 0); CHECK(seed_reusable_parents(database_path, fingerprint)); const Lardon3DTaskKindRegistry *registry = @@ -385,6 +729,38 @@ static bool run_task_test(void) { durable.after_match_result_id == (uint64_t)fixture_parent_count() && memcmp(durable.parameter_fingerprint, fingerprint, 32) == 0); + /* Start with only one compute CPU, acknowledge the first durable sequence, + * then enlarge the host pool before the real sequence_break. Exact reuse + * intentionally records zero scientific work, so Governor slow-start stays + * at CPU1; the sequence count proves fresh admission without manufacturing + * throughput evidence. */ + resource_policy.system_cpu_reserve = 15; + CHECK(lardon3d_resource_governor_set_policy(state.resource_governor, + &resource_policy)); + lardon3d_geometric_verifier_task_test_reset_cpu_contracts(); + lardon3d_geometric_verifier_task_test_arm_sequence_barrier(); + CHECK(lardon3d_project_enqueue_geometric_verifier_task( + &state, &configuration, &task_id)); + CHECK(lardon3d_geometric_verifier_task_test_wait_sequence_barrier()); + CHECK(lardon3d_project_db_load_geometric_verifier_task( + state.project_db, task_id, &durable) == LARDON3D_PROJECT_DB_OK && + durable.after_match_result_id > 0 && + durable.after_match_result_id < (uint64_t)fixture_parent_count()); + resource_policy.system_cpu_reserve = 8; + CHECK(lardon3d_resource_governor_set_policy(state.resource_governor, + &resource_policy)); + lardon3d_geometric_verifier_task_test_release_sequence_barrier(); + CHECK(wait_durable(state.project_db, task_id, TASK_COMPLETED)); + CHECK(lardon3d_geometric_verifier_task_test_cpu_contracts() == (1U << 1)); + Lardon3DProjectDbTask readmitted_task; + CHECK(lardon3d_project_db_load_task(state.project_db, task_id, + &readmitted_task) == + LARDON3D_PROJECT_DB_OK && + readmitted_task.sequence_count > 0); + resource_policy.system_cpu_reserve = 4; + CHECK(lardon3d_resource_governor_set_policy(state.resource_governor, + &resource_policy)); + configuration.verifier.threshold_pixels = 1.6; CHECK(lardon3d_geometric_verifier_fingerprint_for_version( &configuration.verifier, LARDON3D_GEOMETRIC_VERIFIER_VERSION_V2, @@ -502,6 +878,14 @@ static bool run_task_test(void) { CHECK(exec_sql( database_path, "PRAGMA foreign_keys=OFF;BEGIN IMMEDIATE;" + "DROP TABLE IF EXISTS capture_calibration_selections;" + "DROP TABLE IF EXISTS optical_calibration_profiles;" + "DROP TABLE IF EXISTS capture_optical_configurations;" + "DROP INDEX IF EXISTS acquisition_campaign_capture_identity_v23;" + "DROP TABLE IF EXISTS acquisition_campaign_group_optics;" + "DROP TABLE IF EXISTS optical_configurations;" + "DROP TABLE IF EXISTS lens_profile_aliases;DROP TABLE IF EXISTS lens_profiles;" + "DROP TABLE IF EXISTS camera_body_aliases;DROP TABLE IF EXISTS camera_body_profiles;" "DROP TABLE IF EXISTS asset_derivations;" "DROP TABLE IF EXISTS capture_selections;" "DROP TABLE IF EXISTS capture_assets;" @@ -541,12 +925,14 @@ static bool run_task_test(void) { LARDON3D_PROJECT_DB_OK); CHECK(lardon3d_project_db_schema_version(database) == LARDON3D_PROJECT_DB_SCHEMA_VERSION); lardon3d_project_db_close(database); + CHECK(unsetenv("LARDON3D_TEST_GEOMETRIC_PARENT_COUNT") == 0); CHECK(remove_tree(root)); return true; } int main(void) { - return (run_task_test() && run_task_mid_batch_failure_regression_test()) ? - EXIT_SUCCESS : - EXIT_FAILURE; + return (run_task_test() && run_task_mid_batch_failure_regression_test() && + run_engaged_control_regression_tests()) + ? EXIT_SUCCESS + : EXIT_FAILURE; } diff --git a/tests/test_match_result.c b/tests/test_match_result.c index ad279ab..e563b83 100644 --- a/tests/test_match_result.c +++ b/tests/test_match_result.c @@ -53,6 +53,26 @@ static bool create_v9_database(const char *path) { return false; static const char sql[] = "PRAGMA foreign_keys=OFF;BEGIN IMMEDIATE;" + /* This database is a true v9 fixture, not a current database whose + metadata alone was relabelled while additive v20-v23 objects survived. */ + "DROP TABLE IF EXISTS capture_calibration_selections;" + "DROP TABLE IF EXISTS optical_calibration_profiles;" + "DROP TABLE IF EXISTS capture_optical_configurations;" + "DROP INDEX IF EXISTS acquisition_campaign_capture_identity_v23;" + "DROP TABLE IF EXISTS acquisition_campaign_group_optics;" + "DROP TABLE IF EXISTS optical_configurations;" + "DROP TABLE IF EXISTS lens_profile_aliases;" + "DROP TABLE IF EXISTS lens_profiles;" + "DROP TABLE IF EXISTS camera_body_aliases;" + "DROP TABLE IF EXISTS camera_body_profiles;" + "DROP TABLE IF EXISTS selected_execution_items;" + "DROP TABLE IF EXISTS selected_executions;" + "DROP TABLE IF EXISTS raw_development_tasks;" + "DROP TABLE IF EXISTS capture_source_assets;" + "DROP TABLE IF EXISTS photo_quality_triage_results;" + "DROP TABLE IF EXISTS photo_quality_triage_tasks;" + "DROP TABLE IF EXISTS acquisition_campaign_captures;" + "DROP TABLE IF EXISTS acquisition_campaign_tasks;" "DROP TABLE IF EXISTS asset_derivations;" "DROP TABLE IF EXISTS capture_selections;" "DROP TABLE IF EXISTS capture_assets;" diff --git a/tests/test_matcher_task.c b/tests/test_matcher_task.c index 28fcd84..e2e627a 100644 --- a/tests/test_matcher_task.c +++ b/tests/test_matcher_task.c @@ -227,7 +227,7 @@ static bool matcher_exact_memory_boundary_test(void) { CHECK(matcher_capability_boundary_case( &forced, forced_total - 1, forced_total - 1, LARDON3D_RESOURCE_REJECT, LARDON3D_RESOURCE_BACKEND_FIXED, 0, 0, 0)); - /* Exact sizing never weakens the 2 GiB hard floor: an otherwise valid + /* Exact sizing never weakens the 3 GiB host reserve: an otherwise valid * forced capability waits and owns no reservation at that current snapshot. */ CHECK(matcher_capability_boundary_case( &forced, forced_total, 2 * gib, LARDON3D_RESOURCE_WAIT, @@ -360,6 +360,14 @@ static bool downgrade_project_to_historical_v10(const char *database_path) { } static const char sql[] = "PRAGMA foreign_keys=OFF;BEGIN IMMEDIATE;" + "DROP TABLE IF EXISTS capture_calibration_selections;" + "DROP TABLE IF EXISTS optical_calibration_profiles;" + "DROP TABLE IF EXISTS capture_optical_configurations;" + "DROP INDEX IF EXISTS acquisition_campaign_capture_identity_v23;" + "DROP TABLE IF EXISTS acquisition_campaign_group_optics;" + "DROP TABLE IF EXISTS optical_configurations;" + "DROP TABLE IF EXISTS lens_profile_aliases;DROP TABLE IF EXISTS lens_profiles;" + "DROP TABLE IF EXISTS camera_body_aliases;DROP TABLE IF EXISTS camera_body_profiles;" "DROP TABLE IF EXISTS asset_derivations;" "DROP TABLE IF EXISTS capture_selections;" "DROP TABLE IF EXISTS capture_assets;" diff --git a/tests/test_optical_profiles.c b/tests/test_optical_profiles.c new file mode 100644 index 0000000..71f21f2 --- /dev/null +++ b/tests/test_optical_profiles.c @@ -0,0 +1,1290 @@ +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +#define CHECK(condition) \ + do { \ + if (!(condition)) { \ + fprintf(stderr, "optical profiles failure at line %d: %s\n", __LINE__, \ + #condition); \ + return false; \ + } \ + } while (0) + +static bool raw_sql(const char *path, const char *sql) { + sqlite3 *database = NULL; + if (sqlite3_open(path, &database) != SQLITE_OK) + return false; + int code = sqlite3_exec(database, sql, NULL, NULL, NULL); + return sqlite3_close(database) == SQLITE_OK && code == SQLITE_OK; +} + +static bool raw_integer(const char *path, const char *sql, + sqlite3_int64 *value) { + sqlite3 *database = NULL; + sqlite3_stmt *statement = NULL; + bool ok = sqlite3_open(path, &database) == SQLITE_OK && + sqlite3_prepare_v2(database, sql, -1, &statement, NULL) == SQLITE_OK && + sqlite3_step(statement) == SQLITE_ROW && + sqlite3_column_type(statement, 0) == SQLITE_INTEGER; + if (ok) + *value = sqlite3_column_int64(statement, 0); + (void)sqlite3_finalize(statement); + if (database) + ok = sqlite3_close(database) == SQLITE_OK && ok; + return ok; +} + +static bool all_bytes_zero(const void *value, size_t size) { + const unsigned char *bytes = value; + for (size_t index = 0; index < size; ++index) + if (bytes[index] != 0) + return false; + return true; +} + +static bool raw_object_sql(const char *path, const char *name, char *output, + size_t capacity) { + sqlite3 *database = NULL; + sqlite3_stmt *statement = NULL; + bool ok = sqlite3_open(path, &database) == SQLITE_OK && + sqlite3_prepare_v2( + database, + "SELECT sql FROM sqlite_master WHERE name=?1 AND sql IS NOT NULL", + -1, &statement, NULL) == SQLITE_OK; + if (ok) + (void)sqlite3_bind_text(statement, 1, name, -1, SQLITE_TRANSIENT); + if (ok) + ok = sqlite3_step(statement) == SQLITE_ROW && + sqlite3_column_type(statement, 0) == SQLITE_TEXT; + if (ok) { + int bytes = sqlite3_column_bytes(statement, 0); + const unsigned char *text = sqlite3_column_text(statement, 0); + ok = bytes >= 0 && (size_t)bytes < capacity && text; + if (ok) { + memcpy(output, text, (size_t)bytes); + output[bytes] = '\0'; + } + } + (void)sqlite3_finalize(statement); + if (database) + ok = sqlite3_close(database) == SQLITE_OK && ok; + return ok; +} + +static bool make_database_path(char directory[64], char path[256]) { + memcpy(directory, "/tmp/lardon3d-optical-XXXXXX", 29); + if (!mkdtemp(directory)) + return false; + int bytes = snprintf(path, 256, "%s/project.db", directory); + return bytes > 0 && bytes < 256; +} + +static bool create_sparse_calibration(Lardon3DProjectDb *database, + unsigned char fingerprint_byte, + uint32_t width, uint32_t height, + Lardon3DSparseCalibration *output) { + Lardon3DSparseCalibration input = { + .model_kind = LARDON3D_SPARSE_SFM_CALIBRATION_KIND_PINHOLE, + .model_version = LARDON3D_SPARSE_SFM_CALIBRATION_VERSION, + .width = width, + .height = height, + .fx = (double)width * 0.75, + .fy = (double)height, + .cx = (double)width * 0.5, + .cy = (double)height * 0.5, + .provenance_kind = LARDON3D_SPARSE_SFM_PROVENANCE_USER_EXPLICIT, + }; + input.provenance_fingerprint[0] = fingerprint_byte; + return lardon3d_sparse_calibration_create(database, &input, output) == + LARDON3D_PROJECT_DB_OK; +} + +static bool seed_campaign(Lardon3DProjectDb *database, uint64_t task_id, + uint64_t scanset_id, uint32_t group_count) { + Lardon3DTaskDurableSnapshot snapshot = { + .id = task_id, + .saved_state = TASK_PENDING, + .recovery_state = TASK_PENDING, + }; + memcpy(snapshot.name, "optical campaign", 17); + static const unsigned char request[] = {0x4c, 0x33, 0x44, 0x4f}; + Lardon3DProjectDbAcquisitionCampaignTask campaign = { + .task_id = task_id, + .scanset_id = scanset_id, + .next_group_id = 0, + .group_count = group_count, + .request = request, + .request_size = sizeof(request), + }; + return lardon3d_project_db_record_acquisition_campaign_task( + database, &snapshot, LARDON3D_ACQUISITION_CAMPAIGN_TASK_KIND, + LARDON3D_ACQUISITION_CAMPAIGN_TASK_KIND_VERSION, NULL, &campaign, + 1) == LARDON3D_PROJECT_DB_OK; +} + +typedef struct { + uint64_t scanset_id; + uint64_t configuration_id; + uint64_t alternate_configuration_id; + uint64_t capture_id; +} RepairOpticalFixture; + +static bool seed_repair_optical_fixture(Lardon3DProjectDb *database, + RepairOpticalFixture *fixture) { + memset(fixture, 0, sizeof(*fixture)); + Lardon3DProjectDbScanSet scanset; + if (lardon3d_project_db_create_scanset(database, "repair optical fixture", + &scanset) != LARDON3D_PROJECT_DB_OK) + return false; + + Lardon3DOpticalCameraBodyProfile body_input = {0}; + memcpy(body_input.manufacturer, "Generic", sizeof("Generic")); + memcpy(body_input.model, "Repair body", sizeof("Repair body")); + memcpy(body_input.name, "Repair body profile", + sizeof("Repair body profile")); + Lardon3DOpticalCameraBodyProfile body; + if (lardon3d_optical_camera_body_create(database, &body_input, &body) != + LARDON3D_PROJECT_DB_OK) + return false; + + Lardon3DOpticalLensProfile lens_input = { + .interface_kind = LARDON3D_OPTICAL_LENS_ELECTRONIC, + .focal_range_kind = LARDON3D_OPTICAL_FOCAL_RANGE_ZOOM, + .minimum_focal_um = 16000, + .maximum_focal_um = 50000, + }; + memcpy(lens_input.manufacturer, "Generic", sizeof("Generic")); + memcpy(lens_input.model, "Repair zoom", sizeof("Repair zoom")); + memcpy(lens_input.name, "Repair 16-50 zoom", + sizeof("Repair 16-50 zoom")); + Lardon3DOpticalLensProfile lens; + if (lardon3d_optical_lens_create(database, &lens_input, &lens) != + LARDON3D_PROJECT_DB_OK) + return false; + + Lardon3DOpticalConfiguration configuration_input = { + .camera_body_profile_id = body.camera_body_profile_id, + .lens_profile_id = lens.lens_profile_id, + .has_focal_length = true, + .focal_length_um = 16000, + }; + Lardon3DOpticalConfiguration configuration; + if (lardon3d_optical_configuration_create(database, &configuration_input, + &configuration) != + LARDON3D_PROJECT_DB_OK) + return false; + configuration_input.focal_length_um = 24000; + Lardon3DOpticalConfiguration alternate; + if (lardon3d_optical_configuration_create(database, &configuration_input, + &alternate) != + LARDON3D_PROJECT_DB_OK) + return false; + + Lardon3DProjectDbCapture capture; + if (lardon3d_project_db_create_capture(database, scanset.scanset_id, 1, + &capture) != LARDON3D_PROJECT_DB_OK) + return false; + fixture->scanset_id = scanset.scanset_id; + fixture->configuration_id = configuration.optical_configuration_id; + fixture->alternate_configuration_id = alternate.optical_configuration_id; + fixture->capture_id = capture.capture_id; + return true; +} + +static bool expect_group_assign_corrupt_without_row( + const char *path, uint64_t task_id, uint64_t configuration_id) { + Lardon3DProjectDb *database = NULL; + char error[LARDON3D_PROJECT_DB_ERROR_CAPACITY]; + if (lardon3d_project_db_open(path, &database, error) != + LARDON3D_PROJECT_DB_OK) + return false; + bool ok = lardon3d_optical_campaign_group_assign( + database, task_id, 1, configuration_id) == + LARDON3D_PROJECT_DB_CORRUPT; + lardon3d_project_db_close(database); + sqlite3_int64 count = -1; + return ok && + raw_integer(path, + "SELECT COUNT(*) FROM acquisition_campaign_group_optics", + &count) && + count == 0; +} + +static bool expect_retain_result_unadvanced( + const char *path, uint64_t task_id, uint64_t capture_id, + Lardon3DProjectDbResult expected) { + Lardon3DProjectDb *database = NULL; + char error[LARDON3D_PROJECT_DB_ERROR_CAPACITY]; + if (lardon3d_project_db_open(path, &database, error) != + LARDON3D_PROJECT_DB_OK) + return false; + bool ok = lardon3d_project_db_retain_acquisition_campaign_capture( + database, task_id, 1, capture_id, 1) == expected; + unsigned char request[16]; + Lardon3DProjectDbAcquisitionCampaignTask campaign; + Lardon3DProjectDbAcquisitionCampaignCapture mapping; + ok = ok && + lardon3d_project_db_load_acquisition_campaign_task( + database, task_id, request, sizeof(request), &campaign) == + LARDON3D_PROJECT_DB_OK && + campaign.next_group_id == 0 && + lardon3d_project_db_load_acquisition_campaign_capture( + database, task_id, 1, &mapping) == LARDON3D_PROJECT_DB_NOT_FOUND; + lardon3d_project_db_close(database); + return ok; +} + +static bool test_profiles_assignments_and_calibrations(void) { + char directory[64]; + char path[256]; + CHECK(make_database_path(directory, path)); + Lardon3DProjectDb *database = NULL; + char error[LARDON3D_PROJECT_DB_ERROR_CAPACITY]; + CHECK(lardon3d_project_db_open(path, &database, error) == + LARDON3D_PROJECT_DB_OK); + CHECK(lardon3d_project_db_schema_version(database) == 23); + + Lardon3DProjectDbScanSet scanset; + CHECK(lardon3d_project_db_create_scanset(database, "mixed optical campaign", + &scanset) == LARDON3D_PROJECT_DB_OK); + + Lardon3DOpticalCameraBodyProfile sony_input = {0}; + memcpy(sony_input.manufacturer, "Sony", 5); + memcpy(sony_input.model, "ILCE-6000", 10); + memcpy(sony_input.name, "A6000 body", 11); + Lardon3DOpticalCameraBodyProfile sony; + CHECK(lardon3d_optical_camera_body_create(database, &sony_input, &sony) == + LARDON3D_PROJECT_DB_OK); + Lardon3DOpticalCameraBodyProfile retry_body; + CHECK(lardon3d_optical_camera_body_create(database, &sony_input, &retry_body) == + LARDON3D_PROJECT_DB_OK && + retry_body.camera_body_profile_id == sony.camera_body_profile_id); + + Lardon3DOpticalCameraBodyAlias body_alias; + CHECK(lardon3d_optical_camera_body_alias_add( + database, sony.camera_body_profile_id, "SONY", "ILCE-6000", + &body_alias) == LARDON3D_PROJECT_DB_OK); + Lardon3DOpticalCameraBodyProfile alias_body; + CHECK(lardon3d_optical_camera_body_find_exact_alias( + database, "SONY", "ILCE-6000", &alias_body) == + LARDON3D_PROJECT_DB_OK && + alias_body.camera_body_profile_id == sony.camera_body_profile_id); + memset(&alias_body, 0x7f, sizeof(alias_body)); + CHECK(lardon3d_optical_camera_body_find_exact_alias( + database, "sony", "ILCE-6000", &alias_body) == + LARDON3D_PROJECT_DB_NOT_FOUND && + alias_body.camera_body_profile_id == 0 && alias_body.name[0] == '\0'); + + Lardon3DOpticalCameraBodyProfile second_body_input = {0}; + memcpy(second_body_input.manufacturer, "Samsung", 8); + memcpy(second_body_input.model, "SM-G990B", 9); + memcpy(second_body_input.name, "S21 FE body", 12); + Lardon3DOpticalCameraBodyProfile second_body; + CHECK(lardon3d_optical_camera_body_create(database, &second_body_input, + &second_body) == + LARDON3D_PROJECT_DB_OK); + Lardon3DOpticalCameraBodyAlias conflict_alias; + CHECK(lardon3d_optical_camera_body_alias_add( + database, second_body.camera_body_profile_id, "SONY", "ILCE-6000", + &conflict_alias) == LARDON3D_PROJECT_DB_CONSTRAINT); + + Lardon3DOpticalLensProfile zoom_input = { + .interface_kind = LARDON3D_OPTICAL_LENS_ELECTRONIC, + .focal_range_kind = LARDON3D_OPTICAL_FOCAL_RANGE_ZOOM, + .minimum_focal_um = 16000, + .maximum_focal_um = 50000, + }; + memcpy(zoom_input.manufacturer, "Sony", 5); + memcpy(zoom_input.model, "SELP1650", 9); + memcpy(zoom_input.name, "E PZ 16-50 OSS", 15); + Lardon3DOpticalLensProfile zoom; + CHECK(lardon3d_optical_lens_create(database, &zoom_input, &zoom) == + LARDON3D_PROJECT_DB_OK); + Lardon3DOpticalLensAlias zoom_alias; + CHECK(lardon3d_optical_lens_alias_add( + database, zoom.lens_profile_id, "SONY", + "E PZ 16-50mm F3.5-5.6 OSS", &zoom_alias) == + LARDON3D_PROJECT_DB_OK); + Lardon3DOpticalLensProfile exact_lens; + CHECK(lardon3d_optical_lens_find_exact_alias( + database, "SONY", "E PZ 16-50mm F3.5-5.6 OSS", &exact_lens) == + LARDON3D_PROJECT_DB_OK && + exact_lens.lens_profile_id == zoom.lens_profile_id); + + /* A manual lens has no electronics and therefore legitimately has an empty + alias page. It remains fully usable by explicit profile/config selection. */ + Lardon3DOpticalLensProfile meike_input = { + .interface_kind = LARDON3D_OPTICAL_LENS_MANUAL, + .focal_range_kind = LARDON3D_OPTICAL_FOCAL_RANGE_PRIME, + .minimum_focal_um = 12000, + .maximum_focal_um = 12000, + }; + memcpy(meike_input.manufacturer, "Meike", 6); + memcpy(meike_input.model, "12mm F2.8", sizeof("12mm F2.8")); + memcpy(meike_input.name, "Meike manual 12 mm", 19); + Lardon3DOpticalLensProfile meike; + CHECK(lardon3d_optical_lens_create(database, &meike_input, &meike) == + LARDON3D_PROJECT_DB_OK); + Lardon3DOpticalLensAlias no_alias_items[2]; + size_t no_alias_count = 99; + uint64_t no_alias_next = 99; + CHECK(lardon3d_optical_lens_alias_list( + database, meike.lens_profile_id, 0, no_alias_items, 2, + &no_alias_count, &no_alias_next) == LARDON3D_PROJECT_DB_OK && + no_alias_count == 0 && no_alias_next == 0); + + Lardon3DOpticalLensProfile prime_input = { + .interface_kind = LARDON3D_OPTICAL_LENS_ELECTRONIC, + .focal_range_kind = LARDON3D_OPTICAL_FOCAL_RANGE_PRIME, + .minimum_focal_um = 50000, + .maximum_focal_um = 50000, + }; + memcpy(prime_input.manufacturer, "Generic", 8); + memcpy(prime_input.model, "50 Prime", 9); + memcpy(prime_input.name, "New 50 mm profile", 18); + Lardon3DOpticalLensProfile prime; + CHECK(lardon3d_optical_lens_create(database, &prime_input, &prime) == + LARDON3D_PROJECT_DB_OK); + + Lardon3DOpticalLensProfile lens_page[2]; + size_t lens_count = 0; + uint64_t lens_next = 0; + CHECK(lardon3d_optical_lens_list(database, 0, lens_page, 2, &lens_count, + &lens_next) == LARDON3D_PROJECT_DB_OK && + lens_count == 2 && lens_next == meike.lens_profile_id); + CHECK(lardon3d_optical_lens_list(database, lens_next, lens_page, 2, + &lens_count, &lens_next) == + LARDON3D_PROJECT_DB_OK && + lens_count == 1 && lens_page[0].lens_profile_id == prime.lens_profile_id); + + Lardon3DOpticalLensProfile unknown_manual_input = { + .interface_kind = LARDON3D_OPTICAL_LENS_MANUAL, + .focal_range_kind = LARDON3D_OPTICAL_FOCAL_RANGE_UNKNOWN, + }; + memcpy(unknown_manual_input.name, "Unidentified manual lens", + sizeof("Unidentified manual lens")); + Lardon3DOpticalLensProfile unknown_manual; + CHECK(lardon3d_optical_lens_create(database, &unknown_manual_input, + &unknown_manual) == + LARDON3D_PROJECT_DB_OK && + unknown_manual.manufacturer[0] == '\0' && + unknown_manual.model[0] == '\0' && + unknown_manual.minimum_focal_um == 0 && + unknown_manual.maximum_focal_um == 0); + + Lardon3DOpticalConfiguration configuration_input = { + .camera_body_profile_id = sony.camera_body_profile_id, + .lens_profile_id = zoom.lens_profile_id, + .has_focal_length = true, + .focal_length_um = 16000, + }; + Lardon3DOpticalConfiguration config16; + CHECK(lardon3d_optical_configuration_create(database, &configuration_input, + &config16) == + LARDON3D_PROJECT_DB_OK); + configuration_input.focal_length_um = 24000; + Lardon3DOpticalConfiguration config24; + CHECK(lardon3d_optical_configuration_create(database, &configuration_input, + &config24) == + LARDON3D_PROJECT_DB_OK); + configuration_input.focal_length_um = 50000; + Lardon3DOpticalConfiguration config50; + CHECK(lardon3d_optical_configuration_create(database, &configuration_input, + &config50) == + LARDON3D_PROJECT_DB_OK); + CHECK(config16.optical_configuration_id != config24.optical_configuration_id && + config24.optical_configuration_id != config50.optical_configuration_id); + configuration_input.focal_length_um = 51000; + Lardon3DOpticalConfiguration invalid_configuration; + CHECK(lardon3d_optical_configuration_create(database, &configuration_input, + &invalid_configuration) == + LARDON3D_PROJECT_DB_CONSTRAINT); + + Lardon3DOpticalConfiguration meike_configuration_input = { + .camera_body_profile_id = sony.camera_body_profile_id, + .lens_profile_id = meike.lens_profile_id, + .has_focal_length = true, + .focal_length_um = 12000, + }; + Lardon3DOpticalConfiguration meike_configuration; + CHECK(lardon3d_optical_configuration_create( + database, &meike_configuration_input, &meike_configuration) == + LARDON3D_PROJECT_DB_OK); + meike_configuration_input.focal_length_um = 13000; + CHECK(lardon3d_optical_configuration_create( + database, &meike_configuration_input, &invalid_configuration) == + LARDON3D_PROJECT_DB_CONSTRAINT); + + Lardon3DProjectDbCapture captures[5]; + for (size_t index = 0; index < 5; ++index) + CHECK(lardon3d_project_db_create_capture(database, scanset.scanset_id, + (int64_t)index + 1, + &captures[index]) == + LARDON3D_PROJECT_DB_OK); + + Lardon3DOpticalCaptureAssignment unresolved; + memset(&unresolved, 0x7f, sizeof(unresolved)); + CHECK(lardon3d_optical_capture_assignment_load( + database, captures[4].capture_id, &unresolved) == + LARDON3D_PROJECT_DB_NOT_FOUND && + unresolved.capture_id == 0 && unresolved.optical_configuration_id == 0); + CHECK(lardon3d_optical_capture_assign_explicit( + database, captures[3].capture_id, + meike_configuration.optical_configuration_id) == + LARDON3D_PROJECT_DB_OK); + CHECK(lardon3d_optical_capture_assign_explicit( + database, captures[3].capture_id, + meike_configuration.optical_configuration_id) == + LARDON3D_PROJECT_DB_OK); + CHECK(lardon3d_optical_capture_assign_explicit( + database, captures[3].capture_id, + config16.optical_configuration_id) == + LARDON3D_PROJECT_DB_CONSTRAINT); + Lardon3DOpticalCaptureAssignment manual_assignment; + CHECK(lardon3d_optical_capture_assignment_load( + database, captures[3].capture_id, &manual_assignment) == + LARDON3D_PROJECT_DB_OK && + manual_assignment.provenance == + LARDON3D_OPTICAL_ASSIGNMENT_CALLER_EXPLICIT && + !manual_assignment.has_campaign_origin); + + CHECK(seed_campaign(database, 101, scanset.scanset_id, 1)); + CHECK(lardon3d_optical_campaign_group_assign( + database, 101, 1, config16.optical_configuration_id) == + LARDON3D_PROJECT_DB_OK); + CHECK(lardon3d_project_db_retain_acquisition_campaign_capture( + database, 101, 1, captures[3].capture_id, 1) == + LARDON3D_PROJECT_DB_CONSTRAINT); + Lardon3DProjectDbAcquisitionCampaignCapture conflicted_mapping; + CHECK(lardon3d_project_db_load_acquisition_campaign_capture( + database, 101, 1, &conflicted_mapping) == + LARDON3D_PROJECT_DB_NOT_FOUND); + unsigned char conflict_request[16]; + Lardon3DProjectDbAcquisitionCampaignTask conflict_campaign; + CHECK(lardon3d_project_db_load_acquisition_campaign_task( + database, 101, conflict_request, sizeof(conflict_request), + &conflict_campaign) == LARDON3D_PROJECT_DB_OK && + conflict_campaign.next_group_id == 0); + + CHECK(seed_campaign(database, 100, scanset.scanset_id, 3)); + CHECK(lardon3d_optical_campaign_group_assign( + database, 100, 1, config16.optical_configuration_id) == + LARDON3D_PROJECT_DB_OK); + CHECK(lardon3d_optical_campaign_group_assign( + database, 100, 3, config50.optical_configuration_id) == + LARDON3D_PROJECT_DB_OK); + CHECK(lardon3d_optical_campaign_group_assign( + database, 100, 1, config16.optical_configuration_id) == + LARDON3D_PROJECT_DB_OK); + CHECK(lardon3d_optical_campaign_group_assign( + database, 100, 1, config24.optical_configuration_id) == + LARDON3D_PROJECT_DB_CONSTRAINT); + Lardon3DOpticalCampaignGroupAssignment loaded_group; + CHECK(lardon3d_optical_campaign_group_load(database, 100, 1, &loaded_group) == + LARDON3D_PROJECT_DB_OK && + loaded_group.optical_configuration_id == + config16.optical_configuration_id); + memset(&loaded_group, 0x7f, sizeof(loaded_group)); + CHECK(lardon3d_optical_campaign_group_load(database, 100, 2, &loaded_group) == + LARDON3D_PROJECT_DB_NOT_FOUND && + loaded_group.campaign_task_id == 0); + + CHECK(lardon3d_project_db_retain_acquisition_campaign_capture( + database, 100, 1, captures[0].capture_id, 1) == + LARDON3D_PROJECT_DB_OK); + Lardon3DOpticalCaptureAssignment campaign_assignment; + CHECK(lardon3d_optical_capture_assignment_load( + database, captures[0].capture_id, &campaign_assignment) == + LARDON3D_PROJECT_DB_OK && + campaign_assignment.optical_configuration_id == + config16.optical_configuration_id && + campaign_assignment.provenance == LARDON3D_OPTICAL_ASSIGNMENT_CAMPAIGN && + campaign_assignment.has_campaign_origin && + campaign_assignment.campaign_task_id == 100 && + campaign_assignment.campaign_group_id == 1); + CHECK(lardon3d_project_db_retain_acquisition_campaign_capture( + database, 100, 1, captures[0].capture_id, 1) == + LARDON3D_PROJECT_DB_OK); + CHECK(lardon3d_optical_campaign_group_assign( + database, 100, 2, config24.optical_configuration_id) == + LARDON3D_PROJECT_DB_CONSTRAINT); + + CHECK(lardon3d_project_db_retain_acquisition_campaign_capture( + database, 100, 2, captures[1].capture_id, 2) == + LARDON3D_PROJECT_DB_OK); + memset(&unresolved, 0x7f, sizeof(unresolved)); + CHECK(lardon3d_optical_capture_assignment_load( + database, captures[1].capture_id, &unresolved) == + LARDON3D_PROJECT_DB_NOT_FOUND && + unresolved.capture_id == 0); + + /* Failure after mapping+optics insertion but before cursor publication must + roll back all three durable facts as one unit. */ + CHECK(setenv("LARDON3D_TEST_PROJECT_DB_FAIL_CAMPAIGN_OPTICS_COPY", "1", 1) == + 0); + CHECK(lardon3d_project_db_retain_acquisition_campaign_capture( + database, 100, 3, captures[2].capture_id, 3) != + LARDON3D_PROJECT_DB_OK); + CHECK(unsetenv("LARDON3D_TEST_PROJECT_DB_FAIL_CAMPAIGN_OPTICS_COPY") == 0); + Lardon3DProjectDbAcquisitionCampaignCapture missing_mapping; + CHECK(lardon3d_project_db_load_acquisition_campaign_capture( + database, 100, 3, &missing_mapping) == LARDON3D_PROJECT_DB_NOT_FOUND); + CHECK(lardon3d_optical_capture_assignment_load( + database, captures[2].capture_id, &unresolved) == + LARDON3D_PROJECT_DB_NOT_FOUND); + unsigned char campaign_request[16]; + Lardon3DProjectDbAcquisitionCampaignTask campaign_state; + CHECK(lardon3d_project_db_load_acquisition_campaign_task( + database, 100, campaign_request, sizeof(campaign_request), + &campaign_state) == LARDON3D_PROJECT_DB_OK && + campaign_state.next_group_id == 2); + CHECK(lardon3d_project_db_retain_acquisition_campaign_capture( + database, 100, 3, captures[2].capture_id, 3) == + LARDON3D_PROJECT_DB_OK); + CHECK(lardon3d_optical_capture_assignment_load( + database, captures[2].capture_id, &campaign_assignment) == + LARDON3D_PROJECT_DB_OK && + campaign_assignment.optical_configuration_id == + config50.optical_configuration_id); + + Lardon3DSparseCalibration calibration_a; + Lardon3DSparseCalibration calibration_b; + Lardon3DSparseCalibration calibration_other; + CHECK(create_sparse_calibration(database, 1, 6000, 4000, &calibration_a)); + CHECK(create_sparse_calibration(database, 2, 6000, 4000, &calibration_b)); + CHECK(create_sparse_calibration(database, 3, 4000, 3000, + &calibration_other)); + + Lardon3DOpticalCalibrationProfile compatible[3]; + size_t compatible_count = 77; + uint64_t compatible_next = 77; + CHECK(lardon3d_optical_calibration_profile_list_compatible( + database, config16.optical_configuration_id, 0, compatible, 3, + &compatible_count, &compatible_next) == LARDON3D_PROJECT_DB_OK && + compatible_count == 0 && compatible_next == 0); + + Lardon3DOpticalCalibrationProfile profile_input = { + .optical_configuration_id = config16.optical_configuration_id, + .sparse_calibration_id = calibration_a.calibration_id, + .profile_version = 1, + .applicability = LARDON3D_OPTICAL_CALIBRATION_EXACT_CONFIGURATION, + .created_at = 100, + }; + memcpy(profile_input.name, "Lab calibration", 16); + memcpy(profile_input.provenance, "Dedicated checkerboard session", 31); + Lardon3DOpticalCalibrationProfile profile_a; + CHECK(lardon3d_optical_calibration_profile_create(database, &profile_input, + &profile_a) == + LARDON3D_PROJECT_DB_OK); + compatible_count = 0; + compatible_next = 0; + CHECK(lardon3d_optical_calibration_profile_list_compatible( + database, config16.optical_configuration_id, 0, compatible, 3, + &compatible_count, &compatible_next) == LARDON3D_PROJECT_DB_OK && + compatible_count == 1 && + compatible[0].calibration_profile_id == profile_a.calibration_profile_id); + Lardon3DOpticalCalibrationProfile profile_retry; + CHECK(lardon3d_optical_calibration_profile_create(database, &profile_input, + &profile_retry) == + LARDON3D_PROJECT_DB_OK && + profile_retry.calibration_profile_id == profile_a.calibration_profile_id); + profile_input.sparse_calibration_id = calibration_b.calibration_id; + memcpy(profile_input.name, "Field calibration", 18); + profile_input.created_at = 101; + Lardon3DOpticalCalibrationProfile profile_b; + CHECK(lardon3d_optical_calibration_profile_create(database, &profile_input, + &profile_b) == + LARDON3D_PROJECT_DB_OK); + profile_input.optical_configuration_id = config50.optical_configuration_id; + profile_input.sparse_calibration_id = calibration_other.calibration_id; + memcpy(profile_input.name, "Fifty calibration", 18); + profile_input.created_at = 102; + Lardon3DOpticalCalibrationProfile profile_other; + CHECK(lardon3d_optical_calibration_profile_create(database, &profile_input, + &profile_other) == + LARDON3D_PROJECT_DB_OK); + profile_input.sparse_calibration_id = INT64_MAX; + memcpy(profile_input.name, "Missing calibration", 20); + CHECK(lardon3d_optical_calibration_profile_create(database, &profile_input, + &profile_retry) == + LARDON3D_PROJECT_DB_NOT_FOUND && + profile_retry.calibration_profile_id == 0); + + compatible_count = 0; + compatible_next = 0; + CHECK(lardon3d_optical_calibration_profile_list_compatible( + database, config16.optical_configuration_id, 0, compatible, 3, + &compatible_count, &compatible_next) == LARDON3D_PROJECT_DB_OK && + compatible_count == 2 && + compatible[0].calibration_profile_id == profile_a.calibration_profile_id && + compatible[1].calibration_profile_id == profile_b.calibration_profile_id); + Lardon3DOpticalCaptureCalibrationSelection selection; + memset(&selection, 0x7f, sizeof(selection)); + CHECK(lardon3d_optical_capture_calibration_selection_load( + database, captures[0].capture_id, &selection) == + LARDON3D_PROJECT_DB_NOT_FOUND && + selection.capture_id == 0); + CHECK(lardon3d_optical_capture_calibration_select( + database, captures[0].capture_id, + profile_other.calibration_profile_id) == + LARDON3D_PROJECT_DB_CONSTRAINT); + CHECK(lardon3d_optical_capture_calibration_select( + database, captures[0].capture_id, profile_a.calibration_profile_id) == + LARDON3D_PROJECT_DB_OK); + CHECK(lardon3d_optical_capture_calibration_select( + database, captures[0].capture_id, profile_a.calibration_profile_id) == + LARDON3D_PROJECT_DB_OK); + CHECK(lardon3d_optical_capture_calibration_select( + database, captures[0].capture_id, profile_b.calibration_profile_id) == + LARDON3D_PROJECT_DB_CONSTRAINT); + CHECK(lardon3d_optical_capture_calibration_selection_load( + database, captures[0].capture_id, &selection) == + LARDON3D_PROJECT_DB_OK && + selection.calibration_profile_id == profile_a.calibration_profile_id && + selection.optical_configuration_id == config16.optical_configuration_id && + selection.sparse_calibration_id == calibration_a.calibration_id); + CHECK(lardon3d_optical_capture_calibration_select( + database, captures[1].capture_id, profile_a.calibration_profile_id) == + LARDON3D_PROJECT_DB_CONSTRAINT); + + lardon3d_project_db_close(database); + database = NULL; + + /* Dynamic SQLite typing is adversarially corrupted after disabling checks; + public loaders must reject before narrowing and leave outputs zeroed. */ + char corruption[1024]; + int corruption_bytes = snprintf( + corruption, sizeof(corruption), + "PRAGMA ignore_check_constraints=ON;UPDATE lens_profiles SET " + "focal_range_kind='zoom-ish' WHERE lens_profile_id=%llu;", + (unsigned long long)zoom.lens_profile_id); + CHECK(corruption_bytes > 0 && (size_t)corruption_bytes < sizeof(corruption)); + CHECK(raw_sql(path, corruption)); + CHECK(lardon3d_project_db_open(path, &database, error) == + LARDON3D_PROJECT_DB_OK); + memset(&exact_lens, 0x7f, sizeof(exact_lens)); + CHECK(lardon3d_optical_lens_load(database, zoom.lens_profile_id, &exact_lens) == + LARDON3D_PROJECT_DB_CORRUPT && + exact_lens.lens_profile_id == 0 && exact_lens.name[0] == '\0'); + lardon3d_project_db_close(database); + database = NULL; + + corruption_bytes = snprintf( + corruption, sizeof(corruption), + "PRAGMA ignore_check_constraints=ON;UPDATE lens_profiles SET " + "focal_range_kind=3 WHERE lens_profile_id=%llu;UPDATE " + "optical_configurations SET focal_length_um='16000x' WHERE " + "optical_configuration_id=%llu;", + (unsigned long long)zoom.lens_profile_id, + (unsigned long long)config16.optical_configuration_id); + CHECK(corruption_bytes > 0 && (size_t)corruption_bytes < sizeof(corruption)); + CHECK(raw_sql(path, corruption)); + CHECK(lardon3d_project_db_open(path, &database, error) == + LARDON3D_PROJECT_DB_OK); + Lardon3DOpticalConfiguration corrupt_configuration; + memset(&corrupt_configuration, 0x7f, sizeof(corrupt_configuration)); + CHECK(lardon3d_optical_configuration_load( + database, config16.optical_configuration_id, + &corrupt_configuration) == LARDON3D_PROJECT_DB_CORRUPT && + corrupt_configuration.optical_configuration_id == 0); + lardon3d_project_db_close(database); + database = NULL; + + corruption_bytes = snprintf( + corruption, sizeof(corruption), + "PRAGMA foreign_keys=OFF;UPDATE optical_configurations SET " + "focal_length_um=16000 WHERE optical_configuration_id=%llu;" + "UPDATE captures SET scanset_id=9223372036854775807 WHERE " + "capture_id=%llu;", + (unsigned long long)config16.optical_configuration_id, + (unsigned long long)captures[3].capture_id); + CHECK(corruption_bytes > 0 && (size_t)corruption_bytes < sizeof(corruption)); + CHECK(raw_sql(path, corruption)); + CHECK(lardon3d_project_db_open(path, &database, error) == + LARDON3D_PROJECT_DB_OK); + memset(&manual_assignment, 0x7f, sizeof(manual_assignment)); + CHECK(lardon3d_optical_capture_assignment_load( + database, captures[3].capture_id, &manual_assignment) == + LARDON3D_PROJECT_DB_CORRUPT && + manual_assignment.capture_id == 0); + lardon3d_project_db_close(database); + database = NULL; + + CHECK(raw_sql(path, + "PRAGMA ignore_check_constraints=ON;UPDATE " + "acquisition_campaign_captures SET group_id='1x' WHERE " + "task_id=100 AND group_id=1;")); + CHECK(lardon3d_project_db_open(path, &database, error) == + LARDON3D_PROJECT_DB_OK); + CHECK(lardon3d_optical_campaign_group_assign( + database, 100, 1, config16.optical_configuration_id) == + LARDON3D_PROJECT_DB_CORRUPT); + memset(&loaded_group, 0x7f, sizeof(loaded_group)); + CHECK(lardon3d_optical_campaign_group_load(database, 100, 1, &loaded_group) == + LARDON3D_PROJECT_DB_CORRUPT && + loaded_group.campaign_task_id == 0); + memset(&campaign_assignment, 0x7f, sizeof(campaign_assignment)); + CHECK(lardon3d_optical_capture_assignment_load( + database, captures[0].capture_id, &campaign_assignment) == + LARDON3D_PROJECT_DB_CORRUPT && + campaign_assignment.capture_id == 0); + lardon3d_project_db_close(database); + + CHECK(unlink(path) == 0); + CHECK(rmdir(directory) == 0); + return true; +} + +static bool test_calibration_conflict_result_contract(void) { + char directory[64]; + char path[256]; + CHECK(make_database_path(directory, path)); + Lardon3DProjectDb *database = NULL; + char error[LARDON3D_PROJECT_DB_ERROR_CAPACITY]; + CHECK(lardon3d_project_db_open(path, &database, error) == + LARDON3D_PROJECT_DB_OK); + RepairOpticalFixture fixture; + CHECK(seed_repair_optical_fixture(database, &fixture)); + Lardon3DSparseCalibration calibration; + CHECK(create_sparse_calibration(database, 71, 6000, 4000, &calibration)); + + Lardon3DOpticalCalibrationProfile input = { + .optical_configuration_id = fixture.configuration_id, + .sparse_calibration_id = calibration.calibration_id, + .profile_version = 1, + .applicability = LARDON3D_OPTICAL_CALIBRATION_EXACT_CONFIGURATION, + .created_at = 10, + }; + memcpy(input.name, "Retry identity", sizeof("Retry identity")); + memcpy(input.provenance, "Original provenance", + sizeof("Original provenance")); + Lardon3DOpticalCalibrationProfile created; + CHECK(lardon3d_optical_calibration_profile_create(database, &input, &created) == + LARDON3D_PROJECT_DB_OK); + + Lardon3DOpticalCalibrationProfile conflict = input; + memset(conflict.provenance, 0, sizeof(conflict.provenance)); + memcpy(conflict.provenance, "Different valid provenance", + sizeof("Different valid provenance")); + Lardon3DOpticalCalibrationProfile conflict_output; + memset(&conflict_output, 0x7f, sizeof(conflict_output)); + CHECK(lardon3d_optical_calibration_profile_create( + database, &conflict, &conflict_output) == + LARDON3D_PROJECT_DB_CONSTRAINT && + conflict_output.calibration_profile_id == 0 && + conflict_output.provenance[0] == '\0'); + Lardon3DOpticalCalibrationProfile unchanged; + CHECK(lardon3d_optical_calibration_profile_load( + database, created.calibration_profile_id, &unchanged) == + LARDON3D_PROJECT_DB_OK && + strcmp(unchanged.provenance, input.provenance) == 0 && + unchanged.created_at == input.created_at); + lardon3d_project_db_close(database); + database = NULL; + sqlite3_int64 count = 0; + CHECK(raw_integer(path, "SELECT COUNT(*) FROM optical_calibration_profiles", + &count) && + count == 1); + + /* A BLOB in the TEXT property is durable corruption, not a second kind of + caller conflict, even when the natural-key fields still match. */ + CHECK(raw_sql(path, + "PRAGMA ignore_check_constraints=ON;UPDATE " + "optical_calibration_profiles SET provenance=X'626164';")); + CHECK(lardon3d_project_db_open(path, &database, error) == + LARDON3D_PROJECT_DB_OK); + memset(&conflict_output, 0x7f, sizeof(conflict_output)); + CHECK(lardon3d_optical_calibration_profile_create( + database, &input, &conflict_output) == LARDON3D_PROJECT_DB_CORRUPT && + conflict_output.calibration_profile_id == 0 && + conflict_output.provenance[0] == '\0'); + lardon3d_project_db_close(database); + CHECK(raw_integer(path, "SELECT COUNT(*) FROM optical_calibration_profiles", + &count) && + count == 1); + CHECK(unlink(path) == 0); + CHECK(rmdir(directory) == 0); + return true; +} + +static bool test_calibration_dependency_corruption_precedence(void) { + char directory[64]; + char path[256]; + CHECK(make_database_path(directory, path)); + Lardon3DProjectDb *database = NULL; + char error[LARDON3D_PROJECT_DB_ERROR_CAPACITY]; + CHECK(lardon3d_project_db_open(path, &database, error) == + LARDON3D_PROJECT_DB_OK); + RepairOpticalFixture fixture; + CHECK(seed_repair_optical_fixture(database, &fixture)); + Lardon3DSparseCalibration calibration; + Lardon3DSparseCalibration alternate_calibration; + CHECK(create_sparse_calibration(database, 81, 6000, 4000, &calibration)); + CHECK(create_sparse_calibration(database, 82, 6000, 4000, + &alternate_calibration)); + + Lardon3DOpticalCalibrationProfile input = { + .optical_configuration_id = fixture.configuration_id, + .sparse_calibration_id = calibration.calibration_id, + .profile_version = 1, + .applicability = LARDON3D_OPTICAL_CALIBRATION_EXACT_CONFIGURATION, + .created_at = 20, + }; + memcpy(input.name, "Dependency identity", sizeof("Dependency identity")); + memcpy(input.provenance, "Dependency provenance", + sizeof("Dependency provenance")); + Lardon3DOpticalCalibrationProfile created; + CHECK(lardon3d_optical_calibration_profile_create(database, &input, &created) == + LARDON3D_PROJECT_DB_OK); + lardon3d_project_db_close(database); + database = NULL; + + char sql[512]; + char unchanged_query[512]; + int unchanged_bytes = snprintf( + unchanged_query, sizeof(unchanged_query), + "SELECT COUNT(*) FROM optical_calibration_profiles WHERE " + "calibration_profile_id=%llu AND optical_configuration_id=%llu AND " + "sparse_calibration_id=%llu AND name='Dependency identity' AND " + "profile_version=1 AND provenance='Dependency provenance' AND " + "applicability=1 AND created_at=20", + (unsigned long long)created.calibration_profile_id, + (unsigned long long)fixture.configuration_id, + (unsigned long long)calibration.calibration_id); + CHECK(unchanged_bytes > 0 && + (size_t)unchanged_bytes < sizeof(unchanged_query)); + int bytes = snprintf( + sql, sizeof(sql), + "UPDATE sparse_calibrations SET scientific_hash=zeroblob(32) WHERE " + "calibration_id=%llu", + (unsigned long long)calibration.calibration_id); + CHECK(bytes > 0 && (size_t)bytes < sizeof(sql)); + CHECK(raw_sql(path, sql)); + CHECK(lardon3d_project_db_open(path, &database, error) == + LARDON3D_PROJECT_DB_OK); + + /* The natural-key row is durable, so its malformed scientific dependency + must win over both exact-retry and caller-conflict classification. */ + Lardon3DOpticalCalibrationProfile output; + memset(&output, 0x7f, sizeof(output)); + CHECK(lardon3d_optical_calibration_profile_create(database, &input, &output) == + LARDON3D_PROJECT_DB_CORRUPT && + all_bytes_zero(&output, sizeof(output))); + Lardon3DOpticalCalibrationProfile alternate = input; + alternate.sparse_calibration_id = alternate_calibration.calibration_id; + memset(&output, 0x7f, sizeof(output)); + CHECK(lardon3d_optical_calibration_profile_create( + database, &alternate, &output) == LARDON3D_PROJECT_DB_CORRUPT && + all_bytes_zero(&output, sizeof(output))); + lardon3d_project_db_close(database); + database = NULL; + + sqlite3_int64 count = 0; + CHECK(raw_integer(path, unchanged_query, &count) && count == 1); + + bytes = snprintf(sql, sizeof(sql), + "PRAGMA foreign_keys=OFF;DELETE FROM sparse_calibrations " + "WHERE calibration_id=%llu", + (unsigned long long)calibration.calibration_id); + CHECK(bytes > 0 && (size_t)bytes < sizeof(sql)); + CHECK(raw_sql(path, sql)); + CHECK(lardon3d_project_db_open(path, &database, error) == + LARDON3D_PROJECT_DB_OK); + + memset(&output, 0x7f, sizeof(output)); + CHECK(lardon3d_optical_calibration_profile_create(database, &input, &output) == + LARDON3D_PROJECT_DB_CORRUPT && + all_bytes_zero(&output, sizeof(output))); + memset(&output, 0x7f, sizeof(output)); + CHECK(lardon3d_optical_calibration_profile_create( + database, &alternate, &output) == LARDON3D_PROJECT_DB_CORRUPT && + all_bytes_zero(&output, sizeof(output))); + lardon3d_project_db_close(database); + + CHECK(raw_integer(path, "SELECT COUNT(*) FROM optical_calibration_profiles", + &count) && + count == 1); + CHECK(raw_integer(path, unchanged_query, &count) && count == 1); + CHECK(unlink(path) == 0); + CHECK(rmdir(directory) == 0); + return true; +} + +static bool test_campaign_request_corruption_prevents_optics_mutation(void) { + char directory[64]; + char path[256]; + CHECK(make_database_path(directory, path)); + Lardon3DProjectDb *database = NULL; + char error[LARDON3D_PROJECT_DB_ERROR_CAPACITY]; + CHECK(lardon3d_project_db_open(path, &database, error) == + LARDON3D_PROJECT_DB_OK); + RepairOpticalFixture fixture; + CHECK(seed_repair_optical_fixture(database, &fixture)); + CHECK(seed_campaign(database, 200, fixture.scanset_id, 1)); + lardon3d_project_db_close(database); + + CHECK(raw_sql(path, + "UPDATE acquisition_campaign_tasks SET request='not-a-blob' " + "WHERE task_id=200;")); + CHECK(expect_group_assign_corrupt_without_row( + path, 200, fixture.configuration_id)); + CHECK(raw_sql(path, + "PRAGMA ignore_check_constraints=ON;UPDATE " + "acquisition_campaign_tasks SET request=X'' WHERE task_id=200;")); + CHECK(expect_group_assign_corrupt_without_row( + path, 200, fixture.configuration_id)); + + char oversized_update[256]; + int bytes = snprintf( + oversized_update, sizeof(oversized_update), + "UPDATE acquisition_campaign_tasks SET request=zeroblob(%zu) WHERE " + "task_id=200;", + LARDON3D_ACQUISITION_CAMPAIGN_TASK_REQUEST_MAX_BYTES + 1u); + CHECK(bytes > 0 && (size_t)bytes < sizeof(oversized_update)); + CHECK(raw_sql(path, oversized_update)); + CHECK(expect_group_assign_corrupt_without_row( + path, 200, fixture.configuration_id)); + + CHECK(unlink(path) == 0); + CHECK(rmdir(directory) == 0); + return true; +} + +static bool test_capture_optics_conflict_and_corruption_rollback(void) { + char directory[64]; + char path[256]; + CHECK(make_database_path(directory, path)); + Lardon3DProjectDb *database = NULL; + char error[LARDON3D_PROJECT_DB_ERROR_CAPACITY]; + CHECK(lardon3d_project_db_open(path, &database, error) == + LARDON3D_PROJECT_DB_OK); + RepairOpticalFixture fixture; + CHECK(seed_repair_optical_fixture(database, &fixture)); + CHECK(seed_campaign(database, 300, fixture.scanset_id, 1)); + CHECK(lardon3d_optical_campaign_group_assign( + database, 300, 1, fixture.configuration_id) == + LARDON3D_PROJECT_DB_OK); + CHECK(lardon3d_optical_capture_assign_explicit( + database, fixture.capture_id, + fixture.alternate_configuration_id) == LARDON3D_PROJECT_DB_OK); + lardon3d_project_db_close(database); + + CHECK(expect_retain_result_unadvanced( + path, 300, fixture.capture_id, LARDON3D_PROJECT_DB_CONSTRAINT)); + CHECK(lardon3d_project_db_open(path, &database, error) == + LARDON3D_PROJECT_DB_OK); + Lardon3DOpticalCaptureAssignment assignment; + CHECK(lardon3d_optical_capture_assignment_load( + database, fixture.capture_id, &assignment) == + LARDON3D_PROJECT_DB_OK && + assignment.provenance == + LARDON3D_OPTICAL_ASSIGNMENT_CALLER_EXPLICIT && + assignment.optical_configuration_id == + fixture.alternate_configuration_id); + lardon3d_project_db_close(database); + + char mutation[512]; + int bytes = snprintf( + mutation, sizeof(mutation), + "PRAGMA ignore_check_constraints=ON;UPDATE " + "capture_optical_configurations SET assignment_provenance=X'31' WHERE " + "capture_id=%llu;", + (unsigned long long)fixture.capture_id); + CHECK(bytes > 0 && (size_t)bytes < sizeof(mutation)); + CHECK(raw_sql(path, mutation)); + CHECK(expect_retain_result_unadvanced( + path, 300, fixture.capture_id, LARDON3D_PROJECT_DB_CORRUPT)); + + bytes = snprintf( + mutation, sizeof(mutation), + "PRAGMA ignore_check_constraints=ON;UPDATE " + "capture_optical_configurations SET assignment_provenance=9," + "campaign_task_id=NULL,campaign_group_id=NULL WHERE capture_id=%llu;", + (unsigned long long)fixture.capture_id); + CHECK(bytes > 0 && (size_t)bytes < sizeof(mutation)); + CHECK(raw_sql(path, mutation)); + CHECK(expect_retain_result_unadvanced( + path, 300, fixture.capture_id, LARDON3D_PROJECT_DB_CORRUPT)); + + bytes = snprintf( + mutation, sizeof(mutation), + "PRAGMA ignore_check_constraints=ON;UPDATE " + "capture_optical_configurations SET assignment_provenance=2," + "campaign_task_id=300,campaign_group_id=1 WHERE capture_id=%llu;", + (unsigned long long)fixture.capture_id); + CHECK(bytes > 0 && (size_t)bytes < sizeof(mutation)); + CHECK(raw_sql(path, mutation)); + CHECK(expect_retain_result_unadvanced( + path, 300, fixture.capture_id, LARDON3D_PROJECT_DB_CORRUPT)); + + bytes = snprintf( + mutation, sizeof(mutation), + "PRAGMA foreign_keys=OFF;PRAGMA ignore_check_constraints=ON;DELETE FROM " + "capture_optical_configurations WHERE capture_id=%llu;INSERT INTO " + "capture_optical_configurations(capture_id,optical_configuration_id," + "assignment_provenance,campaign_task_id,campaign_group_id) VALUES(" + "%llu,%llu,1,300,1);", + (unsigned long long)fixture.capture_id, + (unsigned long long)fixture.capture_id, + (unsigned long long)fixture.configuration_id); + CHECK(bytes > 0 && (size_t)bytes < sizeof(mutation)); + CHECK(raw_sql(path, mutation)); + /* This exact-looking row is ahead of the cursor and must not be repaired by + the mapping insertion that is later rolled back. */ + CHECK(expect_retain_result_unadvanced( + path, 300, fixture.capture_id, LARDON3D_PROJECT_DB_CORRUPT)); + sqlite3_int64 count = 0; + CHECK(raw_integer(path, + "SELECT COUNT(*) FROM capture_optical_configurations", + &count) && + count == 1); + CHECK(raw_integer(path, "SELECT COUNT(*) FROM acquisition_campaign_captures", + &count) && + count == 0); + + CHECK(unlink(path) == 0); + CHECK(rmdir(directory) == 0); + return true; +} + +static bool test_malformed_configuration_rolls_back_campaign_retention(void) { + char directory[64]; + char path[256]; + CHECK(make_database_path(directory, path)); + Lardon3DProjectDb *database = NULL; + char error[LARDON3D_PROJECT_DB_ERROR_CAPACITY]; + CHECK(lardon3d_project_db_open(path, &database, error) == + LARDON3D_PROJECT_DB_OK); + RepairOpticalFixture fixture; + CHECK(seed_repair_optical_fixture(database, &fixture)); + CHECK(seed_campaign(database, 301, fixture.scanset_id, 1)); + CHECK(lardon3d_optical_campaign_group_assign( + database, 301, 1, fixture.configuration_id) == + LARDON3D_PROJECT_DB_OK); + lardon3d_project_db_close(database); + + char mutation[512]; + int bytes = snprintf( + mutation, sizeof(mutation), + "PRAGMA ignore_check_constraints=ON;UPDATE optical_configurations SET " + "focal_length_um='16000x' WHERE optical_configuration_id=%llu;", + (unsigned long long)fixture.configuration_id); + CHECK(bytes > 0 && (size_t)bytes < sizeof(mutation)); + CHECK(raw_sql(path, mutation)); + CHECK(expect_retain_result_unadvanced( + path, 301, fixture.capture_id, LARDON3D_PROJECT_DB_CORRUPT)); + sqlite3_int64 count = 0; + CHECK(raw_integer(path, "SELECT COUNT(*) FROM acquisition_campaign_captures", + &count) && + count == 0); + CHECK(raw_integer(path, + "SELECT COUNT(*) FROM capture_optical_configurations", + &count) && + count == 0); + + bytes = snprintf( + mutation, sizeof(mutation), + "PRAGMA ignore_check_constraints=ON;UPDATE optical_configurations SET " + "focal_length_um=16000 WHERE optical_configuration_id=%llu;UPDATE " + "lens_profiles SET focal_range_kind=2,minimum_focal_um=24000," + "maximum_focal_um=24000;", + (unsigned long long)fixture.configuration_id); + CHECK(bytes > 0 && (size_t)bytes < sizeof(mutation)); + CHECK(raw_sql(path, mutation)); + CHECK(expect_retain_result_unadvanced( + path, 301, fixture.capture_id, LARDON3D_PROJECT_DB_CORRUPT)); + CHECK(raw_integer(path, "SELECT COUNT(*) FROM acquisition_campaign_captures", + &count) && + count == 0); + CHECK(raw_integer(path, + "SELECT COUNT(*) FROM capture_optical_configurations", + &count) && + count == 0); + + CHECK(unlink(path) == 0); + CHECK(rmdir(directory) == 0); + return true; +} + +static bool downgrade_to_v22_fixture(const char *path) { + Lardon3DProjectDb *database = NULL; + char error[LARDON3D_PROJECT_DB_ERROR_CAPACITY]; + if (lardon3d_project_db_open(path, &database, error) != LARDON3D_PROJECT_DB_OK) + return false; + Lardon3DProjectDbScanSet a6000; + Lardon3DProjectDbScanSet s21; + Lardon3DProjectDbCapture capture; + Lardon3DSparseCalibration calibration; + bool ok = lardon3d_project_db_create_scanset(database, "A6000 originals", + &a6000) == + LARDON3D_PROJECT_DB_OK && + lardon3d_project_db_create_scanset(database, "S21 originals", &s21) == + LARDON3D_PROJECT_DB_OK && + lardon3d_project_db_create_capture(database, a6000.scanset_id, 1, + &capture) == + LARDON3D_PROJECT_DB_OK && + create_sparse_calibration(database, 91, 6000, 4000, &calibration) && + seed_campaign(database, 500, a6000.scanset_id, 1) && + lardon3d_project_db_retain_acquisition_campaign_capture( + database, 500, 1, capture.capture_id, 1) == + LARDON3D_PROJECT_DB_OK; + lardon3d_project_db_close(database); + if (!ok) + return false; + /* This removes only objects introduced by the test-created v23 database, + yielding a deterministic v22 fixture without rewriting any real project. */ + return raw_sql( + path, + "PRAGMA foreign_keys=OFF;BEGIN IMMEDIATE;" + "DROP TABLE capture_calibration_selections;" + "DROP TABLE optical_calibration_profiles;" + "DROP TABLE capture_optical_configurations;" + "DROP INDEX acquisition_campaign_capture_identity_v23;" + "DROP TABLE acquisition_campaign_group_optics;" + "DROP TABLE optical_configurations;" + "DROP TABLE lens_profile_aliases;DROP TABLE lens_profiles;" + "DROP TABLE camera_body_aliases;DROP TABLE camera_body_profiles;" + "UPDATE metadata SET value=22 WHERE key='schema_version';COMMIT;"); +} + +static bool test_migration_rollback_retry_and_equivalence(void) { + char migrated_directory[64]; + char migrated_path[256]; + char fresh_directory[64]; + char fresh_path[256]; + CHECK(make_database_path(migrated_directory, migrated_path)); + CHECK(make_database_path(fresh_directory, fresh_path)); + CHECK(downgrade_to_v22_fixture(migrated_path)); + + Lardon3DProjectDb *database = NULL; + char error[LARDON3D_PROJECT_DB_ERROR_CAPACITY]; + CHECK(setenv("LARDON3D_TEST_PROJECT_DB_FAIL_MIGRATION_V23", "1", 1) == 0); + CHECK(lardon3d_project_db_open(migrated_path, &database, error) != + LARDON3D_PROJECT_DB_OK && + database == NULL); + CHECK(unsetenv("LARDON3D_TEST_PROJECT_DB_FAIL_MIGRATION_V23") == 0); + sqlite3_int64 value = 0; + CHECK(raw_integer(migrated_path, + "SELECT value FROM metadata WHERE key='schema_version'", + &value) && + value == 22); + CHECK(raw_integer(migrated_path, + "SELECT COUNT(*) FROM sqlite_master WHERE name=" + "'camera_body_profiles'", + &value) && + value == 0); + CHECK(raw_integer(migrated_path, "SELECT COUNT(*) FROM captures", &value) && + value == 1); + CHECK(raw_integer(migrated_path, "SELECT COUNT(*) FROM sparse_calibrations", + &value) && + value == 1); + CHECK(raw_integer(migrated_path, + "SELECT COUNT(*) FROM acquisition_campaign_captures", + &value) && + value == 1); + + CHECK(lardon3d_project_db_open(migrated_path, &database, error) == + LARDON3D_PROJECT_DB_OK && + lardon3d_project_db_schema_version(database) == 23); + Lardon3DOpticalCaptureAssignment migrated_unresolved; + CHECK(lardon3d_optical_capture_assignment_load(database, 1, + &migrated_unresolved) == + LARDON3D_PROJECT_DB_NOT_FOUND && + migrated_unresolved.capture_id == 0); + unsigned char migrated_request[16]; + Lardon3DProjectDbAcquisitionCampaignTask migrated_campaign; + CHECK(lardon3d_project_db_load_acquisition_campaign_task( + database, 500, migrated_request, sizeof(migrated_request), + &migrated_campaign) == LARDON3D_PROJECT_DB_OK && + migrated_campaign.next_group_id == 1); + Lardon3DProjectDbAcquisitionCampaignCapture migrated_mapping; + CHECK(lardon3d_project_db_load_acquisition_campaign_capture( + database, 500, 1, &migrated_mapping) == LARDON3D_PROJECT_DB_OK && + migrated_mapping.capture_id == 1); + lardon3d_project_db_close(database); + database = NULL; + CHECK(raw_integer(migrated_path, "SELECT COUNT(*) FROM captures", &value) && + value == 1); + CHECK(raw_integer(migrated_path, "SELECT COUNT(*) FROM sparse_calibrations", + &value) && + value == 1); + const char *empty_tables[] = { + "camera_body_profiles", + "camera_body_aliases", + "lens_profiles", + "lens_profile_aliases", + "optical_configurations", + "acquisition_campaign_group_optics", + "capture_optical_configurations", + "optical_calibration_profiles", + "capture_calibration_selections", + }; + char query[256]; + for (size_t index = 0; index < sizeof(empty_tables) / sizeof(empty_tables[0]); + ++index) { + CHECK(snprintf(query, sizeof(query), "SELECT COUNT(*) FROM %s", + empty_tables[index]) > 0); + CHECK(raw_integer(migrated_path, query, &value) && value == 0); + } + + CHECK(lardon3d_project_db_open(fresh_path, &database, error) == + LARDON3D_PROJECT_DB_OK); + lardon3d_project_db_close(database); + database = NULL; + const char *objects[] = { + "camera_body_profiles", + "camera_body_aliases", + "camera_body_aliases_profile_idx", + "lens_profiles", + "lens_profile_aliases", + "lens_profile_aliases_profile_idx", + "optical_configurations", + "optical_configurations_body_idx", + "optical_configurations_lens_idx", + "acquisition_campaign_group_optics", + "acquisition_campaign_capture_identity_v23", + "capture_optical_configurations", + "capture_optical_configurations_config_idx", + "optical_calibration_profiles", + "optical_calibration_profiles_config_idx", + "optical_calibration_profiles_sparse_idx", + "capture_calibration_selections", + }; + char migrated_sql[8192]; + char fresh_sql[8192]; + for (size_t index = 0; index < sizeof(objects) / sizeof(objects[0]); ++index) { + CHECK(raw_object_sql(migrated_path, objects[index], migrated_sql, + sizeof(migrated_sql))); + CHECK(raw_object_sql(fresh_path, objects[index], fresh_sql, + sizeof(fresh_sql))); + CHECK(strcmp(migrated_sql, fresh_sql) == 0); + } + + CHECK(unlink(migrated_path) == 0); + CHECK(rmdir(migrated_directory) == 0); + CHECK(unlink(fresh_path) == 0); + CHECK(rmdir(fresh_directory) == 0); + return true; +} + +int main(void) { + return test_profiles_assignments_and_calibrations() && + test_calibration_conflict_result_contract() && + test_calibration_dependency_corruption_precedence() && + test_campaign_request_corruption_prevents_optics_mutation() && + test_capture_optics_conflict_and_corruption_rollback() && + test_malformed_configuration_rolls_back_campaign_retention() && + test_migration_rollback_retry_and_equivalence() + ? EXIT_SUCCESS + : EXIT_FAILURE; +} diff --git a/tests/test_persistent_image_catalog.c b/tests/test_persistent_image_catalog.c index c99eb73..0550726 100644 --- a/tests/test_persistent_image_catalog.c +++ b/tests/test_persistent_image_catalog.c @@ -12,8 +12,7 @@ #include -Lardon3DImageCatalogAssetPublishResult lardon3d_image_catalog_test_copy_hash( - int input, int output); +#include "../src/image_catalog_persistent_internal.h" #define CHECK(condition) do { if (!(condition)) { \ (void)fprintf(stderr, "Échec ligne %d : %s\n", __LINE__, #condition); return false; \ diff --git a/tests/test_photo_quality_task.cpp b/tests/test_photo_quality_task.cpp index fe02af9..6d26121 100644 --- a/tests/test_photo_quality_task.cpp +++ b/tests/test_photo_quality_task.cpp @@ -31,9 +31,9 @@ extern "C" const Lardon3DTaskKindRegistry *lardon3d_task_kind_registry_productio } static bool wait_terminal(Lardon3DTaskQueue *queue, uint64_t task_id, - Lardon3DTaskSnapshot *snapshot) { + Lardon3DTaskObservation *snapshot) { for (size_t attempt = 0; attempt < 500; ++attempt) { - if (lardon3d_task_queue_get(queue, task_id, snapshot) && + if (lardon3d_task_queue_get_observation(queue, task_id, snapshot) && (snapshot->state == TASK_COMPLETED || snapshot->state == TASK_FAILED || snapshot->state == TASK_CANCELLED)) return true; @@ -111,9 +111,11 @@ int main() { cv::setNumThreads(3); uint64_t task_id = 0; CHECK(lardon3d_project_enqueue_photo_quality(&state, scanset.scanset_id, &request, &task_id)); - Lardon3DTaskSnapshot terminal{}; + Lardon3DTaskObservation terminal{}; CHECK(wait_terminal(state.task_queue, task_id, &terminal)); - CHECK(terminal.state == TASK_COMPLETED && terminal.progress == 100); + CHECK(terminal.state == TASK_COMPLETED && terminal.progress == 100 && + terminal.durable_progress_known && terminal.durable_completed == 2 && + terminal.durable_total == 2); CHECK(cv::getNumThreads() == 3); Lardon3DProjectDbPhotoQualityResult paired{}, raw_only{}; CHECK(lardon3d_project_db_load_photo_quality_result(database, task_id, 1, &paired) == @@ -136,28 +138,105 @@ int main() { corrupt_request.size(), &completed_parameters) == LARDON3D_PROJECT_DB_OK); CHECK(completed_parameters.group_count == 2 && completed_parameters.next_group_id == 3); CHECK(lardon3d_resource_governor_reservation_count(state.resource_governor) == 0); + Lardon3DTaskDurableSnapshot mismatched_snapshot{}; + mismatched_snapshot.id = task_id; + std::snprintf(mismatched_snapshot.name, sizeof(mismatched_snapshot.name), + "quality kind mismatch"); + mismatched_snapshot.saved_state = mismatched_snapshot.recovery_state = TASK_PENDING; + CHECK(lardon3d_project_db_record_photo_quality_task( + database, &mismatched_snapshot, + LARDON3D_ACQUISITION_CAMPAIGN_TASK_KIND, + LARDON3D_ACQUISITION_CAMPAIGN_TASK_KIND_VERSION, nullptr, + &completed_parameters, 2) == LARDON3D_PROJECT_DB_INVALID_ARGUMENT); /* Corrupt values above the v21 operational group bound must be rejected * while still 64-bit SQLite integers, before narrowing into public fields. */ sqlite3 *corruptor = nullptr; CHECK(sqlite3_open(database_path.c_str(), &corruptor) == SQLITE_OK); - CHECK(sqlite3_exec(corruptor, "PRAGMA ignore_check_constraints=ON", nullptr, nullptr, - nullptr) == SQLITE_OK); + CHECK(sqlite3_exec(corruptor, + "PRAGMA foreign_keys=OFF;PRAGMA ignore_check_constraints=ON", + nullptr, nullptr, nullptr) == SQLITE_OK); const auto execute_corruption = [&](const std::string &assignment) { const std::string sql = "UPDATE photo_quality_triage_tasks SET " + assignment + " WHERE task_id=" + std::to_string(task_id); return sqlite3_exec(corruptor, sql.c_str(), nullptr, nullptr, nullptr) == SQLITE_OK; }; + std::vector corrupt_output(completed_parameters.request_size); Lardon3DProjectDbPhotoQualityTask corrupt_parameters{}; + const auto expect_task_failure = [&](uint64_t corrupt_task_id, + Lardon3DProjectDbResult expected) { + std::memset(corrupt_output.data(), 0xa5, corrupt_output.size()); + std::memset(&corrupt_parameters, 0xa5, sizeof(corrupt_parameters)); + if (lardon3d_project_db_load_photo_quality_task( + database, corrupt_task_id, corrupt_output.data(), + corrupt_output.size(), &corrupt_parameters) != expected) + return false; + if (corrupt_parameters.task_id != 0 || + corrupt_parameters.scanset_id != 0 || + corrupt_parameters.next_group_id != 0 || + corrupt_parameters.group_count != 0 || corrupt_parameters.request || + corrupt_parameters.request_size != 0) + return false; + for (unsigned char byte : corrupt_output) + if (byte != 0xa5) + return false; + return true; + }; + /* No typed business row is ordinary absence; only a present row with broken + * parent relations is durable corruption. Both failure classes hide outputs. */ + CHECK(expect_task_failure(INT64_MAX, LARDON3D_PROJECT_DB_NOT_FOUND)); for (const char *assignment : {"group_count=-1", "group_count=5000", "group_count=4294967296", "next_group_id=0", "next_group_id=4", "next_group_id=1e20", - "scanset_id=-1"}) { + "scanset_id=-1", "scanset_id=1e20", + "scanset_id=9223372036854775807"}) { CHECK(execute_corruption(assignment)); - CHECK(lardon3d_project_db_load_photo_quality_task(database, task_id, corrupt_request.data(), - corrupt_request.size(), &corrupt_parameters) == LARDON3D_PROJECT_DB_CORRUPT); + CHECK(expect_task_failure(task_id, LARDON3D_PROJECT_DB_CORRUPT)); CHECK(execute_corruption("scanset_id=" + std::to_string(scanset.scanset_id) + ",group_count=2,next_group_id=3")); } + CHECK(execute_corruption("request=CAST(request AS TEXT)")); + CHECK(expect_task_failure(task_id, LARDON3D_PROJECT_DB_CORRUPT)); + CHECK(execute_corruption("request=CAST(request AS BLOB)")); + + const auto execute_generic_corruption = [&](const std::string &assignment) { + const std::string sql = "UPDATE tasks SET " + assignment + + " WHERE task_id=" + std::to_string(task_id); + return sqlite3_exec(corruptor, sql.c_str(), nullptr, nullptr, nullptr) == + SQLITE_OK; + }; + CHECK(execute_generic_corruption("task_kind=task_kind||char(0)||'tail'")); + CHECK(expect_task_failure(task_id, LARDON3D_PROJECT_DB_CORRUPT)); + CHECK(execute_generic_corruption("task_kind='photo_quality.triage'")); + CHECK(execute_generic_corruption("task_kind_version=2")); + CHECK(expect_task_failure(task_id, LARDON3D_PROJECT_DB_CORRUPT)); + CHECK(execute_generic_corruption("task_kind_version=1")); + CHECK(execute_generic_corruption("task_kind_version=1e20")); + CHECK(expect_task_failure(task_id, LARDON3D_PROJECT_DB_CORRUPT)); + CHECK(execute_generic_corruption("task_kind_version=1")); + + /* The typed business row determines presence. Broken generic ownership must + * be reported as corruption instead of disappearing behind an inner join. */ + uint64_t orphan_task_id = 0; + CHECK(lardon3d_project_db_allocate_task_id(database, &orphan_task_id) == + LARDON3D_PROJECT_DB_OK); + Lardon3DTaskDurableSnapshot orphan_snapshot{}; + orphan_snapshot.id = orphan_task_id; + std::snprintf(orphan_snapshot.name, sizeof(orphan_snapshot.name), + "quality orphan fixture"); + orphan_snapshot.saved_state = orphan_snapshot.recovery_state = TASK_PENDING; + Lardon3DProjectDbPhotoQualityTask orphan_parameters{ + orphan_task_id, scanset.scanset_id, 1, 2, corrupt_request.data(), + completed_parameters.request_size}; + CHECK(lardon3d_project_db_record_photo_quality_task( + database, &orphan_snapshot, LARDON3D_PHOTO_QUALITY_TASK_KIND, + LARDON3D_PHOTO_QUALITY_TASK_KIND_VERSION, nullptr, + &orphan_parameters, 1) == LARDON3D_PROJECT_DB_OK); + CHECK(sqlite3_exec( + corruptor, + ("DELETE FROM tasks WHERE task_id=" + + std::to_string(orphan_task_id)).c_str(), + nullptr, nullptr, nullptr) == SQLITE_OK); + CHECK(expect_task_failure(orphan_task_id, LARDON3D_PROJECT_DB_CORRUPT)); const auto execute_result_corruption = [&](const std::string &assignment) { const std::string sql = "UPDATE photo_quality_triage_results SET " + assignment + @@ -308,7 +387,8 @@ int main() { restored != nullptr); CHECK(lardon3d_task_queue_add(state.task_queue, restored, nullptr)); CHECK(wait_terminal(state.task_queue, restart_id, &terminal)); - CHECK(terminal.state == TASK_COMPLETED); + CHECK(terminal.state == TASK_COMPLETED && terminal.durable_progress_known && + terminal.durable_completed == 2 && terminal.durable_total == 2); Lardon3DProjectDbPhotoQualityResult retained{}, resumed_raw{}; CHECK(lardon3d_project_db_load_photo_quality_result(database, restart_id, 1, &retained) == LARDON3D_PROJECT_DB_OK && retained.metrics.sharpness_raw == 12345.0); diff --git a/tests/test_project.c b/tests/test_project.c index d2e1f21..c35f79d 100644 --- a/tests/test_project.c +++ b/tests/test_project.c @@ -10,6 +10,7 @@ #include #include +#include #include #include @@ -112,6 +113,194 @@ release_rendezvous(int socket) return write(socket, &token, sizeof(token)) == (ssize_t)sizeof(token); } +typedef struct { + pthread_mutex_t mutex; + pthread_cond_t condition; + Lardon3DAppState *state; + bool callback_entered; + bool callback_release; + bool callback_finished; + bool database_read_ok; + bool boundary_started; + bool boundary_finished; + bool boundary_result; +} ProjectBoundaryProbe; + +static void +project_boundary_finished_callback( + const Lardon3DTask *task, + void *userdata +) +{ + (void)task; + ProjectBoundaryProbe *probe = userdata; + (void)pthread_mutex_lock(&probe->mutex); + probe->callback_entered = true; + (void)pthread_cond_broadcast(&probe->condition); + while (!probe->callback_release) { + (void)pthread_cond_wait(&probe->condition, &probe->mutex); + } + (void)pthread_mutex_unlock(&probe->mutex); + + Lardon3DProjectDbProject project; + bool database_read_ok = probe->state->project_db + && lardon3d_project_db_get_project(probe->state->project_db, &project) + == LARDON3D_PROJECT_DB_OK; + (void)pthread_mutex_lock(&probe->mutex); + probe->database_read_ok = database_read_ok; + probe->callback_finished = true; + (void)pthread_cond_broadcast(&probe->condition); + (void)pthread_mutex_unlock(&probe->mutex); +} + +static void * +project_boundary_thread(void *userdata) +{ + ProjectBoundaryProbe *probe = userdata; + (void)pthread_mutex_lock(&probe->mutex); + probe->boundary_started = true; + (void)pthread_cond_broadcast(&probe->condition); + (void)pthread_mutex_unlock(&probe->mutex); + bool result = lardon3d_runtime_project_boundary(probe->state, 64); + (void)pthread_mutex_lock(&probe->mutex); + probe->boundary_result = result; + probe->boundary_finished = true; + (void)pthread_cond_broadcast(&probe->condition); + (void)pthread_mutex_unlock(&probe->mutex); + return NULL; +} + +static bool +remove_empty_project(const char *root, const char *name) +{ + char path[512]; + const char *files[] = {"project.db", "project.ini"}; + const char *directories[] = { + ".lardon3d/checkpoints", ".lardon3d", "images", + "reconstruction", "exports", "logs", + }; + for (size_t index = 0; index < sizeof(files) / sizeof(files[0]); ++index) { + if (snprintf(path, sizeof(path), "%s/%s/%s", root, name, + files[index]) <= 0 || unlink(path) != 0) { + return false; + } + } + for (size_t index = 0; + index < sizeof(directories) / sizeof(directories[0]); ++index) { + if (snprintf(path, sizeof(path), "%s/%s/%s", root, name, + directories[index]) <= 0 || rmdir(path) != 0) { + return false; + } + } + return snprintf(path, sizeof(path), "%s/%s", root, name) > 0 + && rmdir(path) == 0; +} + +static bool +test_runtime_project_boundary(void) +{ + char root[] = "/tmp/lardon3d-project-boundary-XXXXXX"; + CHECK(mkdtemp(root)); + CHECK(setenv("LARDON3D_PROJECTS_ROOT", root, 1) == 0); + Lardon3DAppState state; + lardon3d_app_state_init(&state); + Lardon3DHardwareProfile profile = { + /* Keep admission deterministic even when the host running this + * lifecycle test has unrelated background load. */ + .logical_cpu_count = 64, + .page_size_bytes = 4096, + .memory_total_bytes = UINT64_MAX, + .cpu_architecture = "test", + }; + Lardon3DResourcePolicy policy = { + .maximum_cpu_load_ratio = 1.0, + .maximum_io_pressure_avg10 = 100.0, + .io_slot_capacity = 1, + }; + state.resource_governor = lardon3d_resource_governor_create( + &profile, &policy); + state.task_queue = state.resource_governor + ? lardon3d_task_queue_create(state.resource_governor, 64) : NULL; + CHECK(state.task_queue); + CHECK(lardon3d_project_create(&state, "Boundary A")); + + ProjectBoundaryProbe probe = {.state = &state}; + CHECK(pthread_mutex_init(&probe.mutex, NULL) == 0); + CHECK(pthread_cond_init(&probe.condition, NULL) == 0); + const Lardon3DResourceEstimate estimate = { + .minimum_batch_size = 1, + .maximum_batch_size = 1, + .desired_cpu_threads = 1, + }; + Lardon3DTask *first = lardon3d_task_create( + "Project A task", &estimate, unused_callback, NULL); + CHECK(first && lardon3d_task_set_finished_callback( + first, project_boundary_finished_callback, &probe)); + uint64_t first_id = 0; + CHECK(lardon3d_task_queue_add(state.task_queue, first, &first_id)); + CHECK(first_id == 1); + (void)pthread_mutex_lock(&probe.mutex); + while (!probe.callback_entered) { + (void)pthread_cond_wait(&probe.condition, &probe.mutex); + } + (void)pthread_mutex_unlock(&probe.mutex); + + pthread_t boundary; + CHECK(pthread_create(&boundary, NULL, project_boundary_thread, &probe) == 0); + (void)pthread_mutex_lock(&probe.mutex); + while (!probe.boundary_started) { + (void)pthread_cond_wait(&probe.condition, &probe.mutex); + } + CHECK(!probe.boundary_finished); + probe.callback_release = true; + (void)pthread_cond_broadcast(&probe.condition); + while (!probe.callback_finished) { + (void)pthread_cond_wait(&probe.condition, &probe.mutex); + } + (void)pthread_mutex_unlock(&probe.mutex); + CHECK(pthread_join(boundary, NULL) == 0); + CHECK(probe.database_read_ok); + CHECK(probe.boundary_result && probe.boundary_finished); + CHECK(!state.project_loaded && !state.project_db && state.task_queue); + Lardon3DTaskSnapshot snapshot; + CHECK(!lardon3d_task_queue_get(state.task_queue, first_id, &snapshot)); + + /* Project B may legitimately reuse operational Task ID 1 because the + * Project switch destroyed A's Queue/history before B was opened. */ + CHECK(lardon3d_project_create(&state, "Boundary B")); + (void)pthread_mutex_lock(&probe.mutex); + probe.callback_entered = false; + probe.callback_finished = false; + probe.database_read_ok = false; + (void)pthread_mutex_unlock(&probe.mutex); + Lardon3DTask *second = lardon3d_task_create( + "Project B task", &estimate, unused_callback, NULL); + uint64_t second_id = 0; + CHECK(second && lardon3d_task_set_finished_callback( + second, project_boundary_finished_callback, &probe) + && lardon3d_task_queue_add(state.task_queue, second, &second_id)); + CHECK(second_id == first_id); + (void)pthread_mutex_lock(&probe.mutex); + while (!probe.callback_finished) { + (void)pthread_cond_wait(&probe.condition, &probe.mutex); + } + CHECK(probe.database_read_ok); + (void)pthread_mutex_unlock(&probe.mutex); + + lardon3d_task_queue_destroy(state.task_queue); + state.task_queue = NULL; + lardon3d_project_close(&state); + lardon3d_resource_governor_destroy(state.resource_governor); + state.resource_governor = NULL; + CHECK(pthread_cond_destroy(&probe.condition) == 0); + CHECK(pthread_mutex_destroy(&probe.mutex) == 0); + CHECK(remove_empty_project(root, "Boundary A")); + CHECK(remove_empty_project(root, "Boundary B")); + CHECK(rmdir(root) == 0); + CHECK(unsetenv("LARDON3D_PROJECTS_ROOT") == 0); + return true; +} + static bool run_test(void) { @@ -458,4 +647,7 @@ run_test(void) return true; } -int main(void) { return run_test() ? EXIT_SUCCESS : EXIT_FAILURE; } +int main(void) { + return run_test() && test_runtime_project_boundary() + ? EXIT_SUCCESS : EXIT_FAILURE; +} diff --git a/tests/test_project_db.c b/tests/test_project_db.c index 0dafd48..187d587 100644 --- a/tests/test_project_db.c +++ b/tests/test_project_db.c @@ -21,6 +21,21 @@ } \ } while (0) +/* Historical migration fixtures are produced from a temporary current DB. + * Remove every additive v23 object first so the fixture really is historical + * rather than a lower version number with future tables left behind. */ +#define DROP_OPTICAL_V23_SQL \ + "DROP TABLE IF EXISTS capture_calibration_selections;" \ + "DROP TABLE IF EXISTS optical_calibration_profiles;" \ + "DROP TABLE IF EXISTS capture_optical_configurations;" \ + "DROP INDEX IF EXISTS acquisition_campaign_capture_identity_v23;" \ + "DROP TABLE IF EXISTS acquisition_campaign_group_optics;" \ + "DROP TABLE IF EXISTS optical_configurations;" \ + "DROP TABLE IF EXISTS lens_profile_aliases;" \ + "DROP TABLE IF EXISTS lens_profiles;" \ + "DROP TABLE IF EXISTS camera_body_aliases;" \ + "DROP TABLE IF EXISTS camera_body_profiles;" + Lardon3DProjectDbResult lardon3d_project_db_test_orphan_checkpoint(Lardon3DProjectDb *database); Lardon3DProjectDbResult lardon3d_project_db_test_delete_catalog_identity( @@ -116,7 +131,7 @@ static bool create_future_database(const char *path) { sqlite3_exec( connection, "CREATE TABLE metadata(key TEXT PRIMARY KEY,value INTEGER NOT NULL);" - "INSERT INTO metadata VALUES('schema_version',23);", + "INSERT INTO metadata VALUES('schema_version',24);", NULL, NULL, NULL) == SQLITE_OK; return sqlite3_close(connection) == SQLITE_OK && ok; } @@ -133,6 +148,7 @@ static bool create_v7_database(const char *path) { return false; static const char sql[] = "PRAGMA foreign_keys=OFF;BEGIN IMMEDIATE;" + DROP_OPTICAL_V23_SQL "DROP TABLE asset_derivations;DROP TABLE capture_selections;DROP TABLE capture_assets;" "DROP TABLE capture_images;DROP TABLE captures;" "DROP TABLE incremental_reconstruction_tasks;" @@ -174,6 +190,7 @@ static bool create_v6_database(const char *path) { return false; static const char sql[] = "PRAGMA foreign_keys=OFF;BEGIN IMMEDIATE;" + DROP_OPTICAL_V23_SQL "DROP TABLE asset_derivations;DROP TABLE capture_selections;DROP TABLE capture_assets;" "DROP TABLE capture_images;DROP TABLE captures;" "DROP TABLE incremental_reconstruction_tasks;" @@ -223,6 +240,7 @@ static bool create_v10_database(const char *path) { } static const char sql[] = "PRAGMA foreign_keys=OFF;BEGIN IMMEDIATE;" + DROP_OPTICAL_V23_SQL "DROP TABLE asset_derivations;DROP TABLE capture_selections;DROP TABLE capture_assets;" "DROP TABLE capture_images;DROP TABLE captures;" "DROP TABLE incremental_reconstruction_tasks;" @@ -261,6 +279,7 @@ static bool create_v13_database(const char *path) { return false; static const char sql[] = "PRAGMA foreign_keys=OFF;BEGIN IMMEDIATE;" + DROP_OPTICAL_V23_SQL "DROP TABLE asset_derivations;DROP TABLE capture_selections;DROP TABLE capture_assets;" "DROP TABLE capture_images;DROP TABLE captures;" "DROP TABLE incremental_reconstruction_tasks;" @@ -942,7 +961,8 @@ static bool run_test(void) { Lardon3DProjectDb *database = NULL; CHECK(lardon3d_project_db_open(database_path, &database, error) == LARDON3D_PROJECT_DB_OK); - CHECK(database && lardon3d_project_db_schema_version(database) == 22); + CHECK(database && lardon3d_project_db_schema_version(database) == + LARDON3D_PROJECT_DB_SCHEMA_VERSION); bool legacy_pending = true; CHECK(lardon3d_project_db_legacy_catalog_pending(database, &legacy_pending) == LARDON3D_PROJECT_DB_OK && @@ -1501,7 +1521,7 @@ static bool run_test(void) { database = NULL; CHECK(query_integer(database_path, "SELECT value FROM metadata WHERE key='schema_version'", - 22)); + LARDON3D_PROJECT_DB_SCHEMA_VERSION)); CHECK(query_integer(database_path, "SELECT count(*) FROM tasks WHERE task_id=1", 1)); @@ -1523,6 +1543,7 @@ static bool run_test(void) { "created_at) VALUES(1,1,1,1,1,zeroblob(32),1,2,1,0,0,1);" "DROP TABLE incremental_reconstruction_tasks;" "DROP TABLE incremental_reconstructions;" + DROP_OPTICAL_V23_SQL "DROP TABLE selected_execution_items;DROP TABLE selected_executions;" "DROP TABLE photo_quality_triage_results;DROP TABLE photo_quality_triage_tasks;" "DROP TABLE acquisition_campaign_captures;DROP TABLE acquisition_campaign_tasks;" @@ -1531,7 +1552,8 @@ static bool run_test(void) { "UPDATE metadata SET value=17 WHERE key='schema_version';")); CHECK(lardon3d_project_db_open(v17_path, &database, error) == LARDON3D_PROJECT_DB_OK); - CHECK(lardon3d_project_db_schema_version(database) == 22); + CHECK(lardon3d_project_db_schema_version(database) == + LARDON3D_PROJECT_DB_SCHEMA_VERSION); lardon3d_project_db_close(database); database = NULL; CHECK(query_integer(v17_path, @@ -1555,6 +1577,7 @@ static bool run_test(void) { database = NULL; CHECK(execute_test_sql( failed_v19_path, + DROP_OPTICAL_V23_SQL "DROP TABLE selected_execution_items;DROP TABLE selected_executions;" "DROP TABLE photo_quality_triage_results;DROP TABLE photo_quality_triage_tasks;" "DROP TABLE acquisition_campaign_captures;DROP TABLE acquisition_campaign_tasks;" @@ -1572,11 +1595,13 @@ static bool run_test(void) { "name='captures'", 0)); CHECK(lardon3d_project_db_open(failed_v19_path, &database, error) == LARDON3D_PROJECT_DB_OK); - CHECK(lardon3d_project_db_schema_version(database) == 22); + CHECK(lardon3d_project_db_schema_version(database) == + LARDON3D_PROJECT_DB_SCHEMA_VERSION); lardon3d_project_db_close(database); database = NULL; CHECK(execute_test_sql( failed_v18_path, + DROP_OPTICAL_V23_SQL "DROP TABLE incremental_reconstruction_tasks;" "DROP TABLE incremental_reconstructions;" "DROP TABLE selected_execution_items;DROP TABLE selected_executions;" @@ -1626,7 +1651,8 @@ static bool run_test(void) { CHECK(create_v1_database(legacy_path)); CHECK(lardon3d_project_db_open(legacy_path, &database, error) == LARDON3D_PROJECT_DB_OK); - CHECK(lardon3d_project_db_schema_version(database) == 22); + CHECK(lardon3d_project_db_schema_version(database) == + LARDON3D_PROJECT_DB_SCHEMA_VERSION); CHECK(lardon3d_project_db_get_project(database, &loaded_project) == LARDON3D_PROJECT_DB_OK && strcmp(loaded_project.stable_id, "legacy-project") == 0); @@ -1641,7 +1667,7 @@ static bool run_test(void) { database = NULL; CHECK(query_integer(legacy_path, "SELECT value FROM metadata WHERE key='schema_version'", - 22)); + LARDON3D_PROJECT_DB_SCHEMA_VERSION)); CHECK(create_v1_database(raw_missing_table_path)); CHECK(lardon3d_project_db_open(raw_missing_table_path, &database, error) == LARDON3D_PROJECT_DB_OK); @@ -1682,7 +1708,8 @@ static bool run_test(void) { lardon3d_project_db_close(database); database = NULL; CHECK(query_integer( - v2_path, "SELECT value FROM metadata WHERE key='schema_version'", 22)); + v2_path, "SELECT value FROM metadata WHERE key='schema_version'", + LARDON3D_PROJECT_DB_SCHEMA_VERSION)); CHECK(create_v2_database(failed_v3_migration_path)); CHECK(setenv("LARDON3D_TEST_PROJECT_DB_FAIL_MIGRATION_V3", "1", 1) == 0); @@ -1716,7 +1743,8 @@ static bool run_test(void) { lardon3d_project_db_close(database); database = NULL; CHECK(query_integer( - v3_path, "SELECT value FROM metadata WHERE key='schema_version'", 22)); + v3_path, "SELECT value FROM metadata WHERE key='schema_version'", + LARDON3D_PROJECT_DB_SCHEMA_VERSION)); CHECK(create_v3_database(failed_v4_path)); CHECK(setenv("LARDON3D_TEST_PROJECT_DB_FAIL_MIGRATION_V4", "1", 1) == 0); @@ -1738,7 +1766,8 @@ static bool run_test(void) { fprintf(stderr, "Migration v4 (%d): %s\n", (int)v4_result, error); } CHECK(v4_result == LARDON3D_PROJECT_DB_OK); - CHECK(lardon3d_project_db_schema_version(database) == 22); + CHECK(lardon3d_project_db_schema_version(database) == + LARDON3D_PROJECT_DB_SCHEMA_VERSION); CHECK(lardon3d_project_db_load_task(database, 9, &task) == LARDON3D_PROJECT_DB_OK); CHECK(lardon3d_project_db_load_artifact(database, "legacy-artifact", @@ -1793,7 +1822,8 @@ static bool run_test(void) { error); } CHECK(retry_v7 == LARDON3D_PROJECT_DB_OK && - lardon3d_project_db_schema_version(database) == 22); + lardon3d_project_db_schema_version(database) == + LARDON3D_PROJECT_DB_SCHEMA_VERSION); lardon3d_project_db_close(database); database = NULL; @@ -1803,7 +1833,8 @@ static bool run_test(void) { 5)); CHECK(lardon3d_project_db_open(direct_v5_path, &database, error) == LARDON3D_PROJECT_DB_OK && - lardon3d_project_db_schema_version(database) == 22); + lardon3d_project_db_schema_version(database) == + LARDON3D_PROJECT_DB_SCHEMA_VERSION); lardon3d_project_db_close(database); database = NULL; @@ -1812,11 +1843,13 @@ static bool run_test(void) { v8_path, "SELECT value FROM metadata WHERE key='schema_version'", 7)); CHECK(lardon3d_project_db_open(v8_path, &database, error) == LARDON3D_PROJECT_DB_OK); - CHECK(lardon3d_project_db_schema_version(database) == 22); + CHECK(lardon3d_project_db_schema_version(database) == + LARDON3D_PROJECT_DB_SCHEMA_VERSION); lardon3d_project_db_close(database); database = NULL; CHECK(query_integer( - v8_path, "SELECT value FROM metadata WHERE key='schema_version'", 22)); + v8_path, "SELECT value FROM metadata WHERE key='schema_version'", + LARDON3D_PROJECT_DB_SCHEMA_VERSION)); CHECK(create_v7_database(failed_v8_path)); CHECK(setenv("LARDON3D_TEST_PROJECT_DB_FAIL_MIGRATION_V8", "1", 1) == 0); @@ -1833,7 +1866,8 @@ static bool run_test(void) { 0)); CHECK(lardon3d_project_db_open(failed_v8_path, &database, error) == LARDON3D_PROJECT_DB_OK && - lardon3d_project_db_schema_version(database) == 22); + lardon3d_project_db_schema_version(database) == + LARDON3D_PROJECT_DB_SCHEMA_VERSION); lardon3d_project_db_close(database); database = NULL; @@ -1852,11 +1886,13 @@ static bool run_test(void) { 0)); CHECK(lardon3d_project_db_open(v10_path, &database, error) == LARDON3D_PROJECT_DB_OK && - lardon3d_project_db_schema_version(database) == 22); + lardon3d_project_db_schema_version(database) == + LARDON3D_PROJECT_DB_SCHEMA_VERSION); lardon3d_project_db_close(database); database = NULL; CHECK(query_integer( - v10_path, "SELECT value FROM metadata WHERE key='schema_version'", 22)); + v10_path, "SELECT value FROM metadata WHERE key='schema_version'", + LARDON3D_PROJECT_DB_SCHEMA_VERSION)); CHECK( query_integer(v10_path, "SELECT count(*) FROM sqlite_master WHERE type='table' AND " @@ -1883,7 +1919,8 @@ static bool run_test(void) { 1)); CHECK(lardon3d_project_db_open(failed_v11_path, &database, error) == LARDON3D_PROJECT_DB_OK); - CHECK(lardon3d_project_db_schema_version(database) == 22); + CHECK(lardon3d_project_db_schema_version(database) == + LARDON3D_PROJECT_DB_SCHEMA_VERSION); lardon3d_project_db_close(database); database = NULL; @@ -1897,11 +1934,13 @@ static bool run_test(void) { 0)); CHECK(lardon3d_project_db_open(v13_path, &database, error) == LARDON3D_PROJECT_DB_OK && - lardon3d_project_db_schema_version(database) == 22); + lardon3d_project_db_schema_version(database) == + LARDON3D_PROJECT_DB_SCHEMA_VERSION); lardon3d_project_db_close(database); database = NULL; CHECK(query_integer( - v13_path, "SELECT value FROM metadata WHERE key='schema_version'", 22)); + v13_path, "SELECT value FROM metadata WHERE key='schema_version'", + LARDON3D_PROJECT_DB_SCHEMA_VERSION)); CHECK( query_integer(v13_path, "SELECT count(*) FROM sqlite_master WHERE type='table' AND " @@ -1924,7 +1963,8 @@ static bool run_test(void) { true_v14_path); } CHECK(true_v14_result == LARDON3D_PROJECT_DB_OK && - lardon3d_project_db_schema_version(database) == 22); + lardon3d_project_db_schema_version(database) == + LARDON3D_PROJECT_DB_SCHEMA_VERSION); lardon3d_project_db_close(database); database = NULL; CHECK( @@ -2039,7 +2079,8 @@ static bool run_test(void) { if (true_v15_result != LARDON3D_PROJECT_DB_OK) fprintf(stderr, "true v15 upgrade: %d %s\n", true_v15_result, error); CHECK(true_v15_result == LARDON3D_PROJECT_DB_OK); - CHECK(lardon3d_project_db_schema_version(database) == 22); + CHECK(lardon3d_project_db_schema_version(database) == + LARDON3D_PROJECT_DB_SCHEMA_VERSION); Lardon3DProjectDbProject migrated_project; CHECK(lardon3d_project_db_get_project(database, &migrated_project) == LARDON3D_PROJECT_DB_OK && @@ -2085,7 +2126,8 @@ static bool run_test(void) { database = NULL; CHECK(lardon3d_project_db_open(database_path, &database, error) == LARDON3D_PROJECT_DB_OK); - CHECK(lardon3d_project_db_schema_version(database) == 22); + CHECK(lardon3d_project_db_schema_version(database) == + LARDON3D_PROJECT_DB_SCHEMA_VERSION); lardon3d_project_db_close(database); database = NULL; CHECK(schema_compare(database_path, true_v15_path, true)); @@ -2120,7 +2162,8 @@ static bool run_test(void) { 0)); CHECK(lardon3d_project_db_open(true_v15_path, &database, error) == LARDON3D_PROJECT_DB_OK); - CHECK(lardon3d_project_db_schema_version(database) == 22); + CHECK(lardon3d_project_db_schema_version(database) == + LARDON3D_PROJECT_DB_SCHEMA_VERSION); lardon3d_project_db_close(database); database = NULL; @@ -2139,7 +2182,8 @@ static bool run_test(void) { 0)); CHECK(lardon3d_project_db_open(failed_v14_path, &database, error) == LARDON3D_PROJECT_DB_OK && - lardon3d_project_db_schema_version(database) == 22); + lardon3d_project_db_schema_version(database) == + LARDON3D_PROJECT_DB_SCHEMA_VERSION); lardon3d_project_db_close(database); database = NULL; @@ -2158,7 +2202,8 @@ static bool run_test(void) { 0)); CHECK(lardon3d_project_db_open(failed_v15_path, &database, error) == LARDON3D_PROJECT_DB_OK && - lardon3d_project_db_schema_version(database) == 22); + lardon3d_project_db_schema_version(database) == + LARDON3D_PROJECT_DB_SCHEMA_VERSION); lardon3d_project_db_close(database); database = NULL; diff --git a/tests/test_public_runtime_abi.c b/tests/test_public_runtime_abi.c new file mode 100644 index 0000000..d118e95 --- /dev/null +++ b/tests/test_public_runtime_abi.c @@ -0,0 +1,139 @@ +#include +#include + +#include +#include +#include +#include +#include + +/* ABI fixture captured from baseline 96d0791 on the supported x86_64 target. + * CONTRACT: additive observability uses separately named objects/functions; + * old callers must never receive a larger write or a changed symbol type. */ +_Static_assert(sizeof(Lardon3DTaskSnapshot) == 432, + "baseline TaskSnapshot size changed"); +_Static_assert(offsetof(Lardon3DTaskSnapshot, id) == 0, + "baseline TaskSnapshot.id offset changed"); +_Static_assert(offsetof(Lardon3DTaskSnapshot, name) == 8, + "baseline TaskSnapshot.name offset changed"); +_Static_assert(offsetof(Lardon3DTaskSnapshot, progress) == 136, + "baseline TaskSnapshot.progress offset changed"); +_Static_assert(offsetof(Lardon3DTaskSnapshot, state) == 140, + "baseline TaskSnapshot.state offset changed"); +_Static_assert(offsetof(Lardon3DTaskSnapshot, message) == 144, + "baseline TaskSnapshot.message offset changed"); +_Static_assert(offsetof(Lardon3DTaskSnapshot, started_at) == 400, + "baseline TaskSnapshot.started_at offset changed"); +_Static_assert(offsetof(Lardon3DTaskSnapshot, finished_at) == 416, + "baseline TaskSnapshot.finished_at offset changed"); + +_Static_assert(sizeof(Lardon3DResourceSnapshot) == 152, + "baseline ResourceSnapshot size changed"); +_Static_assert(offsetof(Lardon3DResourceSnapshot, captured_at) == 0, + "baseline ResourceSnapshot.captured_at offset changed"); +_Static_assert(offsetof(Lardon3DResourceSnapshot, + memory_available_bytes) == 16, + "baseline ResourceSnapshot.memory_available_bytes offset changed"); +_Static_assert(offsetof(Lardon3DResourceSnapshot, memory_free_bytes) == 24, + "baseline ResourceSnapshot.memory_free_bytes offset changed"); +_Static_assert(offsetof(Lardon3DResourceSnapshot, swap_available_bytes) == 32, + "baseline ResourceSnapshot.swap_available_bytes offset changed"); +_Static_assert(offsetof(Lardon3DResourceSnapshot, + gpu_memory_available_known) == 40, + "baseline ResourceSnapshot GPU-known offset changed"); +_Static_assert(offsetof(Lardon3DResourceSnapshot, + gpu_memory_available_bytes) == 48, + "baseline ResourceSnapshot GPU-bytes offset changed"); +_Static_assert(offsetof(Lardon3DResourceSnapshot, cpu_load_1m) == 56, + "baseline ResourceSnapshot cpu_load_1m offset changed"); +_Static_assert(offsetof(Lardon3DResourceSnapshot, cpu_load_5m) == 64, + "baseline ResourceSnapshot cpu_load_5m offset changed"); +_Static_assert(offsetof(Lardon3DResourceSnapshot, cpu_load_15m) == 72, + "baseline ResourceSnapshot cpu_load_15m offset changed"); +_Static_assert(offsetof(Lardon3DResourceSnapshot, cpu_pressure_known) == 80, + "baseline ResourceSnapshot CPU PSI-known offset changed"); +_Static_assert(offsetof(Lardon3DResourceSnapshot, cpu_pressure_avg10) == 88, + "baseline ResourceSnapshot CPU PSI offset changed"); +_Static_assert(offsetof(Lardon3DResourceSnapshot, + memory_pressure_known) == 96, + "baseline ResourceSnapshot memory PSI-known offset changed"); +_Static_assert(offsetof(Lardon3DResourceSnapshot, + memory_pressure_avg10) == 104, + "baseline ResourceSnapshot memory PSI offset changed"); +_Static_assert(offsetof(Lardon3DResourceSnapshot, io_pressure_known) == 112, + "baseline ResourceSnapshot I/O PSI-known offset changed"); +_Static_assert(offsetof(Lardon3DResourceSnapshot, io_pressure_avg10) == 120, + "baseline ResourceSnapshot I/O PSI offset changed"); +_Static_assert(offsetof(Lardon3DResourceSnapshot, swap_activity_known) == 128, + "baseline ResourceSnapshot swap-known offset changed"); +_Static_assert(offsetof(Lardon3DResourceSnapshot, swap_pages_in) == 136, + "baseline ResourceSnapshot swap-in offset changed"); +_Static_assert(offsetof(Lardon3DResourceSnapshot, swap_pages_out) == 144, + "baseline ResourceSnapshot swap-out offset changed"); + +_Static_assert(sizeof(Lardon3DAppState) == 4952, + "baseline AppState size changed"); +_Static_assert(offsetof(Lardon3DAppState, screen) == 0, + "baseline AppState.screen offset changed"); +_Static_assert(offsetof(Lardon3DAppState, running) == 4, + "baseline AppState.running offset changed"); +_Static_assert(offsetof(Lardon3DAppState, project_loaded) == 5, + "baseline AppState.project_loaded offset changed"); +_Static_assert(offsetof(Lardon3DAppState, project_name) == 6, + "baseline AppState.project_name offset changed"); +_Static_assert(offsetof(Lardon3DAppState, project_path) == 134, + "baseline AppState.project_path offset changed"); +_Static_assert(offsetof(Lardon3DAppState, project_stable_id) == 4230, + "baseline AppState.project_stable_id offset changed"); +_Static_assert(offsetof(Lardon3DAppState, status_message) == 4295, + "baseline AppState.status_message offset changed"); +_Static_assert(offsetof(Lardon3DAppState, image_catalog) == 4552, + "baseline AppState.image_catalog offset changed"); +_Static_assert(offsetof(Lardon3DAppState, image_view) == 4560, + "baseline AppState.image_view offset changed"); +_Static_assert(offsetof(Lardon3DAppState, task_queue) == 4568, + "baseline AppState.task_queue offset changed"); +_Static_assert(offsetof(Lardon3DAppState, hardware_profile) == 4576, + "baseline AppState.hardware_profile offset changed"); +_Static_assert(offsetof(Lardon3DAppState, resource_governor) == 4880, + "baseline AppState.resource_governor offset changed"); +_Static_assert(offsetof(Lardon3DAppState, project_db) == 4888, + "baseline AppState.project_db offset changed"); +_Static_assert(offsetof(Lardon3DAppState, orb_vulkan_backend) == 4896, + "baseline AppState.orb_vulkan_backend offset changed"); +_Static_assert(offsetof(Lardon3DAppState, recovery_inspected) == 4904, + "baseline AppState recovery offset changed"); +_Static_assert(offsetof(Lardon3DAppState, + recovery_published_not_durable) == 4936, + "baseline AppState final count offset changed"); +_Static_assert(offsetof(Lardon3DAppState, recovery_queue_full) == 4944, + "baseline AppState final flag offset changed"); +_Static_assert(LARDON3D_SCREEN_RESOURCES == 6, + "baseline screen enum values changed"); + +typedef void (*LegacyLayoutFunction)( + const Lardon3DAppState *, + const char *, + const char *, + const Lardon3DImportTaskSnapshot *, + const Lardon3DTaskSnapshot *, + size_t, + const Lardon3DTaskQueueSummary *, + const Lardon3DResourceAvailability *, + int, + int +); +typedef bool (*LegacyTuiRunFunction)(Lardon3DAppState *); + +_Static_assert(_Generic(&lardon3d_layout_draw, + LegacyLayoutFunction: 1, default: 0), + "legacy layout symbol type changed"); +_Static_assert(_Generic(&lardon3d_tui_run, + LegacyTuiRunFunction: 1, default: 0), + "legacy TUI run symbol type changed"); + +int +main(void) +{ + return 0; +} diff --git a/tests/test_resource_external_storage.c b/tests/test_resource_external_storage.c new file mode 100644 index 0000000..b81f1e1 --- /dev/null +++ b/tests/test_resource_external_storage.c @@ -0,0 +1,1067 @@ +#ifndef _GNU_SOURCE +#define _GNU_SOURCE +#endif + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#include "../src/resource_governor_internal.h" +#include "../src/ssd_controller_internal.h" +#include "../src/task_queue_internal.h" + +#define GIB (UINT64_C(1024) * UINT64_C(1024) * UINT64_C(1024)) +#define CHECK(condition) \ + do { \ + if (!(condition)) { \ + (void)fprintf(stderr, "external storage failure line %d: %s\n", \ + __LINE__, #condition); \ + return false; \ + } \ + } while (0) + +typedef struct { + pthread_mutex_t mutex; + uint64_t now_ns; + Lardon3DSsdProviderSnapshot observation; + bool destroyed; +} FakeStorage; + +typedef struct { + pthread_mutex_t mutex; + pthread_cond_t condition; + Lardon3DTaskQueue *queue; + bool closing; +} QueueClosingProbe; + +static _Atomic(QueueClosingProbe *) active_closing_probe; + +typedef struct { + pthread_mutex_t mutex; + pthread_cond_t condition; + Lardon3DResourceGovernor *governor; + Lardon3DSsdController *controller; + bool engaged; + bool release; +} ExternalUpdateBarrier; + +static _Atomic(ExternalUpdateBarrier *) active_update_barrier; + +/* Strong test definition for the private test-only seam. The callback runs + * after update() owns the Governor mutex, so the physical drain below cannot + * merely win a scheduler race before the updater is actually engaged. */ +void +lardon3d_resource_governor_external_update_engaged_for_test( + Lardon3DResourceGovernor *governor, + Lardon3DSsdController *controller +) +{ + ExternalUpdateBarrier *barrier = atomic_load_explicit( + &active_update_barrier, memory_order_acquire); + if (!barrier || barrier->governor != governor + || barrier->controller != controller) { + return; + } + (void)pthread_mutex_lock(&barrier->mutex); + barrier->engaged = true; + (void)pthread_cond_broadcast(&barrier->condition); + while (!barrier->release) { + (void)pthread_cond_wait(&barrier->condition, &barrier->mutex); + } + (void)pthread_mutex_unlock(&barrier->mutex); +} + +/* Strong definition for the private Queue seam compiled into this test + * binary. It proves teardown crossed the exact closing linearization point + * before the callback may release its scratch capability. */ +void +lardon3d_task_queue_internal_test_event( + Lardon3DTaskQueue *queue, + Lardon3DTaskQueueTestEvent event +) +{ + QueueClosingProbe *probe = atomic_load_explicit( + &active_closing_probe, memory_order_acquire); + if (!probe || probe->queue != queue + || event != LARDON3D_TASK_QUEUE_TEST_CLOSING) { + return; + } + (void)pthread_mutex_lock(&probe->mutex); + probe->closing = true; + (void)pthread_cond_broadcast(&probe->condition); + (void)pthread_mutex_unlock(&probe->mutex); +} + +static void +copy_text(char *destination, size_t capacity, const char *source) +{ + (void)snprintf(destination, capacity, "%s", source ? source : ""); +} + +static bool +fake_now(void *context, uint64_t *now_ns) +{ + FakeStorage *fake = context; + (void)pthread_mutex_lock(&fake->mutex); + *now_ns = fake->now_ns; + (void)pthread_mutex_unlock(&fake->mutex); + return true; +} + +static bool +fake_refresh( + void *context, + Lardon3DSsdProviderSnapshot *snapshot, + char reason[LARDON3D_SSD_REASON_CAPACITY] +) +{ + FakeStorage *fake = context; + (void)pthread_mutex_lock(&fake->mutex); + *snapshot = fake->observation; + (void)pthread_mutex_unlock(&fake->mutex); + reason[0] = '\0'; + return true; +} + +static bool +fake_start_swap( + void *context, + const char *object_path, + char reason[LARDON3D_SSD_REASON_CAPACITY] +) +{ + FakeStorage *fake = context; + (void)pthread_mutex_lock(&fake->mutex); + bool valid = strcmp(object_path, + fake->observation.swap.object_path) == 0; + if (valid) { + fake->observation.swap.active = true; + } else { + copy_text(reason, LARDON3D_SSD_REASON_CAPACITY, + "wrong fake swap object"); + } + (void)pthread_mutex_unlock(&fake->mutex); + return valid; +} + +static bool +fake_stop_swap( + void *context, + const char *object_path, + char reason[LARDON3D_SSD_REASON_CAPACITY] +) +{ + FakeStorage *fake = context; + (void)pthread_mutex_lock(&fake->mutex); + bool valid = strcmp(object_path, + fake->observation.swap.object_path) == 0; + if (valid) { + fake->observation.swap.active = false; + fake->observation.swap.used_bytes = 0; + } else { + copy_text(reason, LARDON3D_SSD_REASON_CAPACITY, + "wrong fake swap object"); + } + (void)pthread_mutex_unlock(&fake->mutex); + return valid; +} + +static bool +fake_mount( + void *context, + const char *object_path, + char mount_path[LARDON3D_SSD_PATH_CAPACITY], + char reason[LARDON3D_SSD_REASON_CAPACITY] +) +{ + FakeStorage *fake = context; + (void)pthread_mutex_lock(&fake->mutex); + bool valid = strcmp(object_path, + fake->observation.scratch.object_path) == 0; + if (valid) { + fake->observation.scratch.mounted = true; + fake->observation.scratch.free_known = true; + fake->observation.scratch.free_bytes = 300 * GIB; + copy_text(fake->observation.scratch.mount_path, + sizeof(fake->observation.scratch.mount_path), + LARDON3D_SSD_SCRATCH_MOUNT_PATH); + copy_text(mount_path, LARDON3D_SSD_PATH_CAPACITY, + LARDON3D_SSD_SCRATCH_MOUNT_PATH); + } else { + copy_text(reason, LARDON3D_SSD_REASON_CAPACITY, + "wrong fake scratch object"); + } + (void)pthread_mutex_unlock(&fake->mutex); + return valid; +} + +static bool +fake_unmount( + void *context, + const char *object_path, + char reason[LARDON3D_SSD_REASON_CAPACITY] +) +{ + FakeStorage *fake = context; + (void)pthread_mutex_lock(&fake->mutex); + bool valid = strcmp(object_path, + fake->observation.scratch.object_path) == 0; + if (valid) { + fake->observation.scratch.mounted = false; + fake->observation.scratch.free_known = false; + fake->observation.scratch.free_bytes = 0; + fake->observation.scratch.mount_path[0] = '\0'; + } else { + copy_text(reason, LARDON3D_SSD_REASON_CAPACITY, + "wrong fake scratch object"); + } + (void)pthread_mutex_unlock(&fake->mutex); + return valid; +} + +static void +fake_destroy(void *context) +{ + FakeStorage *fake = context; + (void)pthread_mutex_lock(&fake->mutex); + fake->destroyed = true; + (void)pthread_mutex_unlock(&fake->mutex); +} + +static const Lardon3DSsdProviderOps fake_ops = { + .monotonic_now_ns = fake_now, + .refresh = fake_refresh, + .start_swap = fake_start_swap, + .stop_swap = fake_stop_swap, + .mount_scratch = fake_mount, + .unmount_scratch = fake_unmount, + .destroy = fake_destroy, +}; + +static bool +fake_storage_init(FakeStorage *fake) +{ + memset(fake, 0, sizeof(*fake)); + if (pthread_mutex_init(&fake->mutex, NULL) != 0) { + return false; + } + fake->now_ns = UINT64_C(10000000000); + Lardon3DSsdProviderSnapshot *source = &fake->observation; + source->model_known = true; + copy_text(source->model, sizeof(source->model), "Fake external SSD"); + source->swap.present = true; + source->swap.unit_ready = true; + source->swap.interface_available = true; + source->swap.active_known = true; + source->swap.active = true; + source->swap.size_bytes = 8 * GIB; + source->swap.total_known = true; + source->swap.used_known = true; + source->swap.total_bytes = 8 * GIB; + copy_text(source->swap.label, sizeof(source->swap.label), + LARDON3D_SSD_SWAP_LABEL); + copy_text(source->swap.uuid, sizeof(source->swap.uuid), "fake-swap-uuid"); + copy_text(source->swap.drive_identity, + sizeof(source->swap.drive_identity), "fake-drive-identity"); + copy_text(source->swap.object_path, sizeof(source->swap.object_path), + "/org/fake/swap"); + copy_text(source->swap.device, sizeof(source->swap.device), + "/dev/fake-swap"); + + source->scratch.present = true; + source->scratch.unit_ready = true; + source->scratch.interface_available = true; + source->scratch.mounted = true; + source->scratch.size_bytes = 400 * GIB; + source->scratch.total_known = true; + source->scratch.free_known = true; + source->scratch.total_bytes = 400 * GIB; + source->scratch.free_bytes = 350 * GIB; + copy_text(source->scratch.label, sizeof(source->scratch.label), + LARDON3D_SSD_SCRATCH_LABEL); + copy_text(source->scratch.uuid, sizeof(source->scratch.uuid), + "fake-scratch-uuid"); + copy_text(source->scratch.drive_identity, + sizeof(source->scratch.drive_identity), "fake-drive-identity"); + copy_text(source->scratch.object_path, + sizeof(source->scratch.object_path), "/org/fake/scratch"); + copy_text(source->scratch.device, sizeof(source->scratch.device), + "/dev/fake-scratch"); + copy_text(source->scratch.mount_path, + sizeof(source->scratch.mount_path), + LARDON3D_SSD_SCRATCH_MOUNT_PATH); + + source->memory_available_known = true; + source->memory_available_bytes = 16 * GIB; + source->memory_pressure_known = true; + source->io_pressure_known = true; + source->swap_activity_known = true; + return true; +} + +static Lardon3DSsdController * +create_controller(FakeStorage *fake) +{ + return lardon3d_ssd_controller_create_with_provider( + (Lardon3DSsdProvider) { + .ops = &fake_ops, + .context = fake, + }, + UINT64_C(1000000000)); +} + +static Lardon3DResourceGovernor * +create_governor(void) +{ + Lardon3DHardwareProfile profile = { + .logical_cpu_count = 8, + .page_size_bytes = 4096, + .memory_total_bytes = 16 * GIB, + .cpu_architecture = "synthetic", + }; + Lardon3DResourcePolicy policy; + if (!lardon3d_resource_policy_default(&profile, &policy)) { + return NULL; + } + return lardon3d_resource_governor_create(&profile, &policy); +} + +static bool +register_controller( + Lardon3DResourceGovernor *governor, + Lardon3DSsdController *controller +) +{ + Lardon3DSsdSnapshot snapshot; + Lardon3DResourceExternalStorage storage; + return lardon3d_ssd_controller_copy_snapshot(controller, &snapshot) + && lardon3d_resource_external_storage_from_ssd_snapshot( + &snapshot, &storage) + && lardon3d_resource_governor_register_external_storage( + governor, controller, &storage); +} + +typedef struct { + Lardon3DResourceGovernor *governor; + uint64_t generation; + atomic_bool started; + atomic_bool changed; +} WaitContext; + +static void * +wait_for_generation(void *userdata) +{ + WaitContext *context = userdata; + atomic_store(&context->started, true); + atomic_store(&context->changed, + lardon3d_resource_governor_wait_for_change( + context->governor, context->generation, + UINT64_C(2000000000))); + return NULL; +} + +typedef struct { + Lardon3DResourceGovernor *governor; + Lardon3DSsdController *controller; + Lardon3DResourceExternalStorage storage; + bool updated; +} UpdateCall; + +static void * +update_registry_once(void *userdata) +{ + UpdateCall *call = userdata; + call->updated = lardon3d_resource_governor_update_external_storage( + call->governor, call->controller, &call->storage); + return NULL; +} + +static bool +test_registry_and_wrappers(void) +{ + FakeStorage fake; + CHECK(fake_storage_init(&fake)); + Lardon3DSsdController *controller = create_controller(&fake); + Lardon3DResourceGovernor *governor = create_governor(); + CHECK(controller && governor); + + Lardon3DSsdSnapshot physical; + CHECK(lardon3d_ssd_controller_copy_snapshot(controller, &physical)); + CHECK(physical.state == LARDON3D_SSD_ENABLED); + CHECK(physical.scratch_allocations_allowed); + + Lardon3DResourceSnapshot host = {.memory_available_bytes = 8 * GIB}; + Lardon3DResourceAvailability before; + Lardon3DResourceAvailability after; + CHECK(lardon3d_resource_governor_availability( + governor, &host, &before)); + CHECK(register_controller(governor, controller)); + CHECK(!lardon3d_resource_governor_register_external_storage( + governor, controller, &(Lardon3DResourceExternalStorage) {0})); + + Lardon3DResourceExternalStorage external; + CHECK(lardon3d_resource_governor_get_external_storage( + governor, &external)); + CHECK(external.status == LARDON3D_RESOURCE_EXTERNAL_STORAGE_AVAILABLE); + CHECK(external.new_scratch_allocations_allowed); + CHECK(external.active_scratch_leases == 0); + CHECK(strcmp(external.stable_identity, "fake-drive-identity") == 0); + + /* Hundreds of GiB of registered scratch plus external swap is not host + * RAM and cannot change any admission counter or budget. */ + CHECK(lardon3d_resource_governor_availability( + governor, &host, &after)); + CHECK(before.memory_budget_bytes == after.memory_budget_bytes); + CHECK(before.memory_reserved_bytes == after.memory_reserved_bytes); + CHECK(before.memory_available_bytes == after.memory_available_bytes); + CHECK(before.cpu_budget == after.cpu_budget); + CHECK(before.cpu_available == after.cpu_available); + CHECK(before.active_reservations == after.active_reservations); + + Lardon3DSsdScratchLease lease = {0}; + CHECK(lardon3d_resource_governor_acquire_scratch( + governor, controller, &lease)); + CHECK(lardon3d_resource_governor_get_external_storage( + governor, &external)); + CHECK(external.status == LARDON3D_RESOURCE_EXTERNAL_STORAGE_IN_USE); + CHECK(external.active_scratch_leases == 1); + CHECK(!lardon3d_resource_governor_acquire_scratch( + governor, controller, &lease)); + Lardon3DSsdScratchLease copied_lease = lease; + CHECK(!lardon3d_resource_governor_release_scratch( + governor, controller, &copied_lease)); + CHECK(!lardon3d_resource_governor_unregister_external_storage( + governor, controller)); + CHECK(!lardon3d_ssd_controller_destroy(controller)); + + CHECK(lardon3d_ssd_controller_copy_snapshot(controller, &physical)); + UpdateCall update = { + .governor = governor, + .controller = controller, + }; + CHECK(lardon3d_resource_external_storage_from_ssd_snapshot( + &physical, &update.storage)); + ExternalUpdateBarrier update_barrier = { + .governor = governor, + .controller = controller, + }; + CHECK(pthread_mutex_init(&update_barrier.mutex, NULL) == 0); + CHECK(pthread_cond_init(&update_barrier.condition, NULL) == 0); + atomic_store_explicit( + &active_update_barrier, &update_barrier, memory_order_release); + pthread_t update_thread; + CHECK(pthread_create(&update_thread, NULL, + update_registry_once, &update) == 0); + (void)pthread_mutex_lock(&update_barrier.mutex); + while (!update_barrier.engaged) { + (void)pthread_cond_wait( + &update_barrier.condition, &update_barrier.mutex); + } + (void)pthread_mutex_unlock(&update_barrier.mutex); + + CHECK(lardon3d_ssd_controller_disable(controller) + == LARDON3D_SSD_CONTROL_PENDING); + (void)pthread_mutex_lock(&update_barrier.mutex); + update_barrier.release = true; + (void)pthread_cond_broadcast(&update_barrier.condition); + (void)pthread_mutex_unlock(&update_barrier.mutex); + CHECK(pthread_join(update_thread, NULL) == 0); + atomic_store_explicit( + &active_update_barrier, NULL, memory_order_release); + CHECK(update.updated); + CHECK(pthread_cond_destroy(&update_barrier.condition) == 0); + CHECK(pthread_mutex_destroy(&update_barrier.mutex) == 0); + + CHECK(lardon3d_ssd_controller_copy_snapshot(controller, &physical)); + CHECK(lardon3d_resource_external_storage_from_ssd_snapshot( + &physical, &external)); + CHECK(lardon3d_resource_governor_update_external_storage( + governor, controller, &external)); + CHECK(external.status == LARDON3D_RESOURCE_EXTERNAL_STORAGE_DRAINING); + CHECK(!external.new_scratch_allocations_allowed); + Lardon3DSsdScratchLease rejected = {0}; + CHECK(!lardon3d_resource_governor_acquire_scratch( + governor, controller, &rejected)); + + CHECK(lardon3d_resource_governor_release_scratch( + governor, controller, &lease)); + CHECK(lardon3d_resource_governor_get_external_storage( + governor, &external)); + CHECK(external.status == LARDON3D_RESOURCE_EXTERNAL_STORAGE_SAFE); + CHECK(external.active_scratch_leases == 0); + CHECK(lease.opaque_controller == 0 && lease.opaque_lease_id == 0); + + uint64_t aggregate_generation = + lardon3d_resource_governor_generation(governor); + ++external.generation; + CHECK(lardon3d_resource_governor_update_external_storage( + governor, controller, &external)); + CHECK(lardon3d_resource_governor_generation(governor) + == aggregate_generation); + + WaitContext waiter = { + .governor = governor, + .generation = aggregate_generation, + }; + pthread_t wait_thread; + CHECK(pthread_create(&wait_thread, NULL, + wait_for_generation, &waiter) == 0); + while (!atomic_load(&waiter.started)) { + sched_yield(); + } + ++external.generation; + copy_text(external.reason, sizeof(external.reason), + "material external storage change"); + CHECK(lardon3d_resource_governor_update_external_storage( + governor, controller, &external)); + CHECK(pthread_join(wait_thread, NULL) == 0); + CHECK(atomic_load(&waiter.changed)); + + Lardon3DResourceExternalStorage stale = external; + ++external.generation; + external.status = LARDON3D_RESOURCE_EXTERNAL_STORAGE_ABSENT; + external.new_scratch_allocations_allowed = false; + external.active_scratch_leases = 0; + CHECK(lardon3d_resource_governor_update_external_storage( + governor, controller, &external)); + CHECK(!lardon3d_resource_governor_acquire_scratch( + governor, controller, &rejected)); + stale.status = LARDON3D_RESOURCE_EXTERNAL_STORAGE_AVAILABLE; + stale.new_scratch_allocations_allowed = true; + copy_text(stale.reason, sizeof(stale.reason), + "stale availability must not restore allocation"); + CHECK(!lardon3d_resource_governor_update_external_storage( + governor, controller, &stale)); + Lardon3DResourceExternalStorage retained; + CHECK(lardon3d_resource_governor_get_external_storage( + governor, &retained)); + CHECK(retained.status == LARDON3D_RESOURCE_EXTERNAL_STORAGE_ABSENT); + ++external.generation; + external.status = LARDON3D_RESOURCE_EXTERNAL_STORAGE_ERROR; + copy_text(external.stable_identity, sizeof(external.stable_identity), + "replacement-drive"); + copy_text(external.reason, sizeof(external.reason), + "replacement or malformed state blocks allocation"); + CHECK(lardon3d_resource_governor_update_external_storage( + governor, controller, &external)); + CHECK(!lardon3d_resource_governor_acquire_scratch( + governor, controller, &rejected)); + + CHECK(lardon3d_resource_governor_unregister_external_storage( + governor, controller)); + external = (Lardon3DResourceExternalStorage) { + .generation = UINT64_MAX, + .status = LARDON3D_RESOURCE_EXTERNAL_STORAGE_ERROR, + .active_scratch_leases = LARDON3D_SSD_MAX_SCRATCH_LEASES, + }; + CHECK(!lardon3d_resource_governor_get_external_storage( + governor, &external)); + CHECK(external.generation == 0 && external.active_scratch_leases == 0); + CHECK(!lardon3d_resource_governor_update_external_storage( + governor, controller, &external)); + + CHECK(lardon3d_ssd_controller_destroy(controller)); + (void)pthread_mutex_lock(&fake.mutex); + bool destroyed = fake.destroyed; + (void)pthread_mutex_unlock(&fake.mutex); + CHECK(destroyed); + lardon3d_resource_governor_destroy(governor); + CHECK(pthread_mutex_destroy(&fake.mutex) == 0); + return true; +} + +static bool +complete_enabled_snapshot(Lardon3DSsdSnapshot *snapshot) +{ + if (!snapshot) { + return false; + } + *snapshot = (Lardon3DSsdSnapshot) { + .state = LARDON3D_SSD_ENABLED, + .generation = 19, + .device_detected = true, + .pairing_valid = true, + .swap_detected = true, + .scratch_detected = true, + .swap_partition_size_known = true, + .scratch_partition_size_known = true, + .swap_partition_size_bytes = 8 * GIB, + .scratch_partition_size_bytes = 400 * GIB, + .swap_active = true, + .scratch_mounted = true, + .scratch_lease_capacity = LARDON3D_SSD_MAX_SCRATCH_LEASES, + .scratch_allocations_allowed = true, + .can_disable = true, + }; + copy_text(snapshot->drive_identity, sizeof(snapshot->drive_identity), + "synthetic-drive"); + copy_text(snapshot->swap_uuid, sizeof(snapshot->swap_uuid), + "synthetic-swap"); + copy_text(snapshot->scratch_uuid, sizeof(snapshot->scratch_uuid), + "synthetic-scratch"); + copy_text(snapshot->swap_device, sizeof(snapshot->swap_device), + "/dev/synthetic-swap"); + copy_text(snapshot->scratch_device, sizeof(snapshot->scratch_device), + "/dev/synthetic-scratch"); + copy_text(snapshot->scratch_mount_path, + sizeof(snapshot->scratch_mount_path), + LARDON3D_SSD_SCRATCH_MOUNT_PATH); + copy_text(snapshot->reason, sizeof(snapshot->reason), + "complete synthetic pair"); + return true; +} + +static bool +test_malformed_conversion(void) +{ + Lardon3DSsdSnapshot malformed = { + .state = LARDON3D_SSD_ENABLED, + .pairing_valid = true, + .scratch_lease_capacity = LARDON3D_SSD_MAX_SCRATCH_LEASES, + .scratch_allocations_allowed = true, + }; + memset(malformed.reason, 'x', sizeof(malformed.reason)); + Lardon3DResourceExternalStorage storage = { + .generation = UINT64_MAX, + .status = LARDON3D_RESOURCE_EXTERNAL_STORAGE_AVAILABLE, + .active_scratch_leases = 9, + }; + CHECK(!lardon3d_resource_external_storage_from_ssd_snapshot( + &malformed, &storage)); + CHECK(storage.generation == 0 && storage.active_scratch_leases == 0); + + malformed = (Lardon3DSsdSnapshot) { + .state = LARDON3D_SSD_ABSENT, + .scratch_lease_capacity = LARDON3D_SSD_MAX_SCRATCH_LEASES, + .swap_total_bytes = 1, + }; + CHECK(!lardon3d_resource_external_storage_from_ssd_snapshot( + &malformed, &storage)); + CHECK(storage.generation == 0 && storage.swap_total_bytes == 0); + + malformed = (Lardon3DSsdSnapshot) { + .state = LARDON3D_SSD_ENABLED, + .pairing_valid = true, + .swap_active = true, + .scratch_mounted = true, + .scratch_allocations_allowed = true, + .can_disable = true, + .scratch_lease_capacity = LARDON3D_SSD_MAX_SCRATCH_LEASES, + }; + (void)snprintf(malformed.scratch_mount_path, + sizeof(malformed.scratch_mount_path), "%s", + LARDON3D_SSD_SCRATCH_MOUNT_PATH); + CHECK(!lardon3d_resource_external_storage_from_ssd_snapshot( + &malformed, &storage)); + CHECK(storage.generation == 0); + + Lardon3DSsdSnapshot complete; + CHECK(complete_enabled_snapshot(&complete)); + CHECK(lardon3d_resource_external_storage_from_ssd_snapshot( + &complete, &storage)); + CHECK(storage.status == LARDON3D_RESOURCE_EXTERNAL_STORAGE_AVAILABLE); + CHECK(storage.new_scratch_allocations_allowed); + + /* Exact tuple strings cannot substitute for present partitions and their + * positive UDisks extents. */ + malformed = complete; + malformed.device_detected = false; + malformed.swap_detected = false; + malformed.scratch_detected = false; + malformed.swap_partition_size_known = false; + malformed.scratch_partition_size_known = false; + malformed.swap_partition_size_bytes = 0; + malformed.scratch_partition_size_bytes = 0; + CHECK(!lardon3d_resource_external_storage_from_ssd_snapshot( + &malformed, &storage)); + CHECK(storage.generation == 0 && storage.active_scratch_leases == 0); + + malformed = complete; + malformed.swap_partition_size_known = false; + malformed.scratch_partition_size_known = false; + malformed.swap_partition_size_bytes = 0; + malformed.scratch_partition_size_bytes = 0; + CHECK(!lardon3d_resource_external_storage_from_ssd_snapshot( + &malformed, &storage)); + CHECK(storage.generation == 0); + + malformed = complete; + malformed.swap_partition_size_bytes = 0; + CHECK(!lardon3d_resource_external_storage_from_ssd_snapshot( + &malformed, &storage)); + CHECK(storage.generation == 0); + + malformed = (Lardon3DSsdSnapshot) { + .state = LARDON3D_SSD_ABSENT, + .scratch_lease_capacity = LARDON3D_SSD_MAX_SCRATCH_LEASES, + .swap_active = true, + .scratch_mounted = true, + }; + copy_text(malformed.scratch_mount_path, + sizeof(malformed.scratch_mount_path), + LARDON3D_SSD_SCRATCH_MOUNT_PATH); + CHECK(!lardon3d_resource_external_storage_from_ssd_snapshot( + &malformed, &storage)); + CHECK(storage.generation == 0); + + malformed = (Lardon3DSsdSnapshot) { + .state = LARDON3D_SSD_ABSENT, + .device_detected = true, + .swap_detected = true, + .swap_partition_size_known = true, + .swap_partition_size_bytes = 8 * GIB, + .scratch_lease_capacity = LARDON3D_SSD_MAX_SCRATCH_LEASES, + }; + CHECK(!lardon3d_resource_external_storage_from_ssd_snapshot( + &malformed, &storage)); + CHECK(storage.generation == 0); + + /* A disconnected sticky hazard is ERROR, never ABSENT. It may retain its + * exact tuple and lease count, but grants no allocation/control authority. */ + malformed = (Lardon3DSsdSnapshot) { + .state = LARDON3D_SSD_ERROR, + .generation = 23, + .scratch_lease_capacity = LARDON3D_SSD_MAX_SCRATCH_LEASES, + .scratch_lease_count = 1, + }; + copy_text(malformed.drive_identity, sizeof(malformed.drive_identity), + "retained-hazard-drive"); + copy_text(malformed.swap_uuid, sizeof(malformed.swap_uuid), + "retained-hazard-swap"); + copy_text(malformed.scratch_uuid, sizeof(malformed.scratch_uuid), + "retained-hazard-scratch"); + copy_text(malformed.reason, sizeof(malformed.reason), + "original pair is disconnected"); + CHECK(lardon3d_resource_external_storage_from_ssd_snapshot( + &malformed, &storage)); + CHECK(storage.status == LARDON3D_RESOURCE_EXTERNAL_STORAGE_ERROR); + CHECK(storage.active_scratch_leases == 1); + CHECK(!storage.new_scratch_allocations_allowed); + + malformed = (Lardon3DSsdSnapshot) { + .state = LARDON3D_SSD_DETECTED, + .generation = 24, + .device_detected = true, + .swap_detected = true, + .swap_partition_size_known = true, + .swap_partition_size_bytes = 8 * GIB, + .scratch_lease_capacity = LARDON3D_SSD_MAX_SCRATCH_LEASES, + }; + copy_text(malformed.swap_uuid, sizeof(malformed.swap_uuid), + "partial-swap"); + copy_text(malformed.reason, sizeof(malformed.reason), + "scratch partition is not detected"); + CHECK(lardon3d_resource_external_storage_from_ssd_snapshot( + &malformed, &storage)); + CHECK(storage.status == LARDON3D_RESOURCE_EXTERNAL_STORAGE_DETECTED); + CHECK(!storage.new_scratch_allocations_allowed); + + /* Drain-only recovery is legitimate only with the complete reconnected + * pair evidence, even when the exact pair is already inactive. */ + malformed = complete; + malformed.state = LARDON3D_SSD_ERROR; + malformed.swap_active = false; + malformed.scratch_mounted = false; + malformed.scratch_allocations_allowed = false; + malformed.can_disable = true; + copy_text(malformed.scratch_mount_path, + sizeof(malformed.scratch_mount_path), "UNKNOWN"); + CHECK(lardon3d_resource_external_storage_from_ssd_snapshot( + &malformed, &storage)); + CHECK(storage.status == LARDON3D_RESOURCE_EXTERNAL_STORAGE_ERROR); + CHECK(strcmp(lardon3d_resource_external_storage_status_name( + LARDON3D_RESOURCE_EXTERNAL_STORAGE_DRAINING), "DRAINING") == 0); + return true; +} + +static bool +test_checked_async_teardown_with_live_lease(void) +{ + FakeStorage fake; + CHECK(fake_storage_init(&fake)); + Lardon3DSsdController *controller = create_controller(&fake); + Lardon3DResourceGovernor *governor = create_governor(); + CHECK(controller && governor); + Lardon3DTuiSsdAsync *operation = + lardon3d_tui_ssd_async_create_with_governor(controller, governor); + CHECK(operation); + CHECK(lardon3d_tui_ssd_async_refresh(operation)); + CHECK(lardon3d_tui_ssd_async_wait_idle( + operation, UINT64_C(1000000000))); + + Lardon3DSsdScratchLease lease = {0}; + CHECK(lardon3d_resource_governor_acquire_scratch( + governor, controller, &lease)); + Lardon3DTuiSsdAsync *retained = operation; + CHECK(!lardon3d_tui_ssd_async_destroy_checked(&retained)); + CHECK(retained == operation); + Lardon3DResourceExternalStorage external; + CHECK(lardon3d_resource_governor_get_external_storage( + governor, &external)); + CHECK(external.active_scratch_leases == 1); + + CHECK(lardon3d_resource_governor_release_scratch( + governor, controller, &lease)); + CHECK(lardon3d_tui_ssd_async_destroy_checked(&retained)); + CHECK(retained == NULL); + CHECK(!lardon3d_resource_governor_get_external_storage( + governor, &external)); + CHECK(lardon3d_ssd_controller_destroy(controller)); + lardon3d_resource_governor_destroy(governor); + CHECK(pthread_mutex_destroy(&fake.mutex) == 0); + return true; +} + +static bool +test_saturated_generation_wrapper_reconciliation(void) +{ + FakeStorage fake; + CHECK(fake_storage_init(&fake)); + Lardon3DSsdController *controller = create_controller(&fake); + Lardon3DResourceGovernor *governor = create_governor(); + CHECK(controller && governor); + CHECK(lardon3d_ssd_controller_set_generation_for_test( + controller, UINT64_MAX)); + + Lardon3DTuiSsdAsync *operation = + lardon3d_tui_ssd_async_create_with_governor(controller, governor); + CHECK(operation && lardon3d_tui_ssd_async_refresh(operation)); + CHECK(lardon3d_tui_ssd_async_wait_idle( + operation, UINT64_C(1000000000))); + + Lardon3DResourceExternalStorage external; + CHECK(lardon3d_resource_governor_get_external_storage( + governor, &external)); + CHECK(external.generation == UINT64_MAX); + CHECK(external.status == LARDON3D_RESOURCE_EXTERNAL_STORAGE_AVAILABLE); + CHECK(external.active_scratch_leases == 0); + + /* Public equal-watermark telemetry remains stale even at saturation. It + * cannot fabricate a lease or grant materially different authority. */ + Lardon3DResourceExternalStorage stale_public = external; + stale_public.status = LARDON3D_RESOURCE_EXTERNAL_STORAGE_IN_USE; + stale_public.active_scratch_leases = 1; + copy_text(stale_public.reason, sizeof(stale_public.reason), + "same-generation public authority must be rejected"); + CHECK(!lardon3d_resource_governor_update_external_storage( + governor, controller, &stale_public)); + CHECK(lardon3d_resource_governor_get_external_storage( + governor, &external)); + CHECK(external.status == LARDON3D_RESOURCE_EXTERNAL_STORAGE_AVAILABLE); + CHECK(external.active_scratch_leases == 0); + + Lardon3DSsdScratchLease lease = {0}; + CHECK(lardon3d_resource_governor_acquire_scratch( + governor, controller, &lease)); + CHECK(lardon3d_resource_governor_get_external_storage( + governor, &external)); + CHECK(external.generation == UINT64_MAX); + CHECK(external.status == LARDON3D_RESOURCE_EXTERNAL_STORAGE_IN_USE); + CHECK(external.active_scratch_leases == 1); + + CHECK(lardon3d_resource_governor_release_scratch( + governor, controller, &lease)); + CHECK(lease.opaque_controller == 0 && lease.opaque_lease_id == 0); + CHECK(lardon3d_resource_governor_get_external_storage( + governor, &external)); + CHECK(external.generation == UINT64_MAX); + CHECK(external.status == LARDON3D_RESOURCE_EXTERNAL_STORAGE_AVAILABLE); + CHECK(external.active_scratch_leases == 0); + + /* Even when the post-release cached copy is malformed, the successful + * physical release and exact address registry are authoritative for zero. + * Conservative ERROR must not resurrect the previous count of one. */ + CHECK(lardon3d_resource_governor_acquire_scratch( + governor, controller, &lease)); + CHECK(lardon3d_ssd_controller_corrupt_cached_snapshot_for_test( + controller)); + CHECK(lardon3d_resource_governor_release_scratch( + governor, controller, &lease)); + CHECK(lardon3d_resource_governor_get_external_storage( + governor, &external)); + CHECK(external.generation == UINT64_MAX); + CHECK(external.status == LARDON3D_RESOURCE_EXTERNAL_STORAGE_ERROR); + CHECK(external.active_scratch_leases == 0); + CHECK(!external.new_scratch_allocations_allowed); + + CHECK(lardon3d_tui_ssd_async_destroy_checked(&operation)); + CHECK(operation == NULL); + CHECK(!lardon3d_resource_governor_get_external_storage( + governor, &external)); + CHECK(lardon3d_ssd_controller_destroy(controller)); + lardon3d_resource_governor_destroy(governor); + CHECK(pthread_mutex_destroy(&fake.mutex) == 0); + return true; +} + +typedef struct { + pthread_mutex_t mutex; + pthread_cond_t condition; + Lardon3DResourceGovernor *governor; + Lardon3DSsdController *controller; + Lardon3DSsdScratchLease lease; + bool acquired; + bool release; + bool released; +} TaskLeaseLifecycle; + +static bool +wait_flag( + pthread_mutex_t *mutex, + pthread_cond_t *condition, + const bool *flag +) +{ + struct timespec deadline; + if (clock_gettime(CLOCK_REALTIME, &deadline) != 0) { + return false; + } + deadline.tv_sec += 10; + (void)pthread_mutex_lock(mutex); + int result = 0; + while (!*flag && result == 0) { + result = pthread_cond_timedwait(condition, mutex, &deadline); + } + bool observed = *flag; + (void)pthread_mutex_unlock(mutex); + return result == 0 && observed; +} + +static bool +task_lease_callback(Lardon3DTask *task, void *userdata) +{ + (void)task; + TaskLeaseLifecycle *lifecycle = userdata; + if (!lardon3d_resource_governor_acquire_scratch( + lifecycle->governor, lifecycle->controller, + &lifecycle->lease)) { + return false; + } + (void)pthread_mutex_lock(&lifecycle->mutex); + lifecycle->acquired = true; + (void)pthread_cond_broadcast(&lifecycle->condition); + while (!lifecycle->release) { + (void)pthread_cond_wait(&lifecycle->condition, &lifecycle->mutex); + } + (void)pthread_mutex_unlock(&lifecycle->mutex); + + bool released = lardon3d_resource_governor_release_scratch( + lifecycle->governor, lifecycle->controller, &lifecycle->lease); + (void)pthread_mutex_lock(&lifecycle->mutex); + lifecycle->released = released; + (void)pthread_cond_broadcast(&lifecycle->condition); + (void)pthread_mutex_unlock(&lifecycle->mutex); + return released; +} + +typedef struct { + Lardon3DTaskQueue *queue; + atomic_bool returned; +} QueueDestroyCall; + +static void * +destroy_queue(void *userdata) +{ + QueueDestroyCall *call = userdata; + lardon3d_task_queue_destroy(call->queue); + atomic_store_explicit(&call->returned, true, memory_order_release); + return NULL; +} + +static bool +test_queue_before_bound_storage_teardown(void) +{ + FakeStorage fake; + CHECK(fake_storage_init(&fake)); + Lardon3DSsdController *controller = create_controller(&fake); + Lardon3DResourceGovernor *governor = create_governor(); + CHECK(controller && governor); + Lardon3DTuiSsdAsync *operation = + lardon3d_tui_ssd_async_create_with_governor(controller, governor); + CHECK(operation && lardon3d_tui_ssd_async_refresh(operation)); + CHECK(lardon3d_tui_ssd_async_wait_idle( + operation, UINT64_C(1000000000))); + + Lardon3DTaskQueue *queue = lardon3d_task_queue_create(governor, 1); + TaskLeaseLifecycle lifecycle = { + .governor = governor, + .controller = controller, + }; + CHECK(queue && pthread_mutex_init(&lifecycle.mutex, NULL) == 0); + CHECK(pthread_cond_init(&lifecycle.condition, NULL) == 0); + const Lardon3DResourceEstimate estimate = { + .minimum_batch_size = 1, + .maximum_batch_size = 1, + .desired_cpu_threads = 1, + }; + Lardon3DTask *task = lardon3d_task_create( + "Task holding external scratch", &estimate, + task_lease_callback, &lifecycle); + CHECK(task && lardon3d_task_queue_add(queue, task, NULL)); + CHECK(wait_flag(&lifecycle.mutex, &lifecycle.condition, + &lifecycle.acquired)); + + QueueClosingProbe probe = {.queue = queue}; + CHECK(pthread_mutex_init(&probe.mutex, NULL) == 0); + CHECK(pthread_cond_init(&probe.condition, NULL) == 0); + atomic_store_explicit( + &active_closing_probe, &probe, memory_order_release); + QueueDestroyCall destroy_call = {.queue = queue}; + pthread_t destroy_thread; + CHECK(pthread_create(&destroy_thread, NULL, + destroy_queue, &destroy_call) == 0); + CHECK(wait_flag(&probe.mutex, &probe.condition, &probe.closing)); + CHECK(!atomic_load_explicit( + &destroy_call.returned, memory_order_acquire)); + + Lardon3DResourceExternalStorage external; + CHECK(lardon3d_resource_governor_get_external_storage( + governor, &external)); + CHECK(external.active_scratch_leases == 1); + (void)pthread_mutex_lock(&lifecycle.mutex); + lifecycle.release = true; + (void)pthread_cond_broadcast(&lifecycle.condition); + (void)pthread_mutex_unlock(&lifecycle.mutex); + CHECK(wait_flag(&lifecycle.mutex, &lifecycle.condition, + &lifecycle.released)); + CHECK(pthread_join(destroy_thread, NULL) == 0); + CHECK(atomic_load_explicit( + &destroy_call.returned, memory_order_acquire)); + atomic_store_explicit( + &active_closing_probe, NULL, memory_order_release); + + /* This is the application ownership boundary: Queue returned only after + * the Task released its lease, so checked teardown can now unregister + * before either borrowed physical owner is destroyed. */ + CHECK(lardon3d_tui_ssd_async_destroy_checked(&operation)); + CHECK(operation == NULL); + CHECK(!lardon3d_resource_governor_get_external_storage( + governor, &external)); + CHECK(lardon3d_ssd_controller_destroy(controller)); + lardon3d_resource_governor_destroy(governor); + + CHECK(pthread_cond_destroy(&probe.condition) == 0); + CHECK(pthread_mutex_destroy(&probe.mutex) == 0); + CHECK(pthread_cond_destroy(&lifecycle.condition) == 0); + CHECK(pthread_mutex_destroy(&lifecycle.mutex) == 0); + CHECK(pthread_mutex_destroy(&fake.mutex) == 0); + return true; +} + +int +main(void) +{ + return test_registry_and_wrappers() && test_malformed_conversion() + && test_checked_async_teardown_with_live_lease() + && test_saturated_generation_wrapper_reconciliation() + && test_queue_before_bound_storage_teardown() + ? 0 : 1; +} diff --git a/tests/test_resource_governor.c b/tests/test_resource_governor.c index 2f87700..14cd253 100644 --- a/tests/test_resource_governor.c +++ b/tests/test_resource_governor.c @@ -218,6 +218,177 @@ decide_repeatedly(void *argument) return NULL; } +static bool +run_portable_default_policy_test(void) +{ + static const struct { + unsigned int logical; + unsigned int reserved; + unsigned int compute; + } cases[] = { + {1, 0, 1}, + {2, 1, 1}, + {3, 2, 1}, + {4, 3, 1}, + {8, 4, 4}, + {16, 4, 12}, + {32, 4, 28}, + }; + for (size_t index = 0; index < sizeof(cases) / sizeof(cases[0]); + ++index) { + Lardon3DHardwareProfile profile = { + .logical_cpu_count = cases[index].logical, + .page_size_bytes = 4096, + .memory_total_bytes = GIBIBYTES(16), + .cpu_architecture = "synthetic", + }; + Lardon3DResourcePolicy policy; + CHECK(lardon3d_resource_policy_default(&profile, &policy) + && policy.system_cpu_reserve == cases[index].reserved); + Lardon3DResourceGovernor *governor = + lardon3d_resource_governor_create(&profile, &policy); + Lardon3DResourcePolicy observed_policy = {0}; + CHECK(governor + && lardon3d_resource_governor_get_policy( + governor, &observed_policy) + && observed_policy.system_cpu_reserve + == policy.system_cpu_reserve + && observed_policy.system_memory_reserve_bytes + == policy.system_memory_reserve_bytes + && observed_policy.emergency_memory_floor_bytes + == policy.emergency_memory_floor_bytes); + Lardon3DResourceCpuTopologyInput unavailable = {0}; + Lardon3DResourceCpuPolicyDiagnostic diagnostic; + /* Force the deterministic count-only seam so this matrix never + * inherits the machine running the test suite. */ + CHECK(governor + && lardon3d_resource_governor_internal_configure_cpu_topology( + governor, &unavailable) + && lardon3d_resource_governor_internal_cpu_policy( + governor, &diagnostic) + && !diagnostic.affinity_configured + && diagnostic.compute_cpu_count == cases[index].compute + && diagnostic.reserved_cpu_count == cases[index].reserved); + lardon3d_resource_governor_destroy(governor); + } + return true; +} + +static bool +run_default_memory_band_test(void) +{ + Lardon3DHardwareProfile profile = { + .logical_cpu_count = 8, + .page_size_bytes = 4096, + .memory_total_bytes = GIBIBYTES(16), + .cpu_architecture = "synthetic", + }; + Lardon3DResourcePolicy policy; + CHECK(lardon3d_resource_policy_default(&profile, &policy) + && policy.system_memory_reserve_bytes == GIBIBYTES(3) + && policy.emergency_memory_floor_bytes == GIBIBYTES(3)); + policy.maximum_cpu_load_ratio = 1.0; + Lardon3DResourceGovernor *governor = + lardon3d_resource_governor_create(&profile, &policy); + Lardon3DResourceCpuTopologyInput count_only = {0}; + CHECK(governor + && lardon3d_resource_governor_internal_configure_cpu_topology( + governor, &count_only) + && use_fixed_test_clock(governor)); + Lardon3DResourceEstimate estimate = { + .memory_fixed_bytes = GIBIBYTES(1), + .minimum_batch_size = 1, + .maximum_batch_size = 1, + .desired_cpu_threads = 1, + .task_class = LARDON3D_RESOURCE_TASK_CPU, + }; + Lardon3DResourceSnapshot snapshot = { + .memory_available_bytes = GIBIBYTES(4) + MEBIBYTES(512), + }; + Lardon3DResourceDecision decision; + Lardon3DResourceReservation *reservation = NULL; + /* This 1 GiB admission proves capacity subtracts 3 GiB, not the 4 GiB + * caution threshold: 4.5 - 3 fits, while 4.5 - 4 would not. */ + CHECK(lardon3d_resource_governor_reserve(governor, &snapshot, &estimate, + &decision, &reservation) + && reservation && decision.kind == LARDON3D_RESOURCE_START + && lardon3d_resource_governor_pressure(governor) + == LARDON3D_RESOURCE_PRESSURE_GREEN + && lardon3d_resource_governor_release(governor, reservation)); + + estimate.memory_fixed_bytes = 0; + estimate.memory_bytes_per_item = MEBIBYTES(256); + estimate.maximum_batch_size = 4; + snapshot.memory_available_bytes = GIBIBYTES(3) + MEBIBYTES(512); + for (unsigned int observation = 0; observation < 2; ++observation) { + reservation = NULL; + CHECK(lardon3d_resource_governor_reserve(governor, &snapshot, + &estimate, &decision, &reservation) + && reservation && decision.kind == LARDON3D_RESOURCE_START + && decision.batch_size == 1 + && lardon3d_resource_governor_pressure(governor) + == LARDON3D_RESOURCE_PRESSURE_YELLOW + && lardon3d_resource_governor_release(governor, reservation)); + } + /* A stable default caution band remains YELLOW, while the 3 GiB admission + * floor independently rejects work that would consume the host reserve. */ + estimate.memory_fixed_bytes = MEBIBYTES(600); + estimate.memory_bytes_per_item = 0; + estimate.maximum_batch_size = 1; + reservation = NULL; + CHECK(lardon3d_resource_governor_reserve(governor, &snapshot, &estimate, + &decision, &reservation) + && !reservation && decision.kind == LARDON3D_RESOURCE_WAIT + && lardon3d_resource_governor_pressure(governor) + == LARDON3D_RESOURCE_PRESSURE_YELLOW); + + estimate.memory_fixed_bytes = MEBIBYTES(256); + snapshot.memory_available_bytes = GIBIBYTES(5); + for (unsigned int observation = 0; observation < 3; ++observation) { + reservation = NULL; + CHECK(lardon3d_resource_governor_reserve(governor, &snapshot, + &estimate, &decision, &reservation) + && reservation + && lardon3d_resource_governor_release(governor, reservation)); + } + CHECK(lardon3d_resource_governor_pressure(governor) + == LARDON3D_RESOURCE_PRESSURE_GREEN); + snapshot.memory_available_bytes = GIBIBYTES(3); + reservation = NULL; + CHECK(lardon3d_resource_governor_reserve(governor, &snapshot, &estimate, + &decision, &reservation) + && !reservation && decision.kind == LARDON3D_RESOURCE_WAIT + && lardon3d_resource_governor_pressure(governor) + == LARDON3D_RESOURCE_PRESSURE_RED); + lardon3d_resource_governor_destroy(governor); + + profile.memory_total_bytes = GIBIBYTES(2); + CHECK(lardon3d_resource_policy_default(&profile, &policy) + && policy.system_memory_reserve_bytes == MEBIBYTES(512) + && policy.emergency_memory_floor_bytes == MEBIBYTES(512)); + policy.maximum_cpu_load_ratio = 1.0; + governor = lardon3d_resource_governor_create(&profile, &policy); + CHECK(governor && use_fixed_test_clock(governor)); + estimate.memory_fixed_bytes = MEBIBYTES(64); + snapshot.memory_available_bytes = MEBIBYTES(600); + reservation = NULL; + CHECK(lardon3d_resource_governor_reserve(governor, &snapshot, &estimate, + &decision, &reservation) + && reservation + && lardon3d_resource_governor_pressure(governor) + == LARDON3D_RESOURCE_PRESSURE_YELLOW + && lardon3d_resource_governor_release(governor, reservation)); + snapshot.memory_available_bytes = MEBIBYTES(512); + reservation = NULL; + CHECK(lardon3d_resource_governor_reserve(governor, &snapshot, &estimate, + &decision, &reservation) + && !reservation && decision.kind == LARDON3D_RESOURCE_WAIT + && lardon3d_resource_governor_pressure(governor) + == LARDON3D_RESOURCE_PRESSURE_RED); + lardon3d_resource_governor_destroy(governor); + return true; +} + static bool run_test(void) { @@ -229,8 +400,8 @@ run_test(void) }; Lardon3DResourcePolicy policy; CHECK(lardon3d_resource_policy_default(&profile, &policy)); - CHECK(policy.system_memory_reserve_bytes == GIBIBYTES(4)); - CHECK(policy.emergency_memory_floor_bytes == GIBIBYTES(2)); + CHECK(policy.system_memory_reserve_bytes == GIBIBYTES(3)); + CHECK(policy.emergency_memory_floor_bytes == GIBIBYTES(3)); CHECK(policy.system_cpu_reserve == 4); CHECK(policy.maximum_cpu_pressure_avg10 == 20.0); CHECK(policy.maximum_memory_pressure_avg10 == 1.0); @@ -1257,11 +1428,70 @@ run_cpu_topology_policy_test(void) CHECK(diagnostic.compute_mask[word] == 0 && diagnostic.reserved_mask[word] == 0); } + + /* Two CPUs excluded by an external mask already satisfy half of the host + * reserve. Without topology, only the remaining two are removed by count; + * no unverifiable sibling mask is fabricated. */ + topology = (Lardon3DResourceCpuTopologyInput) { + .affinity_available = true, + .topology_available = false, + .allowed_cpu_count = 14, + }; + for (unsigned int cpu = 0; cpu < 14; ++cpu) { + topology.allowed_cpu_ids[cpu] = cpu; + } + CHECK(lardon3d_resource_governor_internal_configure_cpu_topology( + governor, &topology) + && lardon3d_resource_governor_internal_cpu_policy( + governor, &diagnostic)); + CHECK(!diagnostic.affinity_configured + && diagnostic.externally_constrained + && diagnostic.compute_cpu_count == 12 + && diagnostic.reserved_cpu_count == 2 + && strcmp(diagnostic.reason, + "fallback-portable-topology-unavailable") == 0); + lardon3d_resource_governor_destroy(governor); + + /* Asymmetric complete-core groups of 3,3,2 cannot meet logical reserve 4 + * exactly. The minimum safe whole-core total is 5, not the greedy 6; the + * highest physical identities win the deterministic equal-size tie. */ + profile.logical_cpu_count = 8; + governor = lardon3d_resource_governor_create(&profile, &policy); + CHECK(governor && use_fixed_test_clock(governor)); + topology = (Lardon3DResourceCpuTopologyInput) { + .affinity_available = true, + .topology_available = true, + .allowed_cpu_count = 8, + .topology_entry_count = 8, + }; + const unsigned int asymmetric_core[] = {0, 0, 0, 1, 1, 1, 2, 2}; + for (unsigned int cpu = 0; cpu < 8; ++cpu) { + topology.allowed_cpu_ids[cpu] = cpu; + topology.topology_entries[cpu] = + (Lardon3DResourceCpuTopologyEntry) { + .cpu_id = cpu, + .package_id = 0, + .core_id = asymmetric_core[cpu], + }; + } + CHECK(lardon3d_resource_governor_internal_configure_cpu_topology( + governor, &topology) + && lardon3d_resource_governor_internal_cpu_policy( + governor, &diagnostic) + && diagnostic.affinity_configured + && diagnostic.compute_cpu_count == 3 + && diagnostic.reserved_cpu_count == 5); + for (unsigned int cpu = 0; cpu < 8; ++cpu) { + bool reserved = cpu >= 3; + CHECK(cpu_mask_has(diagnostic.reserved_mask, cpu) == reserved + && cpu_mask_has(diagnostic.compute_mask, cpu) == !reserved); + } lardon3d_resource_governor_destroy(governor); /* Read-only current-host assertion: when the caller really sees the * unrestricted 0..15 topology, the production discovery must reproduce * the validated compute/reserved masks. Other hosts skip this exact IDs. */ + profile.logical_cpu_count = 16; CHECK(lardon3d_resource_policy_default(&profile, &policy)); governor = lardon3d_resource_governor_create(&profile, &policy); CHECK(governor && lardon3d_resource_governor_internal_cpu_policy( @@ -1712,7 +1942,7 @@ run_capability_governor_test(void) governor, &snapshot, "matcher.run", 1, &envelope, &selection, &reservation)); CHECK(!reservation && selection.pressure == - LARDON3D_RESOURCE_PRESSURE_YELLOW); + LARDON3D_RESOURCE_PRESSURE_RED); snapshot.memory_available_bytes = GIBIBYTES(2); CHECK(lardon3d_resource_governor_internal_reserve_capability( governor, &snapshot, "matcher.run", 1, &envelope, &selection, @@ -2472,7 +2702,7 @@ static bool run_cpu_feedback_progression_test(void) { Lardon3DHardwareProfile profile = { - .logical_cpu_count = 16, + .logical_cpu_count = 32, .page_size_bytes = 4096, .memory_total_bytes = GIBIBYTES(16), .cpu_architecture = "test", @@ -2482,7 +2712,11 @@ run_cpu_feedback_progression_test(void) policy.maximum_cpu_load_ratio = 1.0; Lardon3DResourceGovernor *governor = lardon3d_resource_governor_create(&profile, &policy); - CHECK(governor && use_fixed_test_clock(governor)); + Lardon3DResourceCpuTopologyInput count_only = {0}; + CHECK(governor + && lardon3d_resource_governor_internal_configure_cpu_topology( + governor, &count_only) + && use_fixed_test_clock(governor)); Lardon3DTaskCapabilityEnvelope envelope = { .count = 1, .capabilities = {{ @@ -2490,7 +2724,7 @@ run_cpu_feedback_progression_test(void) .memory_bytes_per_item = MEBIBYTES(1), .minimum_batch_size = 1, .maximum_batch_size = 4, - .desired_cpu_threads = 12, + .desired_cpu_threads = 28, .task_class = LARDON3D_RESOURCE_TASK_CPU, }, .backend = LARDON3D_RESOURCE_BACKEND_CPU, @@ -2505,7 +2739,7 @@ run_cpu_feedback_progression_test(void) }; Lardon3DResourceCapabilitySelection selection; Lardon3DResourceReservation *reservation = NULL; - const unsigned int expected_cpu[] = {1, 2, 4, 8, 12}; + const unsigned int expected_cpu[] = {1, 2, 4, 8, 16, 28}; for (size_t step = 0; step < sizeof(expected_cpu) / sizeof(expected_cpu[0]); ++step) { for (unsigned int observation = 0; observation < 2; ++observation) { @@ -2526,7 +2760,7 @@ run_cpu_feedback_progression_test(void) CHECK(lardon3d_resource_governor_internal_reserve_capability( governor, &snapshot, "test.cpu.ramp", 1, &envelope, &selection, &reservation)); - CHECK(reservation && selection.decision.cpu_threads == 12 + CHECK(reservation && selection.decision.cpu_threads == 28 && selection.decision.batch_size == 2 && strcmp(selection.reason, "throughput-trial") == 0); CHECK(lardon3d_resource_governor_release(governor, reservation)); @@ -2534,10 +2768,10 @@ run_cpu_feedback_progression_test(void) CHECK(lardon3d_resource_governor_internal_last_diagnostic( governor, "test.cpu.ramp", 1, &ramp_diagnostic) && ramp_diagnostic.previous_wall_time_ns == 1000000000ULL - && ramp_diagnostic.items_completed == 12 - && ramp_diagnostic.durable_items_per_second_milli == 12000); + && ramp_diagnostic.items_completed == 28 + && ramp_diagnostic.durable_items_per_second_milli == 28000); - /* The first active swap-I/O delta is detected after the CPU12/batch2 + /* The first active swap-I/O delta is detected after the maximum-CPU/batch2 * operational trial was copied. This same admission must replace both * dimensions with CPU1/batch1 before it creates the reservation. */ snapshot.swap_pages_out = 1; @@ -2565,7 +2799,7 @@ run_cpu_feedback_progression_test(void) && strcmp(ramp_diagnostic.reason, "pressure-decrease") == 0 && lardon3d_resource_governor_release(governor, reservation)); - /* Recovery cannot reuse the old CPU12 baseline. Three healthy admissions + /* Recovery cannot reuse the old maximum-CPU baseline. Three healthy admissions * clear YELLOW, then two fresh CPU1 samples reopen only a CPU2 trial. */ for (unsigned int observation = 0; observation < 3; ++observation) { reservation = NULL; @@ -2674,7 +2908,10 @@ run_cpu_feedback_progression_test(void) profile.logical_cpu_count = 12; policy.system_cpu_reserve = 4; governor = lardon3d_resource_governor_create(&profile, &policy); - CHECK(governor && use_fixed_test_clock(governor)); + CHECK(governor + && lardon3d_resource_governor_internal_configure_cpu_topology( + governor, &count_only) + && use_fixed_test_clock(governor)); envelope.capabilities[0].estimate.maximum_batch_size = 1; envelope.capabilities[0].batch_adaptive = false; const unsigned int capped_cpu[] = {1, 2, 4, 8}; @@ -2707,6 +2944,8 @@ int main(void) { return (run_driver_runtime_policy_test() + && run_portable_default_policy_test() + && run_default_memory_band_test() && run_test() && run_generation_test() && run_adaptive_batch_test() && run_gate_g_boundary_test() && run_topology_value_reader_test() && run_gpu_busy_identity_reader_test() diff --git a/tests/test_resource_snapshot.c b/tests/test_resource_snapshot.c index b7b2815..ff8ae91 100644 --- a/tests/test_resource_snapshot.c +++ b/tests/test_resource_snapshot.c @@ -169,6 +169,12 @@ run_test(void) && snapshot.captured_at.tv_nsec <= after.tv_nsec)); CHECK(snapshot.memory_available_bytes > 0); CHECK(snapshot.memory_free_bytes <= profile.memory_total_bytes); + Lardon3DResourceObservation observation; + CHECK(lardon3d_resource_observation_capture( + &profile, &observation, error, sizeof(error))); + CHECK(observation.swap_total_known); + CHECK(observation.snapshot.swap_available_bytes + <= observation.swap_total_bytes); CHECK(snapshot.cpu_load_1m >= 0.0); CHECK(snapshot.cpu_load_5m >= 0.0); CHECK(snapshot.cpu_load_15m >= 0.0); diff --git a/tests/test_runtime_observer.c b/tests/test_runtime_observer.c new file mode 100644 index 0000000..f923383 --- /dev/null +++ b/tests/test_runtime_observer.c @@ -0,0 +1,509 @@ +#include +#include +#include +#include +#include + +#include "../src/runtime_observer_internal.h" + +#define GIB (UINT64_C(1024) * UINT64_C(1024) * UINT64_C(1024)) +#define CHECK(condition) \ + do { \ + if (!(condition)) { \ + (void)fprintf(stderr, "runtime observer failure line %d: %s\n",\ + __LINE__, #condition); \ + return false; \ + } \ + } while (0) + +typedef struct { + uint64_t now_ns; + size_t capture_calls; + bool fail_capture; + bool destroyed; + Lardon3DRuntimeObserverSample sample; +} FakeObserver; + +static bool +fake_now(void *context, uint64_t *now_ns) +{ + FakeObserver *fake = context; + *now_ns = fake->now_ns; + return true; +} + +static bool +fake_capture( + void *context, + Lardon3DRuntimeObserverSample *sample, + char reason[LARDON3D_TUI_TEXT_CAPACITY] +) +{ + FakeObserver *fake = context; + ++fake->capture_calls; + if (fake->fail_capture) { + (void)snprintf(reason, LARDON3D_TUI_TEXT_CAPACITY, + "injected bounded capture failure"); + return false; + } + *sample = fake->sample; + reason[0] = '\0'; + return true; +} + +static void +fake_destroy(void *context) +{ + ((FakeObserver *)context)->destroyed = true; +} + +static const Lardon3DRuntimeObserverProviderOps fake_ops = { + .monotonic_now_ns = fake_now, + .capture = fake_capture, + .destroy = fake_destroy, +}; + +static bool production_stub_enabled; +static unsigned int last_diagnostic_calls; +static Lardon3DTaskObservation production_stub_task; + +/* The production-owner stubs are enabled only for the final association + * regression. They supply owners, not model results, so the same production + * observer builder remains under test. */ +size_t +lardon3d_task_queue_observe( + Lardon3DTaskQueue *queue, + Lardon3DTaskObservation *observations, + size_t capacity, + Lardon3DTaskQueueSummary *summary +) +{ + (void)queue; + if (summary) { + *summary = (Lardon3DTaskQueueSummary) {0}; + } + if (!production_stub_enabled || !observations || capacity == 0) { + return 0; + } + observations[0] = production_stub_task; + if (summary) { + summary->running = 1; + summary->total = 1; + } + return 1; +} + +bool +lardon3d_resource_observation_capture( + const Lardon3DHardwareProfile *profile, + Lardon3DResourceObservation *observation, + char *error_message, + size_t error_message_size +) +{ + (void)profile; + if (error_message && error_message_size) { + error_message[0] = '\0'; + } + if (!production_stub_enabled || !observation) { + return false; + } + *observation = (Lardon3DResourceObservation) { + .snapshot = { + .memory_available_bytes = 8 * GIB, + .swap_available_bytes = 3 * GIB, + }, + .swap_total_known = true, + .swap_total_bytes = 4 * GIB, + }; + return true; +} + +bool +lardon3d_resource_governor_availability( + Lardon3DResourceGovernor *governor, + const Lardon3DResourceSnapshot *snapshot, + Lardon3DResourceAvailability *availability +) +{ + (void)governor; + (void)snapshot; + if (!production_stub_enabled || !availability) { + return false; + } + *availability = (Lardon3DResourceAvailability) { + .memory_budget_bytes = 13 * GIB, + .cpu_budget = 4, + .cpu_reserved = 2, + .cpu_available = 2, + }; + return true; +} + +bool +lardon3d_resource_governor_get_policy( + Lardon3DResourceGovernor *governor, + Lardon3DResourcePolicy *policy +) +{ + (void)governor; + if (!production_stub_enabled || !policy) { + return false; + } + *policy = (Lardon3DResourcePolicy) { + .system_memory_reserve_bytes = 3 * GIB, + }; + return true; +} + +bool +lardon3d_resource_governor_get_external_storage( + Lardon3DResourceGovernor *governor, + Lardon3DResourceExternalStorage *storage +) +{ + (void)governor; + if (storage) { + *storage = (Lardon3DResourceExternalStorage) {0}; + } + if (!production_stub_enabled || !storage) { + return false; + } + *storage = (Lardon3DResourceExternalStorage) { + .generation = 9, + .status = LARDON3D_RESOURCE_EXTERNAL_STORAGE_AVAILABLE, + .new_scratch_allocations_allowed = true, + }; + (void)snprintf(storage->stable_identity, + sizeof(storage->stable_identity), "drive-production"); + (void)snprintf(storage->reason, sizeof(storage->reason), + "registered production snapshot"); + return true; +} + +Lardon3DResourcePressure +lardon3d_resource_governor_pressure(Lardon3DResourceGovernor *governor) +{ + (void)governor; + return LARDON3D_RESOURCE_PRESSURE_YELLOW; +} + +bool +lardon3d_resource_governor_internal_cpu_policy( + Lardon3DResourceGovernor *governor, + Lardon3DResourceCpuPolicyDiagnostic *diagnostic +) +{ + (void)governor; + if (!production_stub_enabled || !diagnostic) { + return false; + } + *diagnostic = (Lardon3DResourceCpuPolicyDiagnostic) { + .compute_cpu_count = 4, + }; + (void)snprintf(diagnostic->reason, sizeof(diagnostic->reason), + "whole-core compute pool"); + return true; +} + +bool +lardon3d_resource_governor_internal_last_diagnostic( + Lardon3DResourceGovernor *governor, + const char *task_kind, + uint32_t task_kind_version, + Lardon3DResourceSequenceDiagnostic *diagnostic +) +{ + (void)governor; + (void)task_kind; + (void)task_kind_version; + (void)diagnostic; + ++last_diagnostic_calls; + return false; +} + +const char * +lardon3d_task_state_name(Lardon3DTaskState state) +{ + static const char *const names[] = { + "PENDING", "RUNNING", "PAUSED", "CANCELLED", "FAILED", "COMPLETED", + }; + return state >= TASK_PENDING && state <= TASK_COMPLETED + ? names[state] : "UNKNOWN"; +} + +static void +prepare_sample(FakeObserver *fake) +{ + fake->sample = (Lardon3DRuntimeObserverSample) { + .realtime_now = {.tv_sec = 100, .tv_nsec = 0}, + .task_count = 1, + .task_summary = {.running = 1, .total = 1}, + .resource_valid = true, + .resource_observation = { + .snapshot = { + .memory_available_bytes = 8 * GIB, + .swap_available_bytes = 3 * GIB, + .memory_pressure_known = true, + .memory_pressure_avg10 = 0.0, + .swap_activity_known = true, + .swap_pages_in = 10, + .swap_pages_out = 20, + }, + .swap_total_known = true, + .swap_total_bytes = 4 * GIB, + }, + .availability = { + .memory_budget_bytes = 13 * GIB, + .memory_reserved_bytes = GIB, + .cpu_budget = 4, + .cpu_reserved = 2, + .cpu_available = 2, + .gpu_memory_known = true, + .gpu_memory_reserved_bytes = 128 * UINT64_C(1024) * 1024, + .gpu_memory_available_bytes = 512 * UINT64_C(1024) * 1024, + .gpu_slot_budget = 1, + .gpu_slots_reserved = 1, + .io_slot_budget = 2, + .io_slots_reserved = 1, + .io_slots_available = 1, + }, + .policy = {.system_memory_reserve_bytes = 3 * GIB}, + .cpu_policy = {.compute_cpu_count = 4}, + .pressure = LARDON3D_RESOURCE_PRESSURE_GREEN, + .external_storage_registered = true, + .external_storage = { + .generation = 7, + .status = LARDON3D_RESOURCE_EXTERNAL_STORAGE_IN_USE, + .new_scratch_allocations_allowed = true, + .scratch_total_known = true, + .scratch_free_known = true, + .scratch_total_bytes = 8 * GIB, + .scratch_free_bytes = 6 * GIB, + .swap_total_known = true, + .swap_used_known = true, + .swap_total_bytes = 4 * GIB, + .swap_used_bytes = GIB, + .active_scratch_leases = 2, + .stable_identity = "drive-1", + .reason = "registered and in use", + }, + .ssd_controller_available = true, + .ssd_snapshot_valid = true, + .ssd = { + .state = LARDON3D_SSD_IN_USE, + .scratch_mounted = true, + .scratch_lease_count = 2, + }, + }; + (void)snprintf(fake->sample.cpu_policy.reason, + sizeof(fake->sample.cpu_policy.reason), "whole-core compute pool"); + fake->sample.tasks[0] = (Lardon3DTaskObservation) { + .id = 91, + .has_task_kind = true, + .task_kind_version = 1, + .progress = 25, + .durable_progress_known = true, + .durable_completed = 25, + .durable_total = 100, + .state = TASK_RUNNING, + .started_at = {.tv_sec = 90, .tv_nsec = 0}, + .has_execution_contract = true, + .execution_contract = { + .batch_size = 8, + .cpu_threads = 2, + .gpu_slots = 1, + .io_slots = 1, + }, + }; + (void)snprintf(fake->sample.tasks[0].name, + sizeof(fake->sample.tasks[0].name), "matcher"); + (void)snprintf(fake->sample.tasks[0].task_kind, + sizeof(fake->sample.tasks[0].task_kind), "matcher.run"); + (void)snprintf(fake->sample.tasks[0].message, + sizeof(fake->sample.tasks[0].message), "matching"); +} + +static bool +run_test(void) +{ + Lardon3DHardwareProfile profile = { + .logical_cpu_count = 8, + .memory_total_bytes = 16 * GIB, + .gpu_available = true, + .gpu_uses_shared_memory = true, + }; + FakeObserver fake = {.now_ns = UINT64_C(2000000000)}; + prepare_sample(&fake); + Lardon3DRuntimeObserver *observer = + lardon3d_runtime_observer_create_with_provider( + &profile, + (Lardon3DRuntimeObserverProvider) { + .ops = &fake_ops, + .context = &fake, + }, + UINT64_C(1000000000)); + CHECK(observer); + + Lardon3DRuntimeSnapshot view; + CHECK(lardon3d_runtime_observer_refresh(observer, true, true, &view)); + CHECK(fake.capture_calls == 1); + CHECK(view.generation == 1); + CHECK(view.active_task_known && view.active_task_index == 0); + CHECK(view.stages[LARDON3D_TUI_STAGE_MATCHER].state + == LARDON3D_TUI_STAGE_RUNNING); + CHECK(view.stages[LARDON3D_TUI_STAGE_DENSE].state + == LARDON3D_TUI_STAGE_NOT_APPLICABLE); + CHECK(view.active_progress.percentage == 25); + CHECK(view.active_progress.durable_counts_known); + CHECK(view.active_progress.completed == 25); + CHECK(view.active_progress.total == 100); + CHECK(view.active_progress.elapsed_known); + CHECK(view.active_progress.elapsed_seconds == 10); + CHECK(view.resources.valid); + CHECK(view.resources.cpu_logical_total == 8); + CHECK(view.resources.cpu_active == 2); + CHECK(view.resources.cpu_admitted_known); + CHECK(view.resources.cpu_admitted == 2); + CHECK(view.resources.cpu_available == 2); + CHECK(view.resources.gpu_slots_active == 1); + CHECK(!view.resources.gpu_busy_known); + CHECK(view.resources.ram_reserve_bytes == 3 * GIB); + CHECK(view.resources.swap_total_bytes == 4 * GIB); + CHECK(view.resources.swap_used_bytes == GIB); + CHECK(!view.resources.swap_delta_known); + CHECK(view.resources.gpu_uses_shared_memory); + CHECK(view.resources.batch_known); + CHECK(view.resources.batch_size == 8); + CHECK(!view.resources.inflight_known); + CHECK(!view.resources.helpers_known); + CHECK(view.resources.gpu_backend == LARDON3D_TUI_GPU_BACKEND_UNKNOWN); + CHECK(view.resources.scratch_known && view.resources.scratch_leases == 2); + CHECK(view.resources.external_storage_registered); + CHECK(view.resources.external_storage_status + == LARDON3D_RESOURCE_EXTERNAL_STORAGE_IN_USE); + CHECK(view.resources.scratch_new_allocations_allowed); + CHECK(view.resources.external_swap_used_bytes == GIB); + CHECK(strcmp(view.resources.external_storage_identity, "drive-1") == 0); + CHECK(view.ssd.state == LARDON3D_SSD_IN_USE); + + fake.now_ns += UINT64_C(500000000); + CHECK(lardon3d_runtime_observer_refresh(observer, true, false, &view)); + CHECK(fake.capture_calls == 1); + CHECK(view.generation == 1); + + fake.now_ns += UINT64_C(500000000); + fake.sample.resource_observation.snapshot.swap_pages_in = 12; + fake.sample.resource_observation.snapshot.swap_pages_out = 25; + CHECK(lardon3d_runtime_observer_refresh(observer, true, false, &view)); + CHECK(fake.capture_calls == 2); + CHECK(view.resources.swap_delta_known); + CHECK(view.resources.swap_pages_in_delta == 2); + CHECK(view.resources.swap_pages_out_delta == 5); + + /* A project identity transition invalidates coalescing immediately. */ + fake.now_ns += 1; + CHECK(lardon3d_runtime_observer_refresh(observer, false, false, &view)); + CHECK(fake.capture_calls == 3); + CHECK(view.task_count == 0); + CHECK(!view.active_task_known); + CHECK(view.task_summary.total == 0); + + fake.now_ns += UINT64_C(1000000000); + fake.fail_capture = true; + uint64_t generation = view.generation; + CHECK(!lardon3d_runtime_observer_refresh(observer, false, true, &view)); + CHECK(view.stale); + CHECK(view.generation == generation); + CHECK(strstr(view.status, "injected") != NULL); + + fake.fail_capture = false; + fake.now_ns += UINT64_C(1000000000); + fake.sample.resource_observation.snapshot.swap_pages_in = 1; + fake.sample.resource_observation.snapshot.swap_pages_out = 2; + CHECK(lardon3d_runtime_observer_refresh(observer, false, true, &view)); + CHECK(!view.resources.swap_delta_known); + CHECK(!view.stale); + + /* The admitted Task contract knows batch, but cannot fabricate private + * inflight/helper dimensions when no Governor diagnostic exists. */ + fake.now_ns += UINT64_C(1000000000); + CHECK(lardon3d_runtime_observer_refresh(observer, true, true, &view)); + CHECK(view.resources.batch_known && view.resources.batch_size == 8); + CHECK(!view.resources.inflight_known && !view.resources.helpers_known); + CHECK(view.resources.gpu_backend + == LARDON3D_TUI_GPU_BACKEND_UNKNOWN); + + /* Aggregate CPU reservations cannot fill in the admitted count for a Task + * whose exact installed contract is absent. */ + fake.sample.tasks[0].has_execution_contract = false; + fake.sample.tasks[0].execution_contract = + (Lardon3DTaskExecutionContract) {0}; + fake.now_ns += UINT64_C(1000000000); + CHECK(lardon3d_runtime_observer_refresh(observer, true, true, &view)); + CHECK(!view.resources.cpu_admitted_known); + CHECK(view.resources.cpu_admitted == 0); + CHECK(!view.resources.batch_known); + fake.sample.tasks[0].has_execution_contract = true; + fake.sample.tasks[0].execution_contract = + (Lardon3DTaskExecutionContract) { + .batch_size = 8, + .cpu_threads = 2, + .gpu_slots = 1, + .io_slots = 1, + }; + + /* A fully saturated production Queue can expose 64 pending records before + * the running record in its newest-first order. The 129-entry observation + * bound and active scan must still select the actual running Task. */ + Lardon3DTaskObservation active = fake.sample.tasks[0]; + fake.sample.task_count = 65; + fake.sample.task_summary = (Lardon3DTaskQueueSummary) { + .running = 1, + .pending = 64, + .total = 65, + }; + for (size_t index = 0; index < 64; ++index) { + fake.sample.tasks[index] = (Lardon3DTaskObservation) { + .id = UINT64_C(1000) + (uint64_t)index, + .state = TASK_PENDING, + }; + (void)snprintf(fake.sample.tasks[index].name, + sizeof(fake.sample.tasks[index].name), "pending-%zu", index); + } + fake.sample.tasks[64] = active; + fake.now_ns += UINT64_C(1000000000); + CHECK(lardon3d_runtime_observer_refresh(observer, true, true, &view)); + CHECK(view.task_count == 65); + CHECK(view.active_task_known && view.active_task_index == 64); + CHECK(view.tasks[view.active_task_index].id == active.id); + + lardon3d_runtime_observer_destroy(observer); + CHECK(fake.destroyed); + + /* Production capture must not query a kind/version-wide last diagnostic: + * such a row may belong to a prior Task with the same kind. */ + production_stub_enabled = true; + production_stub_task = active; + last_diagnostic_calls = 0; + int queue_owner; + int governor_owner; + observer = lardon3d_runtime_observer_create(&profile, + (Lardon3DTaskQueue *)&queue_owner, + (Lardon3DResourceGovernor *)&governor_owner); + CHECK(observer); + CHECK(lardon3d_runtime_observer_refresh(observer, true, true, &view)); + CHECK(last_diagnostic_calls == 0); + CHECK(view.resources.batch_known && view.resources.batch_size == 8); + CHECK(!view.resources.inflight_known && !view.resources.helpers_known); + CHECK(view.resources.gpu_backend == LARDON3D_TUI_GPU_BACKEND_UNKNOWN); + lardon3d_runtime_observer_destroy(observer); + production_stub_enabled = false; + return true; +} + +int +main(void) +{ + return run_test() ? EXIT_SUCCESS : EXIT_FAILURE; +} diff --git a/tests/test_selected_execution.c b/tests/test_selected_execution.c index 9629759..29ce0dd 100644 --- a/tests/test_selected_execution.c +++ b/tests/test_selected_execution.c @@ -48,6 +48,16 @@ static bool seed_v21(const char *path) { "2,randomblob(32));" "INSERT INTO sparse_calibration_scopes VALUES(1,randomblob(32),1);" "INSERT INTO sparse_calibration_scope_images VALUES(1,1,1);" + /* This fixture is a true v21 database; future additive objects must not + remain merely because it was generated from a temporary current DB. */ + "DROP TABLE capture_calibration_selections;" + "DROP TABLE optical_calibration_profiles;" + "DROP TABLE capture_optical_configurations;" + "DROP INDEX acquisition_campaign_capture_identity_v23;" + "DROP TABLE acquisition_campaign_group_optics;" + "DROP TABLE optical_configurations;" + "DROP TABLE lens_profile_aliases;DROP TABLE lens_profiles;" + "DROP TABLE camera_body_aliases;DROP TABLE camera_body_profiles;" "DROP TABLE selected_execution_items;DROP TABLE selected_executions;" "UPDATE metadata SET value=21 WHERE key='schema_version';"); } @@ -79,7 +89,8 @@ static bool run(void) { if (migration_result != LARDON3D_PROJECT_DB_OK) fprintf(stderr, "retry migration result=%d error=%s\n", (int)migration_result, error); CHECK(migration_result == LARDON3D_PROJECT_DB_OK); - CHECK(lardon3d_project_db_schema_version(database) == 22); + CHECK(lardon3d_project_db_schema_version(database) == + LARDON3D_PROJECT_DB_SCHEMA_VERSION); Lardon3DProjectDbSelectedExecutionItem item = { .item_index = 0, .quality_group_id = 1, @@ -154,6 +165,35 @@ static bool run(void) { CHECK(sql(path, "UPDATE selected_execution_items SET representation_source=2 " "WHERE execution_id=1;")); + /* Numeric prefixes in TEXT demonstrate why positive-value checks alone are + insufficient: sqlite3_column_int64() would coerce these to valid IDs. */ + CHECK(sql(path, "UPDATE selected_executions SET calibration_scope_id='1x' " + "WHERE execution_id=1;")); + CHECK(lardon3d_project_db_open(path, &database, error) == LARDON3D_PROJECT_DB_OK); + CHECK(lardon3d_project_db_load_selected_execution(database, execution_id, &execution) == + LARDON3D_PROJECT_DB_CORRUPT); + lardon3d_project_db_close(database); + CHECK(sql(path, "UPDATE selected_executions SET calibration_scope_id=1 " + "WHERE execution_id=1;")); + + CHECK(sql(path, "UPDATE selected_execution_items SET source_asset_id='2x' " + "WHERE execution_id=1;")); + CHECK(lardon3d_project_db_open(path, &database, error) == LARDON3D_PROJECT_DB_OK); + CHECK(lardon3d_project_db_load_selected_execution_item(database, execution_id, 0, &loaded) == + LARDON3D_PROJECT_DB_CORRUPT); + lardon3d_project_db_close(database); + CHECK(sql(path, "UPDATE selected_execution_items SET source_asset_id=2 " + "WHERE execution_id=1;")); + + CHECK(sql(path, "UPDATE selected_execution_items SET image_id='1x' " + "WHERE execution_id=1;")); + CHECK(lardon3d_project_db_open(path, &database, error) == LARDON3D_PROJECT_DB_OK); + CHECK(lardon3d_project_db_load_selected_execution_item(database, execution_id, 0, &loaded) == + LARDON3D_PROJECT_DB_CORRUPT); + lardon3d_project_db_close(database); + CHECK(sql(path, "UPDATE selected_execution_items SET image_id=1 " + "WHERE execution_id=1;")); + CHECK(sqlite3_open(path, &raw) == SQLITE_OK); CHECK(sqlite3_exec(raw, "PRAGMA ignore_check_constraints=ON;" "UPDATE selected_executions SET next_item_index=4294967296 " diff --git a/tests/test_sparse_sfm_geometry.cpp b/tests/test_sparse_sfm_geometry.cpp index 712fdcf..d8a5060 100644 --- a/tests/test_sparse_sfm_geometry.cpp +++ b/tests/test_sparse_sfm_geometry.cpp @@ -53,6 +53,150 @@ static double translation_direction_error( return std::acos(std::clamp(dot, -1.0, 1.0)); } +static bool relative_result_equal( + const Lardon3DSparseGeometryRelativePoseResult &left, + const Lardon3DSparseGeometryRelativePoseResult &right) { + return std::memcmp(left.pose_ba.rotation_cw, right.pose_ba.rotation_cw, + sizeof(left.pose_ba.rotation_cw)) == 0 && + std::memcmp(left.pose_ba.translation_cw, right.pose_ba.translation_cw, + sizeof(left.pose_ba.translation_cw)) == 0 && + left.inlier_count == right.inlier_count && + left.inlier_ratio == right.inlier_ratio && + left.median_parallax_rad == right.median_parallax_rad && + left.inlier_mask == right.inlier_mask && + left.inlier_mask_capacity == right.inlier_mask_capacity; +} + +static bool pnp_result_equal(const Lardon3DSparseGeometryPnPResult &left, + const Lardon3DSparseGeometryPnPResult &right) { + return std::memcmp(left.pose_cw.rotation_cw, right.pose_cw.rotation_cw, + sizeof(left.pose_cw.rotation_cw)) == 0 && + std::memcmp(left.pose_cw.translation_cw, right.pose_cw.translation_cw, + sizeof(left.pose_cw.translation_cw)) == 0 && + left.inlier_count == right.inlier_count && + left.inlier_ratio == right.inlier_ratio && + left.inlier_mask == right.inlier_mask && + left.inlier_mask_capacity == right.inlier_mask_capacity; +} + +static int signed_parameter_boundary_test() { + const Lardon3DSparseGeometryCalibration calibration = + {1280, 960, 800, 800, 640, 480, 0, 0, 0, 0}; + constexpr size_t count = 8; + Lardon3DSparseGeometryPoint2 relative_a[count]; + Lardon3DSparseGeometryPoint2 relative_b[count]; + Lardon3DSparseGeometryPoint3 pnp_points[count]; + Lardon3DSparseGeometryPoint2 pnp_pixels[count]; + for (size_t index = 0; index < count; ++index) { + relative_a[index] = {500.0 + static_cast(index), 480.0}; + relative_b[index] = {510.0 + static_cast(index), 480.0}; + pnp_points[index] = {static_cast(index), 0.0, 4.0}; + pnp_pixels[index] = {500.0 + static_cast(index), 480.0}; + } + + auto relative_rejects_without_mutation = + [&](uint32_t max_iterations, uint32_t minimum_inliers) { + uint8_t mask[count]; + std::fill(mask, mask + count, UINT8_C(0xa5)); + uint8_t mask_before[count]; + std::memcpy(mask_before, mask, sizeof(mask)); + Lardon3DSparseGeometryRelativePoseResult result = {}; + for (size_t index = 0; index < 9; ++index) + result.pose_ba.rotation_cw[index] = 10.0 + static_cast(index); + for (size_t index = 0; index < 3; ++index) + result.pose_ba.translation_cw[index] = + 20.0 + static_cast(index); + result.inlier_count = 71; + result.inlier_ratio = 0.375; + result.median_parallax_rad = 0.125; + result.inlier_mask = mask; + result.inlier_mask_capacity = count; + const Lardon3DSparseGeometryRelativePoseResult before = result; + Lardon3DSparseGeometryRelativePoseParameters parameters = + {1.0, 0.999, max_iterations, minimum_inliers, 0.5, 1e-4, 0.5, 7}; + + const Lardon3DSparseGeometryResult status = + lardon3d_sparse_geometry_relative_pose( + &calibration, &calibration, relative_a, relative_b, count, + ¶meters, &result); + return status == LARDON3D_SPARSE_GEOMETRY_INVALID_ARGUMENT && + relative_result_equal(result, before) && + std::memcmp(mask, mask_before, sizeof(mask)) == 0; + }; + + auto pnp_rejects_without_mutation = + [&](uint32_t max_iterations, uint32_t minimum_inliers) { + uint8_t mask[count]; + std::fill(mask, mask + count, UINT8_C(0x5a)); + uint8_t mask_before[count]; + std::memcpy(mask_before, mask, sizeof(mask)); + Lardon3DSparseGeometryPnPResult result = {}; + for (size_t index = 0; index < 9; ++index) + result.pose_cw.rotation_cw[index] = 30.0 + static_cast(index); + for (size_t index = 0; index < 3; ++index) + result.pose_cw.translation_cw[index] = + 40.0 + static_cast(index); + result.inlier_count = 83; + result.inlier_ratio = 0.625; + result.inlier_mask = mask; + result.inlier_mask_capacity = count; + const Lardon3DSparseGeometryPnPResult before = result; + Lardon3DSparseGeometryPnPParameters parameters = + {1.0, 0.999, max_iterations, minimum_inliers, 0.5, 9}; + + const Lardon3DSparseGeometryResult status = lardon3d_sparse_geometry_pnp( + &calibration, pnp_points, pnp_pixels, count, ¶meters, &result); + return status == LARDON3D_SPARSE_GEOMETRY_INVALID_ARGUMENT && + pnp_result_equal(result, before) && + std::memcmp(mask, mask_before, sizeof(mask)) == 0; + }; + + const uint32_t signed_maximum = + static_cast(std::numeric_limits::max()); + const uint32_t unrepresentable_values[] = { + signed_maximum + UINT32_C(1), std::numeric_limits::max()}; + for (uint32_t value : unrepresentable_values) { + CHECK(relative_rejects_without_mutation(value, 6)); + CHECK(relative_rejects_without_mutation(100, value)); + CHECK(pnp_rejects_without_mutation(value, 6)); + CHECK(pnp_rejects_without_mutation(100, value)); + } + CHECK(relative_rejects_without_mutation(0, 6)); + CHECK(pnp_rejects_without_mutation(0, 6)); + + /* + * INT_MAX itself remains valid. These collinear fixtures reach the existing + * deterministic degeneracy gate without asking either robust solver to run + * an impractically large iteration budget. + */ + Lardon3DSparseGeometryRelativePoseParameters relative_parameters = + {1.0, 0.999, signed_maximum, signed_maximum, 0.5, 1e-4, 0.5, 11}; + Lardon3DSparseGeometryRelativePoseResult relative_result = {}; + CHECK(lardon3d_sparse_geometry_relative_pose( + &calibration, &calibration, relative_a, relative_b, count, + &relative_parameters, &relative_result) == + LARDON3D_SPARSE_GEOMETRY_DEGENERATE); + relative_parameters.max_iterations = 1; + relative_parameters.minimum_inliers = 0; + CHECK(lardon3d_sparse_geometry_relative_pose( + &calibration, &calibration, relative_a, relative_b, count, + &relative_parameters, &relative_result) == + LARDON3D_SPARSE_GEOMETRY_DEGENERATE); + + Lardon3DSparseGeometryPnPParameters pnp_parameters = + {1.0, 0.999, signed_maximum, signed_maximum, 0.5, 13}; + Lardon3DSparseGeometryPnPResult pnp_result = {}; + CHECK(lardon3d_sparse_geometry_pnp( + &calibration, pnp_points, pnp_pixels, count, &pnp_parameters, + &pnp_result) == LARDON3D_SPARSE_GEOMETRY_DEGENERATE); + pnp_parameters.max_iterations = 1; + pnp_parameters.minimum_inliers = 0; + CHECK(lardon3d_sparse_geometry_pnp( + &calibration, pnp_points, pnp_pixels, count, &pnp_parameters, + &pnp_result) == LARDON3D_SPARSE_GEOMETRY_DEGENERATE); + return 0; +} + static int matrix_test() { Lardon3DSparseGeometryCalibration calibration = {1280, 960, 800, 800, 640, 480, 0, 0, 0, 0}; @@ -502,6 +646,7 @@ static int resource_test() { } int main(int argc, char **argv) { + CHECK(signed_parameter_boundary_test() == 0); if (argc > 1) if (std::strcmp(argv[1], "matrix") == 0) return matrix_test(); diff --git a/tests/test_ssd_controller.c b/tests/test_ssd_controller.c new file mode 100644 index 0000000..7c0d7f9 --- /dev/null +++ b/tests/test_ssd_controller.c @@ -0,0 +1,1529 @@ +#include "../src/ssd_controller_internal.h" + +#include +#include +#include +#include +#include +#include + +#define KIB UINT64_C(1024) +#define MIB (UINT64_C(1024) * KIB) +#define GIB (UINT64_C(1024) * MIB) + +#define CHECK(condition) \ + do { \ + if (!(condition)) { \ + fprintf(stderr, "CHECK failed at %s:%d: %s\n", __FILE__, __LINE__, \ + #condition); \ + return false; \ + } \ + } while (0) + +typedef struct { + uint64_t now_ns; + Lardon3DSsdProviderSnapshot observation; + size_t refresh_calls; + size_t start_calls; + size_t stop_calls; + size_t mount_calls; + size_t unmount_calls; + bool fail_refresh; + bool fail_next_refresh; + bool fail_start; + bool fail_stop; + bool fail_mount; + bool fail_unmount; + bool fail_refresh_after_start; + bool fail_refresh_after_mount; + bool start_effect_on_failure; + bool mount_effect_on_failure; + bool start_without_effect; + bool stop_without_effect; + bool mount_without_effect; + bool unmount_without_effect; + char failure_reason[LARDON3D_SSD_REASON_CAPACITY]; + char mount_result[LARDON3D_SSD_PATH_CAPACITY]; + bool destroyed; +} FakeProvider; + +static void text_copy(char *destination, size_t capacity, const char *source) { + (void)snprintf(destination, capacity, "%s", source ? source : ""); +} + +static void fake_make_valid(FakeProvider *fake) { + memset(fake, 0, sizeof(*fake)); + fake->now_ns = UINT64_C(10000000000); + text_copy(fake->failure_reason, sizeof(fake->failure_reason), "injected timeout"); + text_copy( + fake->mount_result, + sizeof(fake->mount_result), + LARDON3D_SSD_SCRATCH_MOUNT_PATH + ); + + fake->observation.model_known = true; + fake->observation.serial_known = true; + fake->observation.connection_speed_known = true; + text_copy(fake->observation.model, sizeof(fake->observation.model), "Test SSD"); + text_copy(fake->observation.serial, sizeof(fake->observation.serial), "TEST-SERIAL"); + fake->observation.connection_speed_mbps = 10000; + + fake->observation.swap.present = true; + fake->observation.swap.unit_ready = true; + fake->observation.swap.interface_available = true; + fake->observation.swap.active_known = true; + fake->observation.swap.size_bytes = 8 * GIB; + text_copy( + fake->observation.swap.label, + sizeof(fake->observation.swap.label), + LARDON3D_SSD_SWAP_LABEL + ); + text_copy( + fake->observation.swap.uuid, + sizeof(fake->observation.swap.uuid), + "00000000-0000-0000-0000-000000000001" + ); + text_copy( + fake->observation.swap.drive_identity, + sizeof(fake->observation.swap.drive_identity), + "/org/freedesktop/UDisks2/drives/test_drive" + ); + text_copy( + fake->observation.swap.object_path, + sizeof(fake->observation.swap.object_path), + "/org/freedesktop/UDisks2/block_devices/sdz1" + ); + text_copy( + fake->observation.swap.device, + sizeof(fake->observation.swap.device), + "/dev/sdz1" + ); + fake->observation.swap.total_known = true; + fake->observation.swap.used_known = true; + fake->observation.swap.total_bytes = 8 * GIB; + + fake->observation.scratch.present = true; + fake->observation.scratch.unit_ready = true; + fake->observation.scratch.interface_available = true; + fake->observation.scratch.size_bytes = 400 * GIB; + text_copy( + fake->observation.scratch.label, + sizeof(fake->observation.scratch.label), + LARDON3D_SSD_SCRATCH_LABEL + ); + text_copy( + fake->observation.scratch.uuid, + sizeof(fake->observation.scratch.uuid), + "00000000-0000-0000-0000-000000000002" + ); + text_copy( + fake->observation.scratch.drive_identity, + sizeof(fake->observation.scratch.drive_identity), + "/org/freedesktop/UDisks2/drives/test_drive" + ); + text_copy( + fake->observation.scratch.object_path, + sizeof(fake->observation.scratch.object_path), + "/org/freedesktop/UDisks2/block_devices/sdz2" + ); + text_copy( + fake->observation.scratch.device, + sizeof(fake->observation.scratch.device), + "/dev/sdz2" + ); + fake->observation.scratch.total_known = true; + fake->observation.scratch.total_bytes = 400 * GIB; + + fake->observation.memory_available_known = true; + fake->observation.memory_available_bytes = 16 * GIB; + fake->observation.memory_pressure_known = true; + fake->observation.io_pressure_known = true; + fake->observation.swap_activity_known = true; +} + +static bool fake_now(void *context, uint64_t *now_ns) { + FakeProvider *fake = context; + *now_ns = fake->now_ns; + return true; +} + +static bool fake_refresh( + void *context, + Lardon3DSsdProviderSnapshot *snapshot, + char reason[LARDON3D_SSD_REASON_CAPACITY] +) { + FakeProvider *fake = context; + fake->refresh_calls += 1; + if (fake->fail_next_refresh) { + fake->fail_next_refresh = false; + text_copy(reason, LARDON3D_SSD_REASON_CAPACITY, fake->failure_reason); + return false; + } + if (fake->fail_refresh) { + text_copy(reason, LARDON3D_SSD_REASON_CAPACITY, fake->failure_reason); + return false; + } + *snapshot = fake->observation; + reason[0] = '\0'; + return true; +} + +static bool fake_check_object( + const char *actual, + const char *expected, + char reason[LARDON3D_SSD_REASON_CAPACITY] +) { + if (strcmp(actual, expected) == 0) { + return true; + } + text_copy(reason, LARDON3D_SSD_REASON_CAPACITY, "wrong object path"); + return false; +} + +static bool fake_start( + void *context, + const char *object_path, + char reason[LARDON3D_SSD_REASON_CAPACITY] +) { + FakeProvider *fake = context; + fake->start_calls += 1; + if (!fake_check_object( + object_path, + fake->observation.swap.object_path, + reason + )) { + return false; + } + if (!fake->start_without_effect + && (!fake->fail_start || fake->start_effect_on_failure)) { + fake->observation.swap.active = true; + } + if (fake->fail_refresh_after_start) { + fake->fail_next_refresh = true; + } + if (fake->fail_start) { + text_copy(reason, LARDON3D_SSD_REASON_CAPACITY, fake->failure_reason); + return false; + } + return true; +} + +static bool fake_stop( + void *context, + const char *object_path, + char reason[LARDON3D_SSD_REASON_CAPACITY] +) { + FakeProvider *fake = context; + fake->stop_calls += 1; + if (!fake_check_object( + object_path, + fake->observation.swap.object_path, + reason + )) { + return false; + } + if (fake->fail_stop) { + text_copy(reason, LARDON3D_SSD_REASON_CAPACITY, fake->failure_reason); + return false; + } + if (!fake->stop_without_effect) { + fake->observation.swap.active = false; + fake->observation.swap.used_bytes = 0; + } + return true; +} + +static bool fake_mount( + void *context, + const char *object_path, + char mount_path[LARDON3D_SSD_PATH_CAPACITY], + char reason[LARDON3D_SSD_REASON_CAPACITY] +) { + FakeProvider *fake = context; + fake->mount_calls += 1; + if (!fake_check_object( + object_path, + fake->observation.scratch.object_path, + reason + )) { + return false; + } + text_copy(mount_path, LARDON3D_SSD_PATH_CAPACITY, fake->mount_result); + if (!fake->mount_without_effect + && (!fake->fail_mount || fake->mount_effect_on_failure)) { + fake->observation.scratch.mounted = true; + fake->observation.scratch.free_known = true; + fake->observation.scratch.free_bytes = 350 * GIB; + text_copy( + fake->observation.scratch.mount_path, + sizeof(fake->observation.scratch.mount_path), + fake->mount_result + ); + } + if (fake->fail_refresh_after_mount) { + fake->fail_next_refresh = true; + } + if (fake->fail_mount) { + text_copy(reason, LARDON3D_SSD_REASON_CAPACITY, fake->failure_reason); + return false; + } + return true; +} + +static bool fake_unmount( + void *context, + const char *object_path, + char reason[LARDON3D_SSD_REASON_CAPACITY] +) { + FakeProvider *fake = context; + fake->unmount_calls += 1; + if (!fake_check_object( + object_path, + fake->observation.scratch.object_path, + reason + )) { + return false; + } + if (fake->fail_unmount) { + text_copy(reason, LARDON3D_SSD_REASON_CAPACITY, fake->failure_reason); + return false; + } + if (!fake->unmount_without_effect) { + fake->observation.scratch.mounted = false; + fake->observation.scratch.free_known = false; + fake->observation.scratch.mount_path[0] = '\0'; + } + return true; +} + +static void fake_destroy(void *context) { + FakeProvider *fake = context; + fake->destroyed = true; +} + +static const Lardon3DSsdProviderOps FAKE_OPS = { + .monotonic_now_ns = fake_now, + .refresh = fake_refresh, + .start_swap = fake_start, + .stop_swap = fake_stop, + .mount_scratch = fake_mount, + .unmount_scratch = fake_unmount, + .destroy = fake_destroy, +}; + +static Lardon3DSsdController *fake_controller(FakeProvider *fake) { + const Lardon3DSsdProvider provider = { + .ops = &FAKE_OPS, + .context = fake, + }; + return lardon3d_ssd_controller_create_with_provider( + provider, + UINT64_C(1000000000) + ); +} + +#ifdef LARDON3D_SSD_CONTROLLER_TESTING +static GVariant *fixture_block_properties( + const char *label, + const char *uuid, + const char *drive, + const char *device, + uint64_t size +) { + GVariantBuilder properties; + + g_variant_builder_init(&properties, G_VARIANT_TYPE_VARDICT); + g_variant_builder_add(&properties, "{sv}", "IdLabel", g_variant_new_string(label)); + g_variant_builder_add(&properties, "{sv}", "IdUUID", g_variant_new_string(uuid)); + g_variant_builder_add( + &properties, + "{sv}", + "Drive", + g_variant_new_object_path(drive) + ); + g_variant_builder_add( + &properties, + "{sv}", + "Device", + g_variant_new_bytestring(device) + ); + g_variant_builder_add(&properties, "{sv}", "Size", g_variant_new_uint64(size)); + return g_variant_builder_end(&properties); +} + +static GVariant *fixture_drive_interfaces(void) { + GVariantBuilder interfaces; + GVariantBuilder properties; + + g_variant_builder_init(&properties, G_VARIANT_TYPE_VARDICT); + g_variant_builder_add( + &properties, + "{sv}", + "MediaAvailable", + g_variant_new_boolean(true) + ); + g_variant_builder_add( + &properties, + "{sv}", + "Model", + g_variant_new_string("Fixture SSD") + ); + g_variant_builder_add( + &properties, + "{sv}", + "Serial", + g_variant_new_string("FIXTURE-SERIAL") + ); + + g_variant_builder_init(&interfaces, G_VARIANT_TYPE("a{sa{sv}}")); + g_variant_builder_add( + &interfaces, + "{s@a{sv}}", + "org.freedesktop.UDisks2.Drive", + g_variant_builder_end(&properties) + ); + return g_variant_builder_end(&interfaces); +} + +typedef enum { + FIXTURE_SWAP_ACTIVE_BOOLEAN = 0, + FIXTURE_SWAP_ACTIVE_MISSING, + FIXTURE_SWAP_ACTIVE_TEXT, +} FixtureSwapActiveMode; + +static GVariant *fixture_swap_interfaces( + const char *drive, + FixtureSwapActiveMode active_mode +) { + GVariantBuilder interfaces; + GVariantBuilder properties; + + g_variant_builder_init(&interfaces, G_VARIANT_TYPE("a{sa{sv}}")); + g_variant_builder_add( + &interfaces, + "{s@a{sv}}", + "org.freedesktop.UDisks2.Block", + fixture_block_properties( + LARDON3D_SSD_SWAP_LABEL, + "fixture-swap-uuid", + drive, + "/dev/fixture1", + 8 * GIB + ) + ); + + g_variant_builder_init(&properties, G_VARIANT_TYPE_VARDICT); + if (active_mode == FIXTURE_SWAP_ACTIVE_BOOLEAN) { + g_variant_builder_add( + &properties, + "{sv}", + "Active", + g_variant_new_boolean(true) + ); + } else if (active_mode == FIXTURE_SWAP_ACTIVE_TEXT) { + g_variant_builder_add( + &properties, + "{sv}", + "Active", + g_variant_new_string("true") + ); + } + g_variant_builder_add( + &interfaces, + "{s@a{sv}}", + "org.freedesktop.UDisks2.Swapspace", + g_variant_builder_end(&properties) + ); + return g_variant_builder_end(&interfaces); +} + +static GVariant *fixture_scratch_interfaces(const char *drive) { + GVariantBuilder interfaces; + GVariantBuilder properties; + GVariantBuilder mount_points; + + g_variant_builder_init(&interfaces, G_VARIANT_TYPE("a{sa{sv}}")); + g_variant_builder_add( + &interfaces, + "{s@a{sv}}", + "org.freedesktop.UDisks2.Block", + fixture_block_properties( + LARDON3D_SSD_SCRATCH_LABEL, + "fixture-scratch-uuid", + drive, + "/dev/fixture2", + 400 * GIB + ) + ); + + g_variant_builder_init(&mount_points, G_VARIANT_TYPE("aay")); + g_variant_builder_add_value( + &mount_points, + g_variant_new_bytestring(LARDON3D_SSD_SCRATCH_MOUNT_PATH) + ); + g_variant_builder_init(&properties, G_VARIANT_TYPE_VARDICT); + g_variant_builder_add( + &properties, + "{sv}", + "MountPoints", + g_variant_builder_end(&mount_points) + ); + g_variant_builder_add( + &interfaces, + "{s@a{sv}}", + "org.freedesktop.UDisks2.Filesystem", + g_variant_builder_end(&properties) + ); + return g_variant_builder_end(&interfaces); +} + +static GVariant *fixture_managed_objects(FixtureSwapActiveMode active_mode) { + static const char drive[] = "/org/freedesktop/UDisks2/drives/fixture_drive"; + GVariantBuilder objects; + + g_variant_builder_init(&objects, G_VARIANT_TYPE("a{oa{sa{sv}}}")); + g_variant_builder_add( + &objects, + "{o@a{sa{sv}}}", + drive, + fixture_drive_interfaces() + ); + g_variant_builder_add( + &objects, + "{o@a{sa{sv}}}", + "/org/freedesktop/UDisks2/block_devices/fixture1", + fixture_swap_interfaces(drive, active_mode) + ); + g_variant_builder_add( + &objects, + "{o@a{sa{sv}}}", + "/org/freedesktop/UDisks2/block_devices/fixture2", + fixture_scratch_interfaces(drive) + ); + return g_variant_ref_sink(g_variant_builder_end(&objects)); +} +#endif + +static bool snapshot_of( + Lardon3DSsdController *controller, + Lardon3DSsdSnapshot *snapshot +) { + return lardon3d_ssd_controller_get_snapshot(controller, snapshot); +} + +static bool snapshot_actions_are( + const Lardon3DSsdSnapshot *snapshot, + bool can_enable, + bool can_disable, + bool can_cancel_drain +) { + return snapshot && snapshot->can_enable == can_enable + && snapshot->can_disable == can_disable + && snapshot->can_cancel_drain == can_cancel_drain; +} + +static bool test_absent_cache_appearance_and_node_rename(void) { + FakeProvider fake; + Lardon3DSsdController *controller; + Lardon3DSsdSnapshot snapshot; + char stable_identity[LARDON3D_SSD_IDENTITY_CAPACITY]; + + fake_make_valid(&fake); + memset(&fake.observation, 0, sizeof(fake.observation)); + controller = fake_controller(&fake); + CHECK(controller != NULL); + CHECK(snapshot_of(controller, &snapshot)); + CHECK(snapshot.state == LARDON3D_SSD_ABSENT); + CHECK(snapshot_actions_are(&snapshot, false, false, false)); + CHECK(fake.refresh_calls == 1); + + fake_make_valid(&fake); + fake.refresh_calls = 1; + fake.now_ns += UINT64_C(500000000); + CHECK(snapshot_of(controller, &snapshot)); + CHECK(snapshot.state == LARDON3D_SSD_ABSENT); + CHECK(fake.refresh_calls == 1); + CHECK(lardon3d_ssd_controller_refresh(controller, true)); + CHECK(fake.refresh_calls == 2); + CHECK(snapshot_of(controller, &snapshot)); + CHECK(snapshot.state == LARDON3D_SSD_DETECTED); + CHECK(snapshot_actions_are(&snapshot, true, false, false)); + CHECK(snapshot.swap_detected && snapshot.scratch_detected); + CHECK(snapshot.swap_total_known && snapshot.scratch_total_known); + CHECK(snapshot.swap_partition_size_known && snapshot.scratch_partition_size_known); + CHECK(snapshot.swap_partition_size_bytes == 8 * GIB); + CHECK(snapshot.scratch_partition_size_bytes == 400 * GIB); + text_copy(stable_identity, sizeof(stable_identity), snapshot.drive_identity); + + text_copy( + fake.observation.swap.device, + sizeof(fake.observation.swap.device), + "/dev/sdy1" + ); + text_copy( + fake.observation.scratch.device, + sizeof(fake.observation.scratch.device), + "/dev/sdy2" + ); + CHECK(lardon3d_ssd_controller_refresh(controller, true)); + CHECK(snapshot_of(controller, &snapshot)); + CHECK(strcmp(snapshot.drive_identity, stable_identity) == 0); + CHECK(strcmp(snapshot.swap_device, "/dev/sdy1") == 0); + CHECK(strcmp(snapshot.scratch_device, "/dev/sdy2") == 0); + CHECK(lardon3d_ssd_controller_destroy(controller)); + CHECK(fake.destroyed); + return true; +} + +static bool test_bounded_udisks_storage_types(void) { +#ifdef LARDON3D_SSD_CONTROLLER_TESTING + GVariant *objects = fixture_managed_objects(FIXTURE_SWAP_ACTIVE_BOOLEAN); + Lardon3DSsdProviderSnapshot observation; + char reason[LARDON3D_SSD_REASON_CAPACITY] = {0}; + + /* GLib boolean storage is gboolean, not C17 bool. This raw fixture drives + * the production parser and guards the exact ABI-sized copy boundary. */ + CHECK(lardon3d_ssd_parse_managed_objects_for_test(objects, &observation, reason)); + CHECK(observation.swap.present && observation.scratch.present); + CHECK(observation.swap.active_known && observation.swap.active); + CHECK(observation.swap.unit_ready && observation.scratch.unit_ready); + CHECK(observation.scratch.mounted); + CHECK(!observation.scratch.total_known); + CHECK(!observation.scratch.free_known); + CHECK(strcmp( + observation.scratch.mount_path, + LARDON3D_SSD_SCRATCH_MOUNT_PATH + ) == 0); + CHECK(observation.model_known && observation.serial_known); + g_variant_unref(objects); +#endif + return true; +} + +#ifdef LARDON3D_SSD_CONTROLLER_TESTING +static bool check_invalid_swap_active_fixture(FixtureSwapActiveMode active_mode) { + GVariant *objects = fixture_managed_objects(active_mode); + Lardon3DSsdProviderSnapshot observation; + char reason[LARDON3D_SSD_REASON_CAPACITY] = {0}; + FakeProvider fake; + Lardon3DSsdController *controller; + Lardon3DSsdSnapshot snapshot; + + CHECK(lardon3d_ssd_parse_managed_objects_for_test(objects, &observation, reason)); + CHECK(observation.invalid_observation); + CHECK(!observation.swap.active_known); + CHECK(strstr(observation.invalid_reason, "exact boolean Active") != NULL); + g_variant_unref(objects); + + fake_make_valid(&fake); + fake.observation = observation; + controller = fake_controller(&fake); + CHECK(controller != NULL); + CHECK(snapshot_of(controller, &snapshot)); + CHECK(snapshot.state == LARDON3D_SSD_ERROR); + CHECK(strstr(snapshot.reason, "exact boolean Active") != NULL); + CHECK(lardon3d_ssd_controller_enable(controller) == LARDON3D_SSD_CONTROL_ERROR); + CHECK(fake.start_calls == 0 && fake.mount_calls == 0); + CHECK(lardon3d_ssd_controller_disable(controller) == LARDON3D_SSD_CONTROL_ERROR); + CHECK(fake.stop_calls == 0 && fake.unmount_calls == 0); + CHECK(snapshot_of(controller, &snapshot)); + CHECK(snapshot.state == LARDON3D_SSD_ERROR); + CHECK(snapshot.state != LARDON3D_SSD_SAFE_TO_UNPLUG); + CHECK(lardon3d_ssd_controller_destroy(controller)); + return true; +} +#endif + +static bool test_required_swap_active_boolean(void) { +#ifdef LARDON3D_SSD_CONTROLLER_TESTING + CHECK(check_invalid_swap_active_fixture(FIXTURE_SWAP_ACTIVE_MISSING)); + CHECK(check_invalid_swap_active_fixture(FIXTURE_SWAP_ACTIVE_TEXT)); +#endif + return true; +} + +static bool test_active_node_rename_and_identity_replacement(void) { + FakeProvider fake; + Lardon3DSsdController *controller; + Lardon3DSsdSnapshot snapshot; + + fake_make_valid(&fake); + controller = fake_controller(&fake); + CHECK(controller != NULL); + CHECK(lardon3d_ssd_controller_enable(controller) == LARDON3D_SSD_CONTROL_OK); + + text_copy( + fake.observation.swap.device, + sizeof(fake.observation.swap.device), + "/dev/renamed1" + ); + text_copy( + fake.observation.scratch.device, + sizeof(fake.observation.scratch.device), + "/dev/renamed2" + ); + CHECK(lardon3d_ssd_controller_refresh(controller, true)); + CHECK(snapshot_of(controller, &snapshot)); + CHECK(snapshot.state == LARDON3D_SSD_ENABLED); + CHECK(snapshot.scratch_allocations_allowed); + + text_copy( + fake.observation.swap.drive_identity, + sizeof(fake.observation.swap.drive_identity), + "/org/freedesktop/UDisks2/drives/replacement" + ); + text_copy( + fake.observation.scratch.drive_identity, + sizeof(fake.observation.scratch.drive_identity), + "/org/freedesktop/UDisks2/drives/replacement" + ); + text_copy( + fake.observation.swap.uuid, + sizeof(fake.observation.swap.uuid), + "replacement-swap-uuid" + ); + text_copy( + fake.observation.scratch.uuid, + sizeof(fake.observation.scratch.uuid), + "replacement-scratch-uuid" + ); + CHECK(lardon3d_ssd_controller_refresh(controller, true)); + CHECK(snapshot_of(controller, &snapshot)); + CHECK(snapshot.state == LARDON3D_SSD_ERROR); + CHECK(!snapshot.pairing_valid); + CHECK(snapshot_actions_are(&snapshot, false, false, false)); + CHECK(strstr(snapshot.reason, "Stable Drive/UUID pair changed") != NULL); + CHECK(lardon3d_ssd_controller_enable(controller) == LARDON3D_SSD_CONTROL_ERROR); + CHECK(lardon3d_ssd_controller_destroy(controller)); + return true; +} + +static void fake_replace_stable_pair(FakeProvider *fake) { + text_copy( + fake->observation.swap.drive_identity, + sizeof(fake->observation.swap.drive_identity), + "/org/freedesktop/UDisks2/drives/indeterminate_replacement" + ); + text_copy( + fake->observation.scratch.drive_identity, + sizeof(fake->observation.scratch.drive_identity), + "/org/freedesktop/UDisks2/drives/indeterminate_replacement" + ); + text_copy( + fake->observation.swap.uuid, + sizeof(fake->observation.swap.uuid), + "indeterminate-replacement-swap" + ); + text_copy( + fake->observation.scratch.uuid, + sizeof(fake->observation.scratch.uuid), + "indeterminate-replacement-scratch" + ); +} + +static bool test_indeterminate_start_ownership(void) { + FakeProvider fake; + Lardon3DSsdController *controller; + Lardon3DSsdProviderSnapshot original_after_action; + Lardon3DSsdSnapshot snapshot; + + fake_make_valid(&fake); + fake.fail_start = true; + fake.start_effect_on_failure = true; + fake.fail_refresh_after_start = true; + controller = fake_controller(&fake); + CHECK(controller != NULL); + + /* A timeout may follow a real side effect. The failed verification must + * preserve authority for this tuple even when discovery next sees a + * healthy-looking replacement. */ + CHECK(lardon3d_ssd_controller_enable(controller) == LARDON3D_SSD_CONTROL_ERROR); + CHECK(fake.start_calls == 1 && fake.mount_calls == 0); + CHECK(fake.observation.swap.active); + original_after_action = fake.observation; + + fake_replace_stable_pair(&fake); + CHECK(lardon3d_ssd_controller_refresh(controller, true)); + CHECK(snapshot_of(controller, &snapshot)); + CHECK(snapshot.state == LARDON3D_SSD_ERROR); + CHECK(strstr(snapshot.reason, "Stable Drive/UUID pair changed") != NULL); + CHECK(lardon3d_ssd_controller_enable(controller) == LARDON3D_SSD_CONTROL_ERROR); + CHECK(lardon3d_ssd_controller_disable(controller) == LARDON3D_SSD_CONTROL_ERROR); + CHECK(fake.start_calls == 1 && fake.mount_calls == 0); + CHECK(fake.stop_calls == 0 && fake.unmount_calls == 0); + + fake.observation = original_after_action; + CHECK(lardon3d_ssd_controller_refresh(controller, true)); + CHECK(snapshot_of(controller, &snapshot)); + CHECK(snapshot.state == LARDON3D_SSD_ERROR && snapshot.pairing_valid); + CHECK(snapshot_actions_are(&snapshot, false, true, false)); + CHECK(lardon3d_ssd_controller_disable(controller) == LARDON3D_SSD_CONTROL_OK); + CHECK(fake.stop_calls == 1 && fake.unmount_calls == 0); + CHECK(snapshot_of(controller, &snapshot)); + CHECK(snapshot.state == LARDON3D_SSD_SAFE_TO_UNPLUG); + CHECK(snapshot_actions_are(&snapshot, true, false, false)); + CHECK(lardon3d_ssd_controller_destroy(controller)); + return true; +} + +static bool test_indeterminate_mount_ownership(void) { + FakeProvider fake; + Lardon3DSsdController *controller; + Lardon3DSsdProviderSnapshot original_after_action; + Lardon3DSsdSnapshot snapshot; + + fake_make_valid(&fake); + fake.fail_mount = true; + fake.mount_effect_on_failure = true; + fake.fail_refresh_after_mount = true; + controller = fake_controller(&fake); + CHECK(controller != NULL); + + CHECK(lardon3d_ssd_controller_enable(controller) == LARDON3D_SSD_CONTROL_ERROR); + CHECK(fake.start_calls == 1 && fake.mount_calls == 1); + CHECK(fake.observation.swap.active && fake.observation.scratch.mounted); + original_after_action = fake.observation; + + fake_replace_stable_pair(&fake); + CHECK(lardon3d_ssd_controller_refresh(controller, true)); + CHECK(snapshot_of(controller, &snapshot)); + CHECK(snapshot.state == LARDON3D_SSD_ERROR); + CHECK(strstr(snapshot.reason, "Stable Drive/UUID pair changed") != NULL); + CHECK(lardon3d_ssd_controller_enable(controller) == LARDON3D_SSD_CONTROL_ERROR); + CHECK(lardon3d_ssd_controller_disable(controller) == LARDON3D_SSD_CONTROL_ERROR); + CHECK(fake.start_calls == 1 && fake.mount_calls == 1); + CHECK(fake.stop_calls == 0 && fake.unmount_calls == 0); + + fake.observation = original_after_action; + CHECK(lardon3d_ssd_controller_refresh(controller, true)); + CHECK(snapshot_of(controller, &snapshot)); + CHECK(snapshot.state == LARDON3D_SSD_ERROR && snapshot.pairing_valid); + CHECK(snapshot_actions_are(&snapshot, false, true, false)); + CHECK(lardon3d_ssd_controller_disable(controller) == LARDON3D_SSD_CONTROL_OK); + CHECK(fake.stop_calls == 1 && fake.unmount_calls == 1); + CHECK(snapshot_of(controller, &snapshot)); + CHECK(snapshot.state == LARDON3D_SSD_SAFE_TO_UNPLUG); + CHECK(snapshot_actions_are(&snapshot, true, false, false)); + CHECK(lardon3d_ssd_controller_destroy(controller)); + return true; +} + +static bool test_enable_lease_capacity_and_exact_release(void) { + FakeProvider fake; + Lardon3DSsdController *controller; + Lardon3DSsdSnapshot snapshot; + Lardon3DSsdScratchLease leases[LARDON3D_SSD_MAX_SCRATCH_LEASES] = {{0}}; + Lardon3DSsdScratchLease extra = {0}; + Lardon3DSsdScratchLease copied; + Lardon3DSsdScratchLease constructed; + FakeProvider foreign_fake; + Lardon3DSsdController *foreign_controller; + + fake_make_valid(&fake); + controller = fake_controller(&fake); + CHECK(controller != NULL); + CHECK(lardon3d_ssd_controller_enable(controller) == LARDON3D_SSD_CONTROL_OK); + CHECK(fake.start_calls == 1 && fake.mount_calls == 1); + CHECK(snapshot_of(controller, &snapshot)); + CHECK(snapshot.state == LARDON3D_SSD_ENABLED); + CHECK(snapshot_actions_are(&snapshot, false, true, false)); + CHECK(snapshot.swap_active && snapshot.scratch_mounted); + CHECK(strcmp(snapshot.scratch_mount_path, LARDON3D_SSD_SCRATCH_MOUNT_PATH) == 0); + + for (size_t index = 0; index < LARDON3D_SSD_MAX_SCRATCH_LEASES; ++index) { + CHECK(lardon3d_ssd_controller_acquire_scratch(controller, &leases[index])); + } + CHECK(!lardon3d_ssd_controller_acquire_scratch(controller, &extra)); + CHECK(snapshot_of(controller, &snapshot)); + CHECK(snapshot.state == LARDON3D_SSD_IN_USE); + CHECK(snapshot_actions_are(&snapshot, false, true, false)); + CHECK(snapshot.scratch_lease_count == LARDON3D_SSD_MAX_SCRATCH_LEASES); + CHECK(!snapshot.scratch_allocations_allowed); + /* Destruction with live capabilities is a non-mutating refusal; the + * original objects remain releasable through the still-valid controller. */ + CHECK(!lardon3d_ssd_controller_destroy(controller)); + + copied = leases[0]; + constructed = (Lardon3DSsdScratchLease) { + .opaque_controller = leases[0].opaque_controller, + .opaque_lease_id = leases[0].opaque_lease_id, + }; + /* Numeric bytes are observable for ABI purposes but are not ownership. + * Both distinct addresses must fail while the original lease is live. */ + CHECK(!lardon3d_ssd_controller_release_scratch(controller, &copied)); + CHECK(!lardon3d_ssd_controller_release_scratch(controller, &constructed)); + fake_make_valid(&foreign_fake); + foreign_controller = fake_controller(&foreign_fake); + CHECK(foreign_controller != NULL); + CHECK(lardon3d_ssd_controller_enable(foreign_controller) + == LARDON3D_SSD_CONTROL_OK); + CHECK(!lardon3d_ssd_controller_release_scratch(foreign_controller, &leases[0])); + CHECK(lardon3d_ssd_controller_destroy(foreign_controller)); + CHECK(snapshot_of(controller, &snapshot)); + CHECK(snapshot.scratch_lease_count == LARDON3D_SSD_MAX_SCRATCH_LEASES); + CHECK(lardon3d_ssd_controller_release_scratch(controller, &leases[0])); + CHECK(!lardon3d_ssd_controller_release_scratch(controller, &leases[0])); + CHECK(!lardon3d_ssd_controller_release_scratch(controller, &copied)); + CHECK(!lardon3d_ssd_controller_release_scratch(controller, &constructed)); + for (size_t index = 1; index < LARDON3D_SSD_MAX_SCRATCH_LEASES; ++index) { + CHECK(lardon3d_ssd_controller_release_scratch(controller, &leases[index])); + } + CHECK(snapshot_of(controller, &snapshot)); + CHECK(snapshot.state == LARDON3D_SSD_ENABLED); + CHECK(snapshot.scratch_lease_count == 0); + CHECK(snapshot.scratch_allocations_allowed); + CHECK(lardon3d_ssd_controller_destroy(controller)); + return true; +} + +static bool test_disable_unused_and_safe_disappearance(void) { + FakeProvider fake; + Lardon3DSsdController *controller; + Lardon3DSsdSnapshot snapshot; + + fake_make_valid(&fake); + controller = fake_controller(&fake); + CHECK(controller != NULL); + CHECK(lardon3d_ssd_controller_enable(controller) == LARDON3D_SSD_CONTROL_OK); + CHECK(lardon3d_ssd_controller_disable(controller) == LARDON3D_SSD_CONTROL_OK); + CHECK(fake.stop_calls == 1 && fake.unmount_calls == 1); + CHECK(snapshot_of(controller, &snapshot)); + CHECK(snapshot.state == LARDON3D_SSD_SAFE_TO_UNPLUG); + CHECK(!snapshot.swap_active && !snapshot.scratch_mounted); + + memset(&fake.observation, 0, sizeof(fake.observation)); + CHECK(lardon3d_ssd_controller_refresh(controller, true)); + CHECK(snapshot_of(controller, &snapshot)); + CHECK(snapshot.state == LARDON3D_SSD_ABSENT); + CHECK(strcmp(snapshot.swap_device, "UNKNOWN") == 0); + + /* A clean verified drain relinquishes physical ownership. Absence clears + * the safe latch, so a different healthy tuple starts a fresh lifecycle. */ + fake_make_valid(&fake); + text_copy( + fake.observation.swap.drive_identity, + sizeof(fake.observation.swap.drive_identity), + "/org/freedesktop/UDisks2/drives/replacement_after_safe" + ); + text_copy( + fake.observation.scratch.drive_identity, + sizeof(fake.observation.scratch.drive_identity), + "/org/freedesktop/UDisks2/drives/replacement_after_safe" + ); + text_copy( + fake.observation.swap.uuid, + sizeof(fake.observation.swap.uuid), + "replacement-after-safe-swap" + ); + text_copy( + fake.observation.scratch.uuid, + sizeof(fake.observation.scratch.uuid), + "replacement-after-safe-scratch" + ); + CHECK(lardon3d_ssd_controller_refresh(controller, true)); + CHECK(snapshot_of(controller, &snapshot)); + CHECK(snapshot.state == LARDON3D_SSD_DETECTED); + CHECK(strcmp( + snapshot.drive_identity, + "/org/freedesktop/UDisks2/drives/replacement_after_safe" + ) == 0); + CHECK(lardon3d_ssd_controller_destroy(controller)); + return true; +} + +static bool test_sticky_hazard_reconnect_and_verified_drain(void) { + FakeProvider fake; + Lardon3DSsdController *controller; + Lardon3DSsdSnapshot snapshot; + Lardon3DSsdScratchLease lease = {0}; + Lardon3DSsdProviderSnapshot original; + char original_drive[LARDON3D_SSD_IDENTITY_CAPACITY]; + char original_swap_uuid[LARDON3D_SSD_TEXT_CAPACITY]; + char original_scratch_uuid[LARDON3D_SSD_TEXT_CAPACITY]; + + fake_make_valid(&fake); + controller = fake_controller(&fake); + CHECK(controller != NULL); + CHECK(lardon3d_ssd_controller_enable(controller) == LARDON3D_SSD_CONTROL_OK); + CHECK(lardon3d_ssd_controller_acquire_scratch(controller, &lease)); + original = fake.observation; + text_copy( + original_drive, + sizeof(original_drive), + original.swap.drive_identity + ); + text_copy( + original_swap_uuid, + sizeof(original_swap_uuid), + original.swap.uuid + ); + text_copy( + original_scratch_uuid, + sizeof(original_scratch_uuid), + original.scratch.uuid + ); + + memset(&fake.observation, 0, sizeof(fake.observation)); + CHECK(lardon3d_ssd_controller_refresh(controller, true)); + CHECK(snapshot_of(controller, &snapshot)); + CHECK(snapshot.state == LARDON3D_SSD_ERROR); + CHECK(strstr(snapshot.reason, "disappeared") != NULL); + CHECK(strcmp(snapshot.drive_identity, original_drive) == 0); + CHECK(strcmp(snapshot.swap_uuid, original_swap_uuid) == 0); + CHECK(strcmp(snapshot.scratch_uuid, original_scratch_uuid) == 0); + CHECK(strcmp(snapshot.swap_device, "UNKNOWN") == 0); + + /* A second absent poll must not self-heal after the one-poll active flag + * has disappeared from ordinary discovery state. */ + CHECK(lardon3d_ssd_controller_refresh(controller, true)); + CHECK(snapshot_of(controller, &snapshot)); + CHECK(snapshot.state == LARDON3D_SSD_ERROR); + CHECK(strcmp(snapshot.drive_identity, original_drive) == 0); + + fake_make_valid(&fake); + text_copy( + fake.observation.swap.drive_identity, + sizeof(fake.observation.swap.drive_identity), + "/org/freedesktop/UDisks2/drives/unsafe_replacement" + ); + text_copy( + fake.observation.scratch.drive_identity, + sizeof(fake.observation.scratch.drive_identity), + "/org/freedesktop/UDisks2/drives/unsafe_replacement" + ); + text_copy( + fake.observation.swap.uuid, + sizeof(fake.observation.swap.uuid), + "unsafe-replacement-swap" + ); + text_copy( + fake.observation.scratch.uuid, + sizeof(fake.observation.scratch.uuid), + "unsafe-replacement-scratch" + ); + CHECK(lardon3d_ssd_controller_refresh(controller, true)); + CHECK(snapshot_of(controller, &snapshot)); + CHECK(snapshot.state == LARDON3D_SSD_ERROR); + CHECK(strstr(snapshot.reason, "Stable Drive/UUID pair changed") != NULL); + CHECK(strcmp(snapshot.drive_identity, original_drive) == 0); + CHECK(strcmp(snapshot.swap_device, "UNKNOWN") == 0); + CHECK(lardon3d_ssd_controller_refresh(controller, true)); + CHECK(snapshot_of(controller, &snapshot)); + CHECK(snapshot.state == LARDON3D_SSD_ERROR); + CHECK(strcmp(snapshot.drive_identity, original_drive) == 0); + + CHECK(lardon3d_ssd_controller_disable(controller) == LARDON3D_SSD_CONTROL_PENDING); + CHECK(lardon3d_ssd_controller_release_scratch(controller, &lease)); + CHECK(fake.stop_calls == 0 && fake.unmount_calls == 0); + CHECK(lardon3d_ssd_controller_disable(controller) == LARDON3D_SSD_CONTROL_ERROR); + CHECK(fake.stop_calls == 0 && fake.unmount_calls == 0); + + /* Reconnecting the exact original tuple restores control authority, but + * public state stays ERROR until the verified drain endpoint is reached. */ + fake.observation = original; + CHECK(lardon3d_ssd_controller_refresh(controller, true)); + CHECK(snapshot_of(controller, &snapshot)); + CHECK(snapshot.state == LARDON3D_SSD_ERROR); + CHECK(snapshot.pairing_valid); + CHECK(strstr(snapshot.reason, "verified drain") != NULL); + CHECK(lardon3d_ssd_controller_disable(controller) == LARDON3D_SSD_CONTROL_OK); + CHECK(fake.stop_calls == 1 && fake.unmount_calls == 1); + CHECK(snapshot_of(controller, &snapshot)); + CHECK(snapshot.state == LARDON3D_SSD_SAFE_TO_UNPLUG); + CHECK(!snapshot.swap_active && !snapshot.scratch_mounted); + CHECK(lardon3d_ssd_controller_destroy(controller)); + return true; +} + +static bool test_lease_blocks_drain_cancel_and_auto_resume(void) { + FakeProvider fake; + Lardon3DSsdController *controller; + Lardon3DSsdSnapshot snapshot; + Lardon3DSsdScratchLease lease = {0}; + Lardon3DSsdScratchLease rejected = {0}; + + fake_make_valid(&fake); + controller = fake_controller(&fake); + CHECK(controller != NULL); + CHECK(lardon3d_ssd_controller_enable(controller) == LARDON3D_SSD_CONTROL_OK); + CHECK(lardon3d_ssd_controller_acquire_scratch(controller, &lease)); + CHECK(lardon3d_ssd_controller_disable(controller) == LARDON3D_SSD_CONTROL_PENDING); + CHECK(fake.stop_calls == 0 && fake.unmount_calls == 0); + CHECK(!lardon3d_ssd_controller_acquire_scratch(controller, &rejected)); + CHECK(snapshot_of(controller, &snapshot)); + CHECK(snapshot.state == LARDON3D_SSD_DRAINING); + CHECK(snapshot_actions_are(&snapshot, false, false, true)); + CHECK(strstr(snapshot.reason, "lease") != NULL); + + CHECK(lardon3d_ssd_controller_cancel_drain(controller)); + CHECK(snapshot_of(controller, &snapshot)); + CHECK(snapshot.state == LARDON3D_SSD_IN_USE); + CHECK(!snapshot.drain_requested); + CHECK(snapshot_actions_are(&snapshot, false, true, false)); + CHECK(lardon3d_ssd_controller_disable(controller) == LARDON3D_SSD_CONTROL_PENDING); + CHECK(lardon3d_ssd_controller_release_scratch(controller, &lease)); + CHECK(fake.stop_calls == 1 && fake.unmount_calls == 1); + CHECK(snapshot_of(controller, &snapshot)); + CHECK(snapshot.state == LARDON3D_SSD_SAFE_TO_UNPLUG); + CHECK(lardon3d_ssd_controller_destroy(controller)); + return true; +} + +static bool test_used_swap_safety_and_pressure_blockers(void) { + FakeProvider fake; + Lardon3DSsdController *controller; + Lardon3DSsdSnapshot snapshot; + + fake_make_valid(&fake); + controller = fake_controller(&fake); + CHECK(controller != NULL); + CHECK(lardon3d_ssd_controller_enable(controller) == LARDON3D_SSD_CONTROL_OK); + fake.observation.memory_available_known = false; + CHECK(lardon3d_ssd_controller_disable(controller) == LARDON3D_SSD_CONTROL_OK); + CHECK(lardon3d_ssd_controller_destroy(controller)); + + fake_make_valid(&fake); + controller = fake_controller(&fake); + CHECK(controller != NULL); + CHECK(lardon3d_ssd_controller_enable(controller) == LARDON3D_SSD_CONTROL_OK); + fake.observation.swap.used_bytes = 2 * GIB; + fake.observation.memory_available_bytes = 6 * GIB; + CHECK(lardon3d_ssd_controller_disable(controller) == LARDON3D_SSD_CONTROL_OK); + CHECK(lardon3d_ssd_controller_destroy(controller)); + + fake_make_valid(&fake); + controller = fake_controller(&fake); + CHECK(controller != NULL); + CHECK(lardon3d_ssd_controller_enable(controller) == LARDON3D_SSD_CONTROL_OK); + fake.observation.swap.used_bytes = 4 * GIB; + fake.observation.memory_available_bytes = 6 * GIB; + CHECK(lardon3d_ssd_controller_disable(controller) == LARDON3D_SSD_CONTROL_PENDING); + CHECK(fake.stop_calls == 0); + CHECK(snapshot_of(controller, &snapshot)); + CHECK(snapshot.state == LARDON3D_SSD_DRAINING); + CHECK(strstr(snapshot.reason, "3 GiB") != NULL); + CHECK(lardon3d_ssd_controller_cancel_drain(controller)); + + fake.observation.swap.used_bytes = 0; + fake.observation.memory_pressure_elevated = true; + CHECK(lardon3d_ssd_controller_disable(controller) == LARDON3D_SSD_CONTROL_PENDING); + CHECK(snapshot_of(controller, &snapshot)); + CHECK(strstr(snapshot.reason, "memory PSI") != NULL); + CHECK(lardon3d_ssd_controller_cancel_drain(controller)); + + fake.observation.memory_pressure_elevated = false; + fake.observation.swap_pages_out_delta = 1; + CHECK(lardon3d_ssd_controller_disable(controller) == LARDON3D_SSD_CONTROL_PENDING); + CHECK(snapshot_of(controller, &snapshot)); + CHECK(strstr(snapshot.reason, "swap-in/out") != NULL); + CHECK(lardon3d_ssd_controller_cancel_drain(controller)); + CHECK(lardon3d_ssd_controller_destroy(controller)); + return true; +} + +static bool test_disappearance_and_invalid_discovery(void) { + FakeProvider fake; + Lardon3DSsdController *controller; + Lardon3DSsdSnapshot snapshot; + + fake_make_valid(&fake); + controller = fake_controller(&fake); + CHECK(controller != NULL); + CHECK(lardon3d_ssd_controller_enable(controller) == LARDON3D_SSD_CONTROL_OK); + memset(&fake.observation, 0, sizeof(fake.observation)); + CHECK(lardon3d_ssd_controller_refresh(controller, true)); + CHECK(snapshot_of(controller, &snapshot)); + CHECK(snapshot.state == LARDON3D_SSD_ERROR); + CHECK(strstr(snapshot.reason, "disappeared") != NULL); + CHECK(strcmp(snapshot.swap_device, "UNKNOWN") == 0); + CHECK(lardon3d_ssd_controller_destroy(controller)); + + fake_make_valid(&fake); + fake.observation.swap.size_bytes = 0; + controller = fake_controller(&fake); + CHECK(controller != NULL); + CHECK(snapshot_of(controller, &snapshot)); + CHECK(snapshot.state == LARDON3D_SSD_ERROR); + CHECK(strstr(snapshot.reason, "zero") != NULL); + CHECK(lardon3d_ssd_controller_destroy(controller)); + + fake_make_valid(&fake); + fake.observation.scratch.unit_ready = false; + controller = fake_controller(&fake); + CHECK(controller != NULL); + CHECK(snapshot_of(controller, &snapshot)); + CHECK(snapshot.state == LARDON3D_SSD_ERROR); + CHECK(strstr(snapshot.reason, "Unit Not Ready") != NULL); + CHECK(lardon3d_ssd_controller_destroy(controller)); + + fake_make_valid(&fake); + fake.observation.ambiguous_labels = true; + controller = fake_controller(&fake); + CHECK(controller != NULL); + CHECK(snapshot_of(controller, &snapshot)); + CHECK(snapshot.state == LARDON3D_SSD_ERROR); + CHECK(strstr(snapshot.reason, "Multiple partitions") != NULL); + CHECK(lardon3d_ssd_controller_destroy(controller)); + + fake_make_valid(&fake); + text_copy( + fake.observation.scratch.drive_identity, + sizeof(fake.observation.scratch.drive_identity), + "/org/freedesktop/UDisks2/drives/other_drive" + ); + controller = fake_controller(&fake); + CHECK(controller != NULL); + CHECK(snapshot_of(controller, &snapshot)); + CHECK(snapshot.state == LARDON3D_SSD_ERROR); + CHECK(strstr(snapshot.reason, "same UDisks Drive") != NULL); + CHECK(lardon3d_ssd_controller_destroy(controller)); + + fake_make_valid(&fake); + memset(&fake.observation.scratch, 0, sizeof(fake.observation.scratch)); + controller = fake_controller(&fake); + CHECK(controller != NULL); + CHECK(snapshot_of(controller, &snapshot)); + CHECK(snapshot.state == LARDON3D_SSD_DETECTED); + CHECK(strstr(snapshot.reason, "missing") != NULL); + CHECK(lardon3d_ssd_controller_destroy(controller)); + return true; +} + +static bool test_operation_errors_and_mount_mismatch(void) { + FakeProvider fake; + Lardon3DSsdController *controller; + Lardon3DSsdSnapshot snapshot; + + fake_make_valid(&fake); + fake.fail_start = true; + controller = fake_controller(&fake); + CHECK(controller != NULL); + CHECK(lardon3d_ssd_controller_enable(controller) == LARDON3D_SSD_CONTROL_ERROR); + CHECK(snapshot_of(controller, &snapshot)); + CHECK(snapshot.state == LARDON3D_SSD_ERROR); + CHECK(strstr(snapshot.reason, "injected timeout") != NULL); + CHECK(!snapshot.swap_active); + CHECK(lardon3d_ssd_controller_destroy(controller)); + + fake_make_valid(&fake); + fake.fail_mount = true; + controller = fake_controller(&fake); + CHECK(controller != NULL); + CHECK(lardon3d_ssd_controller_enable(controller) == LARDON3D_SSD_CONTROL_ERROR); + CHECK(snapshot_of(controller, &snapshot)); + CHECK(snapshot.state == LARDON3D_SSD_ERROR); + CHECK(snapshot.swap_active && !snapshot.scratch_mounted); + CHECK(lardon3d_ssd_controller_destroy(controller)); + + fake_make_valid(&fake); + text_copy(fake.mount_result, sizeof(fake.mount_result), "/run/media/test/LARDON_SCRATCH"); + controller = fake_controller(&fake); + CHECK(controller != NULL); + CHECK(lardon3d_ssd_controller_enable(controller) == LARDON3D_SSD_CONTROL_ERROR); + CHECK(snapshot_of(controller, &snapshot)); + CHECK(snapshot.state == LARDON3D_SSD_ERROR); + CHECK(snapshot.swap_active && snapshot.scratch_mounted); + CHECK(strcmp(snapshot.scratch_mount_path, "/run/media/test/LARDON_SCRATCH") == 0); + CHECK(strstr(snapshot.reason, "not '/mnt/lardon-scratch'") != NULL); + CHECK(lardon3d_ssd_controller_disable(controller) == LARDON3D_SSD_CONTROL_OK); + CHECK(snapshot_of(controller, &snapshot)); + CHECK(snapshot.state == LARDON3D_SSD_SAFE_TO_UNPLUG); + CHECK(lardon3d_ssd_controller_destroy(controller)); + + fake_make_valid(&fake); + controller = fake_controller(&fake); + CHECK(controller != NULL); + CHECK(lardon3d_ssd_controller_enable(controller) == LARDON3D_SSD_CONTROL_OK); + fake.fail_stop = true; + CHECK(lardon3d_ssd_controller_disable(controller) == LARDON3D_SSD_CONTROL_ERROR); + CHECK(snapshot_of(controller, &snapshot)); + CHECK(snapshot.state == LARDON3D_SSD_ERROR); + CHECK(snapshot.swap_active && snapshot.scratch_mounted); + CHECK(lardon3d_ssd_controller_destroy(controller)); + + fake_make_valid(&fake); + controller = fake_controller(&fake); + CHECK(controller != NULL); + CHECK(lardon3d_ssd_controller_enable(controller) == LARDON3D_SSD_CONTROL_OK); + fake.fail_unmount = true; + CHECK(lardon3d_ssd_controller_disable(controller) == LARDON3D_SSD_CONTROL_ERROR); + CHECK(snapshot_of(controller, &snapshot)); + CHECK(snapshot.state == LARDON3D_SSD_ERROR); + CHECK(!snapshot.swap_active && snapshot.scratch_mounted); + CHECK(strstr(snapshot.reason, "Unmount failed") != NULL); + CHECK(lardon3d_ssd_controller_destroy(controller)); + return true; +} + +static bool test_unknown_telemetry_is_explicit_and_blocks_safely(void) { + FakeProvider fake; + Lardon3DSsdController *controller; + Lardon3DSsdSnapshot snapshot; + + fake_make_valid(&fake); + fake.observation.model_known = false; + fake.observation.model[0] = '\0'; + fake.observation.serial_known = false; + fake.observation.serial[0] = '\0'; + fake.observation.connection_speed_known = false; + controller = fake_controller(&fake); + CHECK(controller != NULL); + CHECK(snapshot_of(controller, &snapshot)); + CHECK(!snapshot.model_known && strcmp(snapshot.model, "UNKNOWN") == 0); + CHECK(!snapshot.serial_known && strcmp(snapshot.serial, "UNKNOWN") == 0); + CHECK(!snapshot.connection_speed_known); + CHECK(lardon3d_ssd_controller_enable(controller) == LARDON3D_SSD_CONTROL_OK); + + fake.observation.swap.used_known = false; + CHECK(lardon3d_ssd_controller_disable(controller) == LARDON3D_SSD_CONTROL_PENDING); + CHECK(snapshot_of(controller, &snapshot)); + CHECK(strstr(snapshot.reason, "used bytes are unknown") != NULL); + CHECK(lardon3d_ssd_controller_cancel_drain(controller)); + + fake.observation.swap.used_known = true; + fake.observation.memory_pressure_known = false; + CHECK(lardon3d_ssd_controller_disable(controller) == LARDON3D_SSD_CONTROL_PENDING); + CHECK(snapshot_of(controller, &snapshot)); + CHECK(strstr(snapshot.reason, "PSI evidence is unknown") != NULL); + CHECK(lardon3d_ssd_controller_cancel_drain(controller)); + + fake.observation.memory_pressure_known = true; + fake.observation.swap_activity_known = false; + CHECK(lardon3d_ssd_controller_disable(controller) == LARDON3D_SSD_CONTROL_PENDING); + CHECK(snapshot_of(controller, &snapshot)); + CHECK(strstr(snapshot.reason, "delta interval") != NULL); + CHECK(lardon3d_ssd_controller_cancel_drain(controller)); + CHECK(lardon3d_ssd_controller_destroy(controller)); + return true; +} + +static bool test_success_without_effect_and_refresh_error(void) { + FakeProvider fake; + Lardon3DSsdController *controller; + Lardon3DSsdSnapshot snapshot; + + fake_make_valid(&fake); + fake.start_without_effect = true; + controller = fake_controller(&fake); + CHECK(controller != NULL); + CHECK(lardon3d_ssd_controller_enable(controller) == LARDON3D_SSD_CONTROL_ERROR); + CHECK(snapshot_of(controller, &snapshot)); + CHECK(strstr(snapshot.reason, "not active") != NULL); + CHECK(lardon3d_ssd_controller_destroy(controller)); + + fake_make_valid(&fake); + controller = fake_controller(&fake); + CHECK(controller != NULL); + fake.fail_refresh = true; + CHECK(!lardon3d_ssd_controller_refresh(controller, true)); + CHECK(!snapshot_of(controller, &snapshot)); + CHECK(snapshot.state == LARDON3D_SSD_ERROR); + CHECK(strstr(snapshot.reason, "injected timeout") != NULL); + CHECK(strcmp(snapshot.swap_device, "UNKNOWN") == 0); + CHECK(lardon3d_ssd_controller_destroy(controller)); + return true; +} + +typedef struct { + Lardon3DSsdController *controller; + bool success; +} LeaseThread; + +typedef struct { + Lardon3DSsdController *controller; + Lardon3DSsdScratchLease *lease; + pthread_barrier_t *barrier; + bool acquire; + bool success; +} SharedLeaseThread; + +static void *shared_lease_call_thread(void *userdata) { + SharedLeaseThread *thread = userdata; + + (void)pthread_barrier_wait(thread->barrier); + thread->success = thread->acquire + ? lardon3d_ssd_controller_acquire_scratch(thread->controller, thread->lease) + : lardon3d_ssd_controller_release_scratch(thread->controller, thread->lease); + return NULL; +} + +static void *lease_stress_thread(void *userdata) { + LeaseThread *thread = userdata; + thread->success = true; + for (size_t iteration = 0; iteration < 250; ++iteration) { + Lardon3DSsdScratchLease lease = {0}; + while (!lardon3d_ssd_controller_acquire_scratch(thread->controller, &lease)) { + sched_yield(); + } + if (!lardon3d_ssd_controller_release_scratch(thread->controller, &lease)) { + thread->success = false; + return NULL; + } + } + return NULL; +} + +static bool test_concurrent_lease_accounting(void) { + enum { THREAD_COUNT = 8 }; + FakeProvider fake; + Lardon3DSsdController *controller; + pthread_t threads[THREAD_COUNT]; + LeaseThread work[THREAD_COUNT]; + Lardon3DSsdSnapshot snapshot; + + fake_make_valid(&fake); + controller = fake_controller(&fake); + CHECK(controller != NULL); + CHECK(lardon3d_ssd_controller_enable(controller) == LARDON3D_SSD_CONTROL_OK); + for (size_t index = 0; index < THREAD_COUNT; ++index) { + work[index].controller = controller; + work[index].success = false; + CHECK(pthread_create(&threads[index], NULL, lease_stress_thread, &work[index]) == 0); + } + for (size_t index = 0; index < THREAD_COUNT; ++index) { + CHECK(pthread_join(threads[index], NULL) == 0); + CHECK(work[index].success); + } + CHECK(snapshot_of(controller, &snapshot)); + CHECK(snapshot.scratch_lease_count == 0); + CHECK(snapshot.state == LARDON3D_SSD_ENABLED); + CHECK(lardon3d_ssd_controller_destroy(controller)); + return true; +} + +static bool test_same_object_lease_serialization(void) { + FakeProvider fake; + Lardon3DSsdController *controller; + Lardon3DSsdScratchLease lease = {0}; + Lardon3DSsdScratchLease saved; + Lardon3DSsdSnapshot snapshot; + pthread_barrier_t barrier; + pthread_t threads[2]; + SharedLeaseThread work[2]; + + fake_make_valid(&fake); + controller = fake_controller(&fake); + CHECK(controller != NULL); + CHECK(lardon3d_ssd_controller_enable(controller) == LARDON3D_SSD_CONTROL_OK); + + CHECK(pthread_barrier_init(&barrier, NULL, 2) == 0); + for (size_t index = 0; index < 2; ++index) { + work[index] = (SharedLeaseThread) { + .controller = controller, + .lease = &lease, + .barrier = &barrier, + .acquire = true, + .success = false, + }; + CHECK(pthread_create( + &threads[index], + NULL, + shared_lease_call_thread, + &work[index] + ) == 0); + } + for (size_t index = 0; index < 2; ++index) { + CHECK(pthread_join(threads[index], NULL) == 0); + } + CHECK(pthread_barrier_destroy(&barrier) == 0); + CHECK(work[0].success != work[1].success); + CHECK(snapshot_of(controller, &snapshot)); + CHECK(snapshot.scratch_lease_count == 1); + CHECK(!lardon3d_ssd_controller_destroy(controller)); + + saved = lease; + memset(&lease, 0, sizeof(lease)); + /* This intentionally violates the public no-reconstruction rule. The + * address registry must still prevent a second live capability. */ + CHECK(!lardon3d_ssd_controller_acquire_scratch(controller, &lease)); + lease = saved; + CHECK(!lardon3d_ssd_controller_acquire_scratch(controller, &lease)); + + CHECK(pthread_barrier_init(&barrier, NULL, 2) == 0); + for (size_t index = 0; index < 2; ++index) { + work[index] = (SharedLeaseThread) { + .controller = controller, + .lease = &lease, + .barrier = &barrier, + .acquire = false, + .success = false, + }; + CHECK(pthread_create( + &threads[index], + NULL, + shared_lease_call_thread, + &work[index] + ) == 0); + } + for (size_t index = 0; index < 2; ++index) { + CHECK(pthread_join(threads[index], NULL) == 0); + } + CHECK(pthread_barrier_destroy(&barrier) == 0); + CHECK(work[0].success != work[1].success); + CHECK(lease.opaque_controller == 0 && lease.opaque_lease_id == 0); + CHECK(!lardon3d_ssd_controller_release_scratch(controller, &lease)); + CHECK(snapshot_of(controller, &snapshot)); + CHECK(snapshot.scratch_lease_count == 0); + CHECK(lardon3d_ssd_controller_destroy(controller)); + return true; +} + +static bool test_public_invalid_arguments_and_names(void) { + Lardon3DSsdScratchLease lease = {0}; + Lardon3DSsdSnapshot snapshot; + + CHECK(lardon3d_ssd_controller_destroy(NULL)); + CHECK(!lardon3d_ssd_controller_refresh(NULL, false)); + memset(&snapshot, 0xa5, sizeof(snapshot)); + CHECK(!lardon3d_ssd_controller_get_snapshot(NULL, &snapshot)); + CHECK(snapshot.state == LARDON3D_SSD_ABSENT); + CHECK(strcmp(snapshot.drive_identity, "UNKNOWN") == 0); + CHECK(snapshot.scratch_lease_capacity == LARDON3D_SSD_MAX_SCRATCH_LEASES); + CHECK(!lardon3d_ssd_controller_get_snapshot(NULL, NULL)); + memset(&snapshot, 0xa5, sizeof(snapshot)); + CHECK(!lardon3d_ssd_controller_copy_snapshot(NULL, &snapshot)); + CHECK(snapshot.state == LARDON3D_SSD_ABSENT); + CHECK(snapshot.scratch_lease_capacity == LARDON3D_SSD_MAX_SCRATCH_LEASES); + CHECK(!lardon3d_ssd_controller_copy_snapshot(NULL, NULL)); + CHECK(!lardon3d_ssd_controller_acquire_scratch(NULL, &lease)); + CHECK(!lardon3d_ssd_controller_release_scratch(NULL, &lease)); + CHECK(strcmp(lardon3d_ssd_state_name(LARDON3D_SSD_ABSENT), "ABSENT") == 0); + CHECK(strcmp(lardon3d_ssd_state_name(LARDON3D_SSD_ERROR), "ERROR") == 0); + CHECK(strcmp(lardon3d_ssd_state_name((Lardon3DSsdState)99), "UNKNOWN") == 0); + return true; +} + +int main(void) { + const struct { + const char *name; + bool (*run)(void); + } tests[] = { + {"absent/cache/appearance/node rename", test_absent_cache_appearance_and_node_rename}, + {"bounded UDisks storage types", test_bounded_udisks_storage_types}, + {"required Swapspace.Active boolean", test_required_swap_active_boolean}, + {"active rename/identity replacement", test_active_node_rename_and_identity_replacement}, + {"indeterminate Start ownership", test_indeterminate_start_ownership}, + {"indeterminate Mount ownership", test_indeterminate_mount_ownership}, + {"enable/lease capacity/exact release", test_enable_lease_capacity_and_exact_release}, + {"disable unused/safe disappearance", test_disable_unused_and_safe_disappearance}, + {"sticky hazard/reconnect/verified drain", test_sticky_hazard_reconnect_and_verified_drain}, + {"lease drain/cancel/auto resume", test_lease_blocks_drain_cancel_and_auto_resume}, + {"used swap and pressure safety", test_used_swap_safety_and_pressure_blockers}, + {"disappearance/invalid discovery", test_disappearance_and_invalid_discovery}, + {"operation errors/mount mismatch", test_operation_errors_and_mount_mismatch}, + {"explicit unknown/safe blocking", test_unknown_telemetry_is_explicit_and_blocks_safely}, + {"operation verification/refresh error", test_success_without_effect_and_refresh_error}, + {"concurrent lease accounting", test_concurrent_lease_accounting}, + {"same-object lease serialization", test_same_object_lease_serialization}, + {"public invalid arguments/names", test_public_invalid_arguments_and_names}, + }; + + for (size_t index = 0; index < sizeof(tests) / sizeof(tests[0]); ++index) { + if (!tests[index].run()) { + fprintf(stderr, "FAILED: %s\n", tests[index].name); + return 1; + } + printf("PASS: %s\n", tests[index].name); + } + return 0; +} diff --git a/tests/test_task.c b/tests/test_task.c index 182b1a5..37fcb6a 100644 --- a/tests/test_task.c +++ b/tests/test_task.c @@ -80,6 +80,14 @@ failure_callback(Lardon3DTask *task, void *userdata) return lardon3d_task_fail(task, "Erreur contrôlée.") && false; } +static bool +success_callback(Lardon3DTask *task, void *userdata) +{ + (void)task; + (void)userdata; + return true; +} + typedef struct { Lardon3DResourceGovernor *governor; bool sequence_rejected; @@ -190,12 +198,9 @@ run_test(void) CHECK(!lardon3d_task_join(NULL)); Work work = {.steps = 100, .pause_ns = 1000000}; - Lardon3DTask *task = lardon3d_task_create( - "Tâche de test", - &estimate, - work_callback, - &work - ); + Lardon3DTask *task = lardon3d_task_create_typed( + "Tâche de test", &estimate, "test.durable", 1, + work_callback, &work, NULL); CHECK(task); FinishProbe completed_probe = {.governor = governor}; CHECK(lardon3d_task_set_finished_callback(task, finished_callback, @@ -204,10 +209,39 @@ run_test(void) CHECK(!lardon3d_task_assign_id(task, 43)); CHECK(lardon3d_task_id(task) == 42); Lardon3DTaskSnapshot snapshot; + Lardon3DTaskObservation observation = {.id = UINT64_MAX}; + CHECK(!lardon3d_task_observation(NULL, &observation)); + CHECK(observation.id == 0 && !observation.has_task_kind + && !observation.durable_progress_known + && !observation.has_execution_contract); CHECK(lardon3d_task_snapshot(task, &snapshot)); CHECK(snapshot.state == TASK_PENDING); CHECK(snapshot.progress == 0); CHECK(strcmp(snapshot.name, "Tâche de test") == 0); + CHECK(!lardon3d_task_set_durable_progress(task, 1, 0, NULL)); + CHECK(!lardon3d_task_set_durable_progress(task, 8, 7, NULL)); + CHECK(lardon3d_task_set_durable_progress( + task, 2, 7, "Préfixe durable.")); + CHECK(lardon3d_task_observation(task, &observation)); + CHECK(observation.durable_progress_known); + CHECK(observation.durable_completed == 2 + && observation.durable_total == 7); + CHECK(observation.progress == 28); + CHECK(lardon3d_task_set_durable_progress( + task, UINT64_MAX - 1, UINT64_MAX, NULL)); + CHECK(lardon3d_task_observation(task, &observation)); + CHECK(observation.durable_completed == UINT64_MAX - 1); + CHECK(observation.durable_total == UINT64_MAX); + CHECK(observation.progress == 99); + CHECK(lardon3d_task_set_durable_progress( + task, UINT64_MAX, UINT64_MAX, NULL)); + CHECK(lardon3d_task_observation(task, &observation)); + CHECK(observation.progress == 100); + CHECK(lardon3d_task_set_progress(task, 0, "Réinitialisé.")); + CHECK(lardon3d_task_observation(task, &observation)); + CHECK(!observation.durable_progress_known); + CHECK(observation.durable_completed == 0 + && observation.durable_total == 0); pthread_t thread; CHECK(lardon3d_test_resource_snapshot_make_fresh(&resource_snapshot)); @@ -243,6 +277,27 @@ run_test(void) CHECK(!lardon3d_task_start(task, NULL, NULL)); lardon3d_task_destroy(task); + task = lardon3d_task_create( + "Tâche sans type", &estimate, success_callback, NULL); + CHECK(task); + CHECK(!lardon3d_task_set_durable_progress(task, 1, 2, NULL)); + lardon3d_task_destroy(task); + + task = lardon3d_task_create_typed( + "Préfixe partiel", &estimate, "test.partial", 1, + success_callback, NULL, NULL); + CHECK(task && lardon3d_task_set_durable_progress(task, 2, 7, NULL)); + CHECK(lardon3d_test_resource_snapshot_make_fresh(&resource_snapshot)); + CHECK(lardon3d_resource_governor_reserve( + governor, &resource_snapshot, &estimate, &decision, &reservation)); + CHECK(lardon3d_task_start(task, governor, reservation)); + CHECK(lardon3d_task_observation(task, &observation)); + CHECK(observation.state == TASK_COMPLETED && observation.progress == 100); + CHECK(observation.durable_progress_known + && observation.durable_completed == 2 + && observation.durable_total == 7); + lardon3d_task_destroy(task); + work = (Work) {.steps = 1000, .pause_ns = 1000000}; task = lardon3d_task_create("Annulation", &estimate, work_callback, &work); CHECK(task); @@ -335,6 +390,12 @@ run_test(void) CHECK(task && lardon3d_task_internal_set_capability_envelope( task, &adaptive_envelope)); + CHECK(lardon3d_task_observation(task, &observation)); + CHECK(observation.has_task_kind); + CHECK(strcmp(observation.task_kind, "test.direct.adaptive") == 0); + CHECK(observation.task_kind_version == 1); + CHECK(observation.sequence_count == 0); + CHECK(!observation.has_execution_contract); CHECK(lardon3d_test_resource_snapshot_make_fresh(&resource_snapshot)); CHECK(lardon3d_resource_governor_reserve( governor, &resource_snapshot, &adaptive_estimate, &decision, diff --git a/tests/test_task_kind_registry.c b/tests/test_task_kind_registry.c index 224d1f5..2e64fa1 100644 --- a/tests/test_task_kind_registry.c +++ b/tests/test_task_kind_registry.c @@ -1,3 +1,4 @@ +#include #include #include #include @@ -115,19 +116,42 @@ snapshot(void) return result; } +static bool +same_estimate(const Lardon3DResourceEstimate *left, + const Lardon3DResourceEstimate *right) +{ + return left->memory_fixed_bytes == right->memory_fixed_bytes + && left->gpu_memory_fixed_bytes == right->gpu_memory_fixed_bytes + && left->memory_bytes_per_item == right->memory_bytes_per_item + && left->gpu_memory_bytes_per_item == right->gpu_memory_bytes_per_item + && left->minimum_batch_size == right->minimum_batch_size + && left->maximum_batch_size == right->maximum_batch_size + && left->desired_cpu_threads == right->desired_cpu_threads + && left->desired_gpu_slots == right->desired_gpu_slots + && left->desired_io_slots == right->desired_io_slots + && left->task_class == right->task_class; +} + static bool run_legacy_estimate_validation_test(void) { static const Lardon3DTaskKindDescriptor descriptors[] = { {.kind = "candidate_pair.generate", .kind_version = 1, .reconstruct = reconstruct}, + {.kind = "features.extract", .kind_version = 1, + .reconstruct = reconstruct}, {.kind = "features.extract.sift", .kind_version = 1, .reconstruct = reconstruct}, {.kind = "features.extract.rootsift", .kind_version = 1, .reconstruct = reconstruct}, + {.kind = "visual_index.update", .kind_version = 1, + .reconstruct = reconstruct}, + {.kind = "geometric_verifier.run", .kind_version = 1, + .reconstruct = reconstruct}, }; Lardon3DTaskKindRegistry registry; - CHECK(lardon3d_task_kind_registry_init(®istry, descriptors, 3)); + CHECK(lardon3d_task_kind_registry_init( + ®istry, descriptors, sizeof(descriptors) / sizeof(descriptors[0]))); int destroyed = 0; int finished = 0; ReconstructContext context = { @@ -136,10 +160,13 @@ run_legacy_estimate_validation_test(void) }; const char *kinds[] = { "candidate_pair.generate", + "features.extract", "features.extract.sift", "features.extract.rootsift", + "visual_index.update", + "geometric_verifier.run", }; - for (size_t index = 0; index < 3; ++index) { + for (size_t index = 0; index < sizeof(kinds) / sizeof(kinds[0]); ++index) { Lardon3DTaskDurableSnapshot corrupt = snapshot(); corrupt.estimate = (Lardon3DResourceEstimate) {0}; Lardon3DTask *task = NULL; @@ -152,48 +179,139 @@ run_legacy_estimate_validation_test(void) CHECK(!task && destroyed == (int)index + 1); } - const Lardon3DResourceEstimate historical[] = { - { - .memory_fixed_bytes = 128 * 1024, - .memory_bytes_per_item = 64 * 1024, - .minimum_batch_size = 1, - .maximum_batch_size = 64, - .desired_cpu_threads = 1, - .desired_io_slots = 1, - .task_class = LARDON3D_RESOURCE_TASK_CPU, - }, - { - .memory_fixed_bytes = 64ULL * 1024 * 1024, - .memory_bytes_per_item = 1024ULL * 1024 * 1024, - .minimum_batch_size = 1, - .maximum_batch_size = 1, - .desired_cpu_threads = 1, - .desired_io_slots = 1, - .task_class = LARDON3D_RESOURCE_TASK_CPU, - }, - { - .memory_fixed_bytes = 64ULL * 1024 * 1024, - .memory_bytes_per_item = 1024ULL * 1024 * 1024, - .minimum_batch_size = 1, - .maximum_batch_size = 1, - .desired_cpu_threads = 1, - .desired_io_slots = 1, - .task_class = LARDON3D_RESOURCE_TASK_CPU, - }, + typedef struct { + const char *kind; + Lardon3DResourceEstimate durable; + Lardon3DResourceEstimate effective; + } EstimateCase; + const Lardon3DResourceEstimate candidate_current = { + .memory_fixed_bytes = 256 * 1024, + .memory_bytes_per_item = 8 * 1024 * 1024, + .minimum_batch_size = 1, + .maximum_batch_size = 64, + .desired_cpu_threads = 64, + .desired_io_slots = 1, + .task_class = LARDON3D_RESOURCE_TASK_CPU, }; - for (size_t index = 0; index < 3; ++index) { + const Lardon3DResourceEstimate feature_current = { + .memory_fixed_bytes = 64ULL * 1024 * 1024, + .memory_bytes_per_item = 512ULL * 1024 * 1024, + .minimum_batch_size = 1, + .maximum_batch_size = 1, + .desired_cpu_threads = INT_MAX, + .desired_io_slots = 1, + .task_class = LARDON3D_RESOURCE_TASK_CPU, + }; + const Lardon3DResourceEstimate sift_current = { + .memory_fixed_bytes = 64ULL * 1024 * 1024, + .memory_bytes_per_item = 1024ULL * 1024 * 1024, + .minimum_batch_size = 1, + .maximum_batch_size = 1, + .desired_cpu_threads = INT_MAX, + .desired_io_slots = 1, + .task_class = LARDON3D_RESOURCE_TASK_CPU, + }; + const Lardon3DResourceEstimate visual_current = { + .memory_fixed_bytes = 8ULL * 1024 * 1024, + .memory_bytes_per_item = 2ULL * 1024 * 1024, + .minimum_batch_size = 1, + .maximum_batch_size = 16, + .desired_cpu_threads = 16, + .desired_io_slots = 1, + .task_class = LARDON3D_RESOURCE_TASK_CPU, + }; + const Lardon3DResourceEstimate geometric_current = { + .memory_bytes_per_item = 8ULL * 1024 * 1024, + .minimum_batch_size = 1, + .maximum_batch_size = 16, + .desired_cpu_threads = 8, + .desired_io_slots = 1, + .task_class = LARDON3D_RESOURCE_TASK_CPU, + }; + EstimateCase accepted[] = { + {.kind = "candidate_pair.generate", + .durable = candidate_current, .effective = candidate_current}, + {.kind = "candidate_pair.generate", + .durable = candidate_current, .effective = candidate_current}, + {.kind = "candidate_pair.generate", + .durable = candidate_current, .effective = candidate_current}, + {.kind = "features.extract", + .durable = feature_current, .effective = feature_current}, + {.kind = "features.extract", + .durable = feature_current, .effective = feature_current}, + {.kind = "features.extract", + .durable = feature_current, .effective = feature_current}, + {.kind = "features.extract.sift", + .durable = sift_current, .effective = sift_current}, + {.kind = "features.extract.sift", + .durable = sift_current, .effective = sift_current}, + {.kind = "features.extract.sift", + .durable = sift_current, .effective = sift_current}, + {.kind = "features.extract.rootsift", + .durable = sift_current, .effective = sift_current}, + {.kind = "features.extract.rootsift", + .durable = sift_current, .effective = sift_current}, + {.kind = "features.extract.rootsift", + .durable = sift_current, .effective = sift_current}, + {.kind = "visual_index.update", + .durable = visual_current, .effective = visual_current}, + {.kind = "visual_index.update", + .durable = visual_current, .effective = visual_current}, + {.kind = "visual_index.update", + .durable = visual_current, .effective = visual_current}, + {.kind = "geometric_verifier.run", + .durable = geometric_current, .effective = geometric_current}, + {.kind = "geometric_verifier.run", + .durable = geometric_current, .effective = geometric_current}, + }; + /* The first five groups contain current, immediately preceding, and oldest + * exact signatures. GV has only current plus its frozen serial envelope. + * No neighboring estimate is accepted. */ + accepted[1].durable.memory_bytes_per_item = 64 * 1024; + accepted[1].durable.desired_cpu_threads = 12; + accepted[2].durable = accepted[1].durable; + accepted[2].durable.memory_fixed_bytes = 128 * 1024; + accepted[2].durable.desired_cpu_threads = 1; + accepted[4].durable.desired_cpu_threads = 12; + accepted[5].durable.desired_cpu_threads = 1; + accepted[7].durable.desired_cpu_threads = 12; + accepted[8].durable.desired_cpu_threads = 1; + accepted[10].durable.desired_cpu_threads = 12; + accepted[11].durable.desired_cpu_threads = 1; + accepted[13].durable.desired_cpu_threads = 12; + accepted[14].durable.desired_cpu_threads = 1; + accepted[16].durable.memory_fixed_bytes = 4ULL * 1024 * 1024; + accepted[16].durable.memory_bytes_per_item = 0; + accepted[16].durable.maximum_batch_size = 8; + accepted[16].durable.desired_cpu_threads = 1; + + for (size_t index = 0; + index < sizeof(accepted) / sizeof(accepted[0]); ++index) { Lardon3DTaskDurableSnapshot durable = snapshot(); - durable.estimate = historical[index]; + durable.estimate = accepted[index].durable; Lardon3DTask *task = NULL; CHECK(lardon3d_task_kind_registry_restore( - ®istry, kinds[index], 1, &durable, &context, &task + ®istry, accepted[index].kind, 1, &durable, &context, &task ) == LARDON3D_TASK_KIND_OK); Lardon3DResourceEstimate effective; CHECK(task && lardon3d_task_resource_estimate(task, &effective)); - CHECK(effective.desired_cpu_threads == 12); + CHECK(same_estimate(&effective, &accepted[index].effective)); lardon3d_task_destroy(task); } - CHECK(destroyed == 6); + + size_t before_malformed = (size_t)destroyed; + const size_t historical_case[] = {1, 4, 7, 10, 13, 16}; + for (size_t index = 0; index < sizeof(kinds) / sizeof(kinds[0]); ++index) { + Lardon3DTaskDurableSnapshot malformed = snapshot(); + malformed.estimate = accepted[historical_case[index]].durable; + ++malformed.estimate.desired_cpu_threads; + Lardon3DTask *task = NULL; + CHECK(lardon3d_task_kind_registry_restore( + ®istry, kinds[index], 1, &malformed, &context, &task + ) == LARDON3D_TASK_KIND_RECONSTRUCTION_FAILED && !task); + } + CHECK((size_t)destroyed == before_malformed + + sizeof(kinds) / sizeof(kinds[0])); return true; } diff --git a/tests/test_task_queue.c b/tests/test_task_queue.c index b24108b..41d4cca 100644 --- a/tests/test_task_queue.c +++ b/tests/test_task_queue.c @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -15,6 +16,7 @@ #include "../src/resource_governor_internal.h" #include "../src/task_internal.h" +#include "../src/task_queue_internal.h" #include "resource_snapshot_test_utils.h" #define CHECK(condition) \ @@ -29,6 +31,117 @@ enum { TASK_COUNT = 400, }; +typedef struct { + pthread_mutex_t mutex; + pthread_cond_t condition; + Lardon3DTaskQueue *queue; + bool block_registered; + bool release_registered; + bool registered; + bool producer_waiting; + bool closing; +} QueueIngressProbe; + +static _Atomic(QueueIngressProbe *) active_queue_ingress_probe; + +/* Strong definition for the private seam compiled into this test binary. + * Events acknowledge exact Queue-internal linearization points; no elapsed- + * time assumption is permitted in lifetime/destruction tests. */ +void +lardon3d_task_queue_internal_test_event( + Lardon3DTaskQueue *queue, + Lardon3DTaskQueueTestEvent event +) +{ + QueueIngressProbe *probe = atomic_load_explicit( + &active_queue_ingress_probe, memory_order_acquire); + if (!probe || probe->queue != queue) { + return; + } + (void)pthread_mutex_lock(&probe->mutex); + if (event == LARDON3D_TASK_QUEUE_TEST_CALL_REGISTERED) { + probe->registered = true; + (void)pthread_cond_broadcast(&probe->condition); + while (probe->block_registered && !probe->release_registered) { + (void)pthread_cond_wait(&probe->condition, &probe->mutex); + } + } else if (event == LARDON3D_TASK_QUEUE_TEST_PRODUCER_WAITING) { + probe->producer_waiting = true; + (void)pthread_cond_broadcast(&probe->condition); + } else if (event == LARDON3D_TASK_QUEUE_TEST_CLOSING) { + probe->closing = true; + (void)pthread_cond_broadcast(&probe->condition); + } + (void)pthread_mutex_unlock(&probe->mutex); +} + +static bool +queue_ingress_probe_init( + QueueIngressProbe *probe, + Lardon3DTaskQueue *queue, + bool block_registered +) +{ + if (pthread_mutex_init(&probe->mutex, NULL) != 0) { + return false; + } + if (pthread_cond_init(&probe->condition, NULL) != 0) { + (void)pthread_mutex_destroy(&probe->mutex); + return false; + } + probe->queue = queue; + probe->block_registered = block_registered; + atomic_store_explicit( + &active_queue_ingress_probe, probe, memory_order_release); + return true; +} + +static void +queue_ingress_probe_disable(QueueIngressProbe *probe) +{ + atomic_store_explicit( + &active_queue_ingress_probe, NULL, memory_order_release); + (void)pthread_cond_destroy(&probe->condition); + (void)pthread_mutex_destroy(&probe->mutex); +} + +static bool +queue_ingress_probe_wait( + QueueIngressProbe *probe, + Lardon3DTaskQueueTestEvent event +) +{ + struct timespec deadline; + if (clock_gettime(CLOCK_REALTIME, &deadline) != 0) { + return false; + } + deadline.tv_sec += 10; + (void)pthread_mutex_lock(&probe->mutex); + bool *observed = &probe->closing; + if (event == LARDON3D_TASK_QUEUE_TEST_CALL_REGISTERED) { + observed = &probe->registered; + } else if (event == LARDON3D_TASK_QUEUE_TEST_PRODUCER_WAITING) { + observed = &probe->producer_waiting; + } + int result = 0; + while (!*observed && result == 0) { + result = pthread_cond_timedwait( + &probe->condition, &probe->mutex, &deadline); + } + bool reached = *observed; + (void)pthread_mutex_unlock(&probe->mutex); + return reached && result == 0; +} + +static void +queue_ingress_probe_release(QueueIngressProbe *probe) +{ + (void)pthread_mutex_lock(&probe->mutex); + probe->release_registered = true; + (void)pthread_cond_broadcast(&probe->condition); + (void)pthread_mutex_unlock(&probe->mutex); +} + typedef struct { pthread_mutex_t mutex; size_t order[TASK_COUNT]; @@ -75,6 +188,266 @@ queue_callback(Lardon3DTask *task, void *userdata) return !work->fail; } +static bool +immediate_callback(Lardon3DTask *task, void *userdata) +{ + (void)task; + (void)userdata; + return true; +} + +typedef struct { + pthread_mutex_t mutex; + pthread_cond_t condition; + size_t started; + size_t finished; + size_t destroyed; + bool release; + bool release_finished; +} LifetimeTracker; + +typedef struct { + LifetimeTracker *tracker; + bool block; + bool fail; +} OwnedQueueWork; + +typedef struct { + pthread_mutex_t mutex; + pthread_cond_t condition; + Lardon3DTaskQueue *queue; + uint64_t task_id; + bool identity_ready; + bool observed; + bool observation_ok; +} FinishedQueueObservation; + +static bool +lifetime_tracker_init(LifetimeTracker *tracker) +{ + if (pthread_mutex_init(&tracker->mutex, NULL) != 0) { + return false; + } + if (pthread_cond_init(&tracker->condition, NULL) != 0) { + (void)pthread_mutex_destroy(&tracker->mutex); + return false; + } + return true; +} + +static void +lifetime_tracker_destroy(LifetimeTracker *tracker) +{ + (void)pthread_cond_destroy(&tracker->condition); + (void)pthread_mutex_destroy(&tracker->mutex); +} + +static bool +wait_tracker_count( + LifetimeTracker *tracker, + bool wait_for_destroyed, + size_t wanted +) +{ + struct timespec deadline; + if (clock_gettime(CLOCK_REALTIME, &deadline) != 0) { + return false; + } + deadline.tv_sec += 10; + (void)pthread_mutex_lock(&tracker->mutex); + size_t *value = wait_for_destroyed ? &tracker->destroyed : &tracker->started; + int result = 0; + while (*value < wanted && result == 0) { + result = pthread_cond_timedwait( + &tracker->condition, &tracker->mutex, &deadline); + } + bool reached = *value >= wanted; + (void)pthread_mutex_unlock(&tracker->mutex); + return reached && result == 0; +} + +static size_t +tracker_destroyed(LifetimeTracker *tracker) +{ + (void)pthread_mutex_lock(&tracker->mutex); + size_t destroyed = tracker->destroyed; + (void)pthread_mutex_unlock(&tracker->mutex); + return destroyed; +} + +static size_t +tracker_started(LifetimeTracker *tracker) +{ + (void)pthread_mutex_lock(&tracker->mutex); + size_t started = tracker->started; + (void)pthread_mutex_unlock(&tracker->mutex); + return started; +} + +static bool +wait_tracker_finished(LifetimeTracker *tracker, size_t wanted) +{ + struct timespec deadline; + if (clock_gettime(CLOCK_REALTIME, &deadline) != 0) { + return false; + } + deadline.tv_sec += 10; + (void)pthread_mutex_lock(&tracker->mutex); + int result = 0; + while (tracker->finished < wanted && result == 0) { + result = pthread_cond_timedwait( + &tracker->condition, &tracker->mutex, &deadline); + } + bool reached = tracker->finished >= wanted; + (void)pthread_mutex_unlock(&tracker->mutex); + return reached && result == 0; +} + +static void +release_tracker_callbacks(LifetimeTracker *tracker) +{ + (void)pthread_mutex_lock(&tracker->mutex); + tracker->release = true; + (void)pthread_cond_broadcast(&tracker->condition); + (void)pthread_mutex_unlock(&tracker->mutex); +} + +static void +release_tracker_finished(LifetimeTracker *tracker) +{ + (void)pthread_mutex_lock(&tracker->mutex); + tracker->release_finished = true; + (void)pthread_cond_broadcast(&tracker->condition); + (void)pthread_mutex_unlock(&tracker->mutex); +} + +static bool +owned_queue_callback(Lardon3DTask *task, void *userdata) +{ + OwnedQueueWork *work = userdata; + (void)pthread_mutex_lock(&work->tracker->mutex); + ++work->tracker->started; + (void)pthread_cond_broadcast(&work->tracker->condition); + while (work->block && !work->tracker->release) { + (void)pthread_cond_wait( + &work->tracker->condition, &work->tracker->mutex); + } + (void)pthread_mutex_unlock(&work->tracker->mutex); + return lardon3d_task_checkpoint(task) && !work->fail; +} + +static void +owned_queue_work_destroy(void *userdata) +{ + OwnedQueueWork *work = userdata; + LifetimeTracker *tracker = work->tracker; + (void)pthread_mutex_lock(&tracker->mutex); + ++tracker->destroyed; + (void)pthread_cond_broadcast(&tracker->condition); + (void)pthread_mutex_unlock(&tracker->mutex); + free(work); +} + +static void +owned_finished_callback(const Lardon3DTask *task, void *userdata) +{ + (void)task; + LifetimeTracker *tracker = userdata; + (void)pthread_mutex_lock(&tracker->mutex); + ++tracker->finished; + (void)pthread_cond_broadcast(&tracker->condition); + while (!tracker->release_finished) { + (void)pthread_cond_wait(&tracker->condition, &tracker->mutex); + } + (void)pthread_mutex_unlock(&tracker->mutex); +} + +static bool +finished_observation_work(Lardon3DTask *task, void *userdata) +{ + FinishedQueueObservation *observation = userdata; + (void)pthread_mutex_lock(&observation->mutex); + while (!observation->identity_ready) { + (void)pthread_cond_wait( + &observation->condition, &observation->mutex); + } + (void)pthread_mutex_unlock(&observation->mutex); + return lardon3d_task_checkpoint(task); +} + +static void +observe_queue_from_finished(const Lardon3DTask *task, void *userdata) +{ + (void)task; + FinishedQueueObservation *observation = userdata; + Lardon3DTaskSnapshot by_id; + Lardon3DTaskSnapshot newest; + Lardon3DTaskQueueSummary summary; + bool ok = lardon3d_task_queue_get( + observation->queue, observation->task_id, &by_id) + && by_id.id == observation->task_id + && by_id.state == TASK_COMPLETED + && lardon3d_task_queue_snapshot( + observation->queue, &newest, 1, &summary) == 1 + && newest.id == observation->task_id + && summary.completed == 1 && summary.total == 1; + (void)pthread_mutex_lock(&observation->mutex); + observation->observation_ok = ok; + observation->observed = true; + (void)pthread_cond_broadcast(&observation->condition); + (void)pthread_mutex_unlock(&observation->mutex); +} + +static bool +wait_finished_observation(FinishedQueueObservation *observation) +{ + struct timespec deadline; + if (clock_gettime(CLOCK_REALTIME, &deadline) != 0) { + return false; + } + deadline.tv_sec += 10; + (void)pthread_mutex_lock(&observation->mutex); + int result = 0; + while (!observation->observed && result == 0) { + result = pthread_cond_timedwait( + &observation->condition, &observation->mutex, &deadline); + } + bool ok = observation->observed && observation->observation_ok; + (void)pthread_mutex_unlock(&observation->mutex); + return result == 0 && ok; +} + +static Lardon3DTask * +create_owned_task( + const char *name, + const Lardon3DResourceEstimate *estimate, + LifetimeTracker *tracker, + bool block, + bool fail +) +{ + OwnedQueueWork *work = calloc(1, sizeof(*work)); + if (!work) { + return NULL; + } + work->tracker = tracker; + work->block = block; + work->fail = fail; + Lardon3DTask *task = lardon3d_task_create_typed( + name, + estimate, + "test.queue_lifetime", + 1, + owned_queue_callback, + work, + owned_queue_work_destroy + ); + if (!task) { + free(work); + } + return task; +} + static bool wait_terminal(Lardon3DTaskQueue *queue, uint64_t id, Lardon3DTaskSnapshot *result) { @@ -91,6 +464,36 @@ wait_terminal(Lardon3DTaskQueue *queue, uint64_t id, Lardon3DTaskSnapshot *resul return false; } +static bool +wait_full_terminal_history( + Lardon3DTaskQueue *queue, + size_t expected_completed +) +{ + for (size_t attempt = 0; attempt < 10000; ++attempt) { + Lardon3DTaskSnapshot records[ + LARDON3D_TASK_QUEUE_HISTORY_CAPACITY + 1]; + Lardon3DTaskQueueSummary summary; + size_t count = lardon3d_task_queue_snapshot( + queue, + records, + LARDON3D_TASK_QUEUE_HISTORY_CAPACITY + 1, + &summary + ); + /* A just-finished Task is terminal before the worker retires it. With + * full history that valid transition exposes 65 records; exactly 64 + * proves the live Task has been destroyed and inserted into history. */ + if (count == LARDON3D_TASK_QUEUE_HISTORY_CAPACITY + && summary.running == 0 && summary.pending == 0 + && summary.completed == expected_completed + && summary.total == expected_completed) { + return true; + } + short_pause(); + } + return false; +} + typedef struct { Lardon3DTaskQueue *queue; Lardon3DResourceEstimate estimate; @@ -99,6 +502,16 @@ typedef struct { bool added; } ProducerThread; +typedef struct { + Lardon3DTaskQueue *queue; + size_t count; +} QueueCountThread; + +typedef struct { + Lardon3DTaskQueue *queue; + atomic_bool returned; +} QueueDestroyThread; + static void * producer_thread(void *context) { @@ -124,6 +537,23 @@ producer_thread(void *context) return NULL; } +static void * +queue_count_thread(void *context) +{ + QueueCountThread *call = context; + call->count = lardon3d_task_queue_count(call->queue); + return NULL; +} + +static void * +queue_destroy_thread(void *context) +{ + QueueDestroyThread *call = context; + lardon3d_task_queue_destroy(call->queue); + atomic_store_explicit(&call->returned, true, memory_order_release); + return NULL; +} + /* Réserve toutes les ressources CPU pour que le worker ne puisse plus consommer de tâche : la file d'attente reste pleine et les producteurs bloquent de façon déterministe. */ @@ -153,6 +583,96 @@ hold_resources( ); } +static bool +test_registered_ingress_blocks_destroy(Lardon3DResourceGovernor *governor) +{ + Lardon3DTaskQueue *queue = lardon3d_task_queue_create(governor, 1); + CHECK(queue); + QueueIngressProbe probe = {0}; + CHECK(queue_ingress_probe_init(&probe, queue, true)); + + QueueCountThread count_call = {.queue = queue, .count = SIZE_MAX}; + pthread_t count_thread; + CHECK(pthread_create( + &count_thread, NULL, queue_count_thread, &count_call) == 0); + CHECK(queue_ingress_probe_wait( + &probe, LARDON3D_TASK_QUEUE_TEST_CALL_REGISTERED)); + + QueueDestroyThread destroy_call = {.queue = queue}; + atomic_init(&destroy_call.returned, false); + pthread_t destroy_thread; + CHECK(pthread_create( + &destroy_thread, NULL, queue_destroy_thread, &destroy_call) == 0); + CHECK(queue_ingress_probe_wait( + &probe, LARDON3D_TASK_QUEUE_TEST_CLOSING)); + CHECK(!atomic_load_explicit(&destroy_call.returned, memory_order_acquire)); + + /* The registered call is still before queue->mutex. Destroy has closed + * ingress and must remain alive until this exact reference can observe + * stopping, release its pin, and return. */ + queue_ingress_probe_release(&probe); + CHECK(pthread_join(count_thread, NULL) == 0); + CHECK(count_call.count == 0); + CHECK(pthread_join(destroy_thread, NULL) == 0); + CHECK(atomic_load_explicit(&destroy_call.returned, memory_order_acquire)); + queue_ingress_probe_disable(&probe); + return true; +} + +static bool +test_generated_id_exhaustion_is_sticky(Lardon3DResourceGovernor *governor) +{ + const Lardon3DResourceEstimate estimate = { + .minimum_batch_size = 1, + .maximum_batch_size = 1, + .desired_cpu_threads = 1, + }; + Lardon3DTaskQueue *queue = lardon3d_task_queue_create(governor, 2); + CHECK(queue); + Lardon3DTaskSnapshot snapshot; + + /* A restored penultimate ID advances the generator to its final value. + * Removing both terminal records proves exhaustion is lifetime state, not + * an accidental consequence of retained collision records. */ + Lardon3DTask *penultimate = lardon3d_task_create( + "Restored penultimate ID", &estimate, immediate_callback, NULL); + CHECK(penultimate && lardon3d_task_assign_id(penultimate, UINT64_MAX - 1)); + CHECK(lardon3d_task_queue_add(queue, penultimate, NULL)); + CHECK(wait_terminal(queue, UINT64_MAX - 1, &snapshot)); + CHECK(lardon3d_task_queue_remove(queue, UINT64_MAX - 1)); + + Lardon3DTask *last = lardon3d_task_create( + "Generated final ID", &estimate, immediate_callback, NULL); + uint64_t last_id = 0; + CHECK(last && lardon3d_task_queue_add(queue, last, &last_id)); + CHECK(last_id == UINT64_MAX); + CHECK(wait_terminal(queue, UINT64_MAX, &snapshot)); + CHECK(lardon3d_task_queue_remove(queue, UINT64_MAX)); + + Lardon3DTask *rejected = lardon3d_task_create( + "Generation exhausted", &estimate, immediate_callback, NULL); + CHECK(rejected); + CHECK(lardon3d_task_queue_try_add_ex(queue, rejected, NULL) + == LARDON3D_TASK_QUEUE_ADD_ERROR); + lardon3d_task_destroy(rejected); + + Lardon3DTask *lower = lardon3d_task_create( + "Lower restored ID", &estimate, immediate_callback, NULL); + CHECK(lower && lardon3d_task_assign_id(lower, 7)); + CHECK(lardon3d_task_queue_add(queue, lower, NULL)); + CHECK(wait_terminal(queue, 7, &snapshot)); + CHECK(lardon3d_task_queue_remove(queue, 7)); + + rejected = lardon3d_task_create( + "Generation remains exhausted", &estimate, immediate_callback, NULL); + CHECK(rejected); + CHECK(lardon3d_task_queue_try_add_ex(queue, rejected, NULL) + == LARDON3D_TASK_QUEUE_ADD_ERROR); + lardon3d_task_destroy(rejected); + lardon3d_task_queue_destroy(queue); + return true; +} + static bool run_test(void) { @@ -211,6 +731,7 @@ run_test(void) Lardon3DTaskSnapshot snapshot; CHECK(wait_terminal(queue, ids[TASK_COUNT - 1], &snapshot)); CHECK(snapshot.state == TASK_COMPLETED); + CHECK(wait_full_terminal_history(queue, TASK_COUNT)); CHECK(log.count == TASK_COUNT); for (size_t index = 0; index < TASK_COUNT; ++index) { CHECK(log.order[index] == index); @@ -218,17 +739,42 @@ run_test(void) CHECK(work[index].contract.batch_size == 1); } Lardon3DTaskQueueSummary summary; - Lardon3DTaskSnapshot listed[8]; - CHECK(lardon3d_task_queue_snapshot(queue, listed, 8, &summary) == 8); + Lardon3DTaskSnapshot listed[LARDON3D_TASK_QUEUE_HISTORY_CAPACITY + 1]; + CHECK(lardon3d_task_queue_snapshot( + queue, + listed, + LARDON3D_TASK_QUEUE_HISTORY_CAPACITY + 1, + &summary + ) == LARDON3D_TASK_QUEUE_HISTORY_CAPACITY); CHECK(summary.total == TASK_COUNT); CHECK(summary.running == 0); CHECK(summary.pending == 0); CHECK(summary.completed == TASK_COUNT); CHECK(lardon3d_task_queue_get_at(queue, 0, &snapshot)); - CHECK(snapshot.id == ids[0]); - CHECK(!lardon3d_task_queue_get_at(queue, TASK_COUNT, &snapshot)); - CHECK(lardon3d_task_queue_remove(queue, ids[0])); + CHECK(snapshot.id == ids[TASK_COUNT - 1]); + CHECK(listed[0].id == ids[TASK_COUNT - 1]); + CHECK(listed[LARDON3D_TASK_QUEUE_HISTORY_CAPACITY - 1].id + == ids[TASK_COUNT - LARDON3D_TASK_QUEUE_HISTORY_CAPACITY]); + CHECK(!lardon3d_task_queue_get_at( + queue, LARDON3D_TASK_QUEUE_HISTORY_CAPACITY, &snapshot)); CHECK(!lardon3d_task_queue_get(queue, ids[0], &snapshot)); + CHECK(!lardon3d_task_queue_remove(queue, ids[0])); + Lardon3DTask *after_eviction = lardon3d_task_create( + "Après éviction", &estimate, immediate_callback, NULL); + uint64_t after_eviction_id = 0; + CHECK(after_eviction && lardon3d_task_queue_add( + queue, after_eviction, &after_eviction_id)); + CHECK(after_eviction_id == TASK_COUNT + 1); + CHECK(wait_terminal(queue, after_eviction_id, &snapshot)); + CHECK(snapshot.state == TASK_COMPLETED); + CHECK(wait_full_terminal_history(queue, TASK_COUNT + 1)); + CHECK(lardon3d_task_queue_get_at(queue, 0, &snapshot) + && snapshot.id == after_eviction_id); + CHECK(lardon3d_task_queue_remove(queue, ids[TASK_COUNT - 1])); + CHECK(!lardon3d_task_queue_get(queue, ids[TASK_COUNT - 1], &snapshot)); + CHECK(lardon3d_task_queue_snapshot(queue, NULL, 0, &summary) == 0); + CHECK(summary.total == TASK_COUNT + 1 + && summary.completed == TASK_COUNT + 1); CHECK(lardon3d_task_queue_count(queue) == 0); lardon3d_task_queue_destroy(queue); CHECK(pthread_mutex_destroy(&log.mutex) == 0); @@ -279,7 +825,7 @@ run_test(void) } short_pause(); } - CHECK(lardon3d_task_pause(slow_task)); + CHECK(lardon3d_task_queue_pause(queue, slow_id)); for (size_t attempt = 0; attempt < 1000; ++attempt) { CHECK(lardon3d_task_queue_get(queue, slow_id, &snapshot)); if (snapshot.state == TASK_PAUSED) { @@ -290,7 +836,7 @@ run_test(void) CHECK(snapshot.state == TASK_PAUSED); CHECK(lardon3d_resource_governor_reservation_count(governor) == 1); CHECK(lardon3d_task_queue_cancel(queue, cancelled_id)); - CHECK(lardon3d_task_resume(slow_task)); + CHECK(lardon3d_task_queue_resume(queue, slow_id)); CHECK(wait_terminal(queue, slow_id, &snapshot)); CHECK(snapshot.state == TASK_COMPLETED); CHECK(wait_terminal(queue, cancelled_id, &snapshot)); @@ -557,6 +1103,357 @@ run_test(void) return true; } +static bool +test_finished_read_only_reentrancy(Lardon3DResourceGovernor *governor) +{ + const Lardon3DResourceEstimate estimate = { + .minimum_batch_size = 1, + .maximum_batch_size = 1, + .desired_cpu_threads = 1, + }; + FinishedQueueObservation observation = {0}; + CHECK(pthread_mutex_init(&observation.mutex, NULL) == 0); + CHECK(pthread_cond_init(&observation.condition, NULL) == 0); + observation.queue = lardon3d_task_queue_create(governor, 1); + CHECK(observation.queue); + Lardon3DTask *task = lardon3d_task_create( + "Observation de fin", + &estimate, + finished_observation_work, + &observation + ); + CHECK(task && lardon3d_task_set_finished_callback( + task, observe_queue_from_finished, &observation)); + CHECK(lardon3d_task_queue_add( + observation.queue, task, &observation.task_id)); + (void)pthread_mutex_lock(&observation.mutex); + observation.identity_ready = true; + (void)pthread_cond_broadcast(&observation.condition); + (void)pthread_mutex_unlock(&observation.mutex); + CHECK(wait_finished_observation(&observation)); + lardon3d_task_queue_destroy(observation.queue); + CHECK(pthread_cond_destroy(&observation.condition) == 0); + CHECK(pthread_mutex_destroy(&observation.mutex) == 0); + return true; +} + +static bool +test_terminal_lifetime(Lardon3DResourceGovernor *governor) +{ + const Lardon3DResourceEstimate estimate = { + .minimum_batch_size = 1, + .maximum_batch_size = 1, + .desired_cpu_threads = 1, + }; + LifetimeTracker tracker = {0}; + CHECK(lifetime_tracker_init(&tracker)); + Lardon3DTaskQueue *queue = lardon3d_task_queue_create(governor, 4); + CHECK(queue); + Lardon3DTaskObservation absent_observation = {.id = UINT64_MAX}; + CHECK(!lardon3d_task_queue_get_observation( + queue, UINT64_MAX, &absent_observation)); + CHECK(absent_observation.id == 0 + && !absent_observation.has_execution_contract); + + Lardon3DTask *active = create_owned_task( + "Durée active", &estimate, &tracker, true, false); + uint64_t active_id = 0; + CHECK(active && lardon3d_task_queue_add(queue, active, &active_id)); + CHECK(wait_tracker_count(&tracker, false, 1)); + + Lardon3DTask *pending = create_owned_task( + "Durée attente", &estimate, &tracker, false, false); + uint64_t pending_id = 0; + CHECK(pending && lardon3d_task_queue_add(queue, pending, &pending_id)); + CHECK(tracker_destroyed(&tracker) == 0); + Lardon3DTaskSnapshot snapshot; + CHECK(lardon3d_task_queue_get(queue, active_id, &snapshot) + && snapshot.state == TASK_RUNNING); + CHECK(lardon3d_task_queue_get(queue, pending_id, &snapshot) + && snapshot.state == TASK_PENDING); + CHECK(!lardon3d_task_queue_remove(queue, active_id)); + CHECK(!lardon3d_task_queue_remove(queue, pending_id)); + + /* Pending cancellation completes its finished callback synchronously; the + * userdata must be gone before Queue destruction while its snapshot stays + * observable. The active Task remains owned until its callback exits. */ + CHECK(lardon3d_task_queue_cancel(queue, pending_id)); + CHECK(wait_tracker_count(&tracker, true, 1)); + CHECK(lardon3d_task_queue_get(queue, pending_id, &snapshot) + && snapshot.state == TASK_CANCELLED); + CHECK(tracker_destroyed(&tracker) == 1); + Lardon3DTaskSnapshot live_first[2]; + Lardon3DTaskQueueSummary live_summary; + CHECK(lardon3d_task_queue_snapshot( + queue, live_first, 2, &live_summary) == 2); + CHECK(live_first[0].id == active_id + && live_first[0].state == TASK_RUNNING); + CHECK(live_first[1].id == pending_id + && live_first[1].state == TASK_CANCELLED); + CHECK(live_summary.running == 1 && live_summary.pending == 0 + && live_summary.completed == 1 && live_summary.total == 2); + + release_tracker_callbacks(&tracker); + CHECK(wait_tracker_count(&tracker, true, 2)); + CHECK(lardon3d_task_queue_get(queue, active_id, &snapshot) + && snapshot.state == TASK_COMPLETED); + + Lardon3DTask *failed = create_owned_task( + "Durée échec", &estimate, &tracker, false, true); + uint64_t failed_id = 0; + CHECK(failed && lardon3d_task_queue_add(queue, failed, &failed_id)); + CHECK(wait_tracker_count(&tracker, true, 3)); + CHECK(lardon3d_task_queue_get(queue, failed_id, &snapshot) + && snapshot.state == TASK_FAILED); + + Lardon3DTask *finished = create_owned_task( + "Durée finalisation", &estimate, &tracker, false, false); + uint64_t finished_id = 0; + CHECK(finished && lardon3d_task_set_finished_callback( + finished, owned_finished_callback, &tracker)); + CHECK(lardon3d_task_queue_add(queue, finished, &finished_id)); + CHECK(wait_tracker_finished(&tracker, 1)); + CHECK(tracker_destroyed(&tracker) == 3); + CHECK(lardon3d_task_queue_get(queue, finished_id, &snapshot) + && snapshot.state == TASK_COMPLETED); + release_tracker_finished(&tracker); + CHECK(wait_tracker_count(&tracker, true, 4)); + CHECK(lardon3d_task_queue_get(queue, finished_id, &snapshot) + && snapshot.state == TASK_COMPLETED); + + CHECK(lardon3d_task_queue_cancel(queue, active_id)); + CHECK(!lardon3d_task_queue_pause(queue, active_id)); + CHECK(!lardon3d_task_queue_resume(queue, active_id)); + + Lardon3DTaskSnapshot ordered[4]; + Lardon3DTaskQueueSummary summary; + CHECK(lardon3d_task_queue_snapshot(queue, ordered, 4, &summary) == 4); + CHECK(ordered[0].id == finished_id + && ordered[0].state == TASK_COMPLETED); + CHECK(ordered[1].id == failed_id && ordered[1].state == TASK_FAILED); + CHECK(ordered[2].id == active_id && ordered[2].state == TASK_COMPLETED); + CHECK(ordered[3].id == pending_id && ordered[3].state == TASK_CANCELLED); + CHECK(summary.running == 0 && summary.pending == 0 + && summary.completed == 4 && summary.total == 4); + CHECK(lardon3d_task_queue_remove(queue, failed_id)); + CHECK(!lardon3d_task_queue_get(queue, failed_id, &snapshot)); + CHECK(!lardon3d_task_queue_remove(queue, failed_id)); + CHECK(lardon3d_task_queue_snapshot(queue, NULL, 0, &summary) == 0); + CHECK(summary.completed == 4 && summary.total == 4); + + lardon3d_task_queue_destroy(queue); + CHECK(tracker_destroyed(&tracker) == 4); + lifetime_tracker_destroy(&tracker); + return true; +} + +typedef struct { + pthread_mutex_t mutex; + pthread_cond_t condition; + size_t ready; + bool go; +} AccessGate; + +typedef struct { + Lardon3DTaskQueue *queue; + AccessGate *gate; + size_t task_count; + bool remove; + bool ok; +} QueueAccessStress; + +static void * +queue_access_stress(void *userdata) +{ + QueueAccessStress *stress = userdata; + (void)pthread_mutex_lock(&stress->gate->mutex); + ++stress->gate->ready; + (void)pthread_cond_broadcast(&stress->gate->condition); + while (!stress->gate->go) { + (void)pthread_cond_wait(&stress->gate->condition, &stress->gate->mutex); + } + (void)pthread_mutex_unlock(&stress->gate->mutex); + + stress->ok = true; + for (size_t iteration = 0; iteration < 10000; ++iteration) { + Lardon3DTaskSnapshot snapshots[LARDON3D_TASK_QUEUE_HISTORY_CAPACITY]; + Lardon3DTaskQueueSummary summary; + size_t count = lardon3d_task_queue_snapshot( + stress->queue, + snapshots, + LARDON3D_TASK_QUEUE_HISTORY_CAPACITY, + &summary + ); + if (count > LARDON3D_TASK_QUEUE_HISTORY_CAPACITY + || summary.total < summary.completed) { + stress->ok = false; + break; + } + uint64_t id = (uint64_t)(iteration % stress->task_count) + 1; + Lardon3DTaskSnapshot snapshot; + if (lardon3d_task_queue_get(stress->queue, id, &snapshot) + && snapshot.id != id) { + stress->ok = false; + break; + } + if (stress->remove) { + (void)lardon3d_task_queue_remove(stress->queue, id); + } + } + return NULL; +} + +static bool +test_concurrent_terminal_access(Lardon3DResourceGovernor *governor) +{ + enum { STRESS_TASKS = 128, ACCESS_THREADS = 2 }; + const Lardon3DResourceEstimate estimate = { + .minimum_batch_size = 1, + .maximum_batch_size = 1, + .desired_cpu_threads = 1, + }; + LifetimeTracker tracker = {0}; + CHECK(lifetime_tracker_init(&tracker)); + Lardon3DTaskQueue *queue = lardon3d_task_queue_create(governor, STRESS_TASKS); + CHECK(queue); + for (size_t index = 0; index < STRESS_TASKS; ++index) { + Lardon3DTask *task = create_owned_task( + "Accès concurrent", &estimate, &tracker, true, false); + uint64_t id = 0; + CHECK(task && lardon3d_task_queue_add(queue, task, &id)); + CHECK(id == index + 1); + } + CHECK(wait_tracker_count(&tracker, false, 1)); + + AccessGate gate = {0}; + CHECK(pthread_mutex_init(&gate.mutex, NULL) == 0); + CHECK(pthread_cond_init(&gate.condition, NULL) == 0); + QueueAccessStress stress[ACCESS_THREADS] = { + {.queue = queue, .gate = &gate, .task_count = STRESS_TASKS}, + {.queue = queue, .gate = &gate, .task_count = STRESS_TASKS, .remove = true}, + }; + pthread_t threads[ACCESS_THREADS]; + for (size_t index = 0; index < ACCESS_THREADS; ++index) { + CHECK(pthread_create( + &threads[index], NULL, queue_access_stress, &stress[index]) == 0); + } + (void)pthread_mutex_lock(&gate.mutex); + while (gate.ready < ACCESS_THREADS) { + (void)pthread_cond_wait(&gate.condition, &gate.mutex); + } + gate.go = true; + (void)pthread_cond_broadcast(&gate.condition); + (void)pthread_mutex_unlock(&gate.mutex); + release_tracker_callbacks(&tracker); + for (size_t index = 0; index < ACCESS_THREADS; ++index) { + CHECK(pthread_join(threads[index], NULL) == 0 && stress[index].ok); + } + CHECK(wait_tracker_count(&tracker, true, STRESS_TASKS)); + + Lardon3DTaskSnapshot retained[LARDON3D_TASK_QUEUE_HISTORY_CAPACITY + 1]; + Lardon3DTaskQueueSummary summary; + size_t retained_count = lardon3d_task_queue_snapshot( + queue, + retained, + LARDON3D_TASK_QUEUE_HISTORY_CAPACITY + 1, + &summary + ); + CHECK(retained_count <= LARDON3D_TASK_QUEUE_HISTORY_CAPACITY); + for (size_t index = 1; index < retained_count; ++index) { + CHECK(retained[index - 1].id > retained[index].id); + } + CHECK(summary.running == 0 && summary.pending == 0); + CHECK(summary.completed == STRESS_TASKS && summary.total == STRESS_TASKS); + lardon3d_task_queue_destroy(queue); + CHECK(tracker_destroyed(&tracker) == STRESS_TASKS); + CHECK(pthread_cond_destroy(&gate.condition) == 0); + CHECK(pthread_mutex_destroy(&gate.mutex) == 0); + lifetime_tracker_destroy(&tracker); + return true; +} + +static bool +test_shutdown_userdata_exact_once(Lardon3DResourceGovernor *governor) +{ + enum { SHUTDOWN_TASKS = 3 }; + const Lardon3DResourceEstimate estimate = { + .minimum_batch_size = 1, + .maximum_batch_size = 1, + .desired_cpu_threads = 1, + }; + Lardon3DResourceReservation *reservation = NULL; + CHECK(hold_resources(governor, &reservation)); + LifetimeTracker tracker = {0}; + CHECK(lifetime_tracker_init(&tracker)); + Lardon3DTaskQueue *queue = lardon3d_task_queue_create(governor, SHUTDOWN_TASKS); + CHECK(queue); + for (size_t index = 0; index < SHUTDOWN_TASKS; ++index) { + Lardon3DTask *task = create_owned_task( + "Arrêt exact", &estimate, &tracker, false, false); + CHECK(task && lardon3d_task_queue_add(queue, task, NULL)); + } + CHECK(tracker_destroyed(&tracker) == 0); + lardon3d_task_queue_destroy(queue); + CHECK(tracker_started(&tracker) == 0); + CHECK(tracker_destroyed(&tracker) == SHUTDOWN_TASKS); + CHECK(lardon3d_resource_governor_release(governor, reservation)); + lifetime_tracker_destroy(&tracker); + return true; +} + +static bool +test_saturation_observes_active(Lardon3DResourceGovernor *governor) +{ + enum { + PENDING_TASKS = 64, + OBSERVATION_CAPACITY = + 2 * LARDON3D_TASK_QUEUE_HISTORY_CAPACITY + 1, + }; + const Lardon3DResourceEstimate estimate = { + .minimum_batch_size = 1, + .maximum_batch_size = 1, + .desired_cpu_threads = 1, + }; + LifetimeTracker tracker = {0}; + CHECK(lifetime_tracker_init(&tracker)); + Lardon3DTaskQueue *queue = lardon3d_task_queue_create( + governor, PENDING_TASKS); + CHECK(queue); + Lardon3DTask *active = create_owned_task( + "Saturation active", &estimate, &tracker, true, false); + uint64_t active_id = 0; + CHECK(active && lardon3d_task_queue_add(queue, active, &active_id)); + CHECK(wait_tracker_count(&tracker, false, 1)); + for (size_t index = 0; index < PENDING_TASKS; ++index) { + Lardon3DTask *pending = create_owned_task( + "Saturation pending", &estimate, &tracker, false, false); + CHECK(pending && lardon3d_task_queue_add(queue, pending, NULL)); + } + + Lardon3DTaskObservation observations[OBSERVATION_CAPACITY]; + Lardon3DTaskQueueSummary summary; + size_t count = lardon3d_task_queue_observe( + queue, observations, OBSERVATION_CAPACITY, &summary); + CHECK(count == PENDING_TASKS + 1); + CHECK(summary.running == 1 && summary.pending == PENDING_TASKS); + bool active_found = false; + for (size_t index = 0; index < count; ++index) { + if (observations[index].id == active_id) { + active_found = observations[index].state == TASK_RUNNING + && observations[index].has_execution_contract + && observations[index].execution_contract.cpu_threads > 0; + } + } + CHECK(active_found); + + release_tracker_callbacks(&tracker); + CHECK(wait_tracker_count( + &tracker, true, (size_t)PENDING_TASKS + 1)); + lardon3d_task_queue_destroy(queue); + lifetime_tracker_destroy(&tracker); + return true; +} + static bool test_enqueue_under_capacity(Lardon3DResourceGovernor *governor) { @@ -637,14 +1534,17 @@ test_capacity_reached_blocks(Lardon3DResourceGovernor *governor) .estimate = estimate, .work = &work[2], }; + QueueIngressProbe probe = {0}; + CHECK(queue_ingress_probe_init(&probe, queue, false)); pthread_t thread; CHECK(pthread_create(&thread, NULL, producer_thread, &producer) == 0); - short_pause(); - CHECK(!producer.added); + CHECK(queue_ingress_probe_wait( + &probe, LARDON3D_TASK_QUEUE_TEST_PRODUCER_WAITING)); CHECK(lardon3d_resource_governor_release(governor, reservation)); lardon3d_task_queue_resources_changed(queue); CHECK(pthread_join(thread, NULL) == 0); CHECK(producer.added); + queue_ingress_probe_disable(&probe); Lardon3DTaskSnapshot snapshot; for (size_t index = 0; index < 2; ++index) { CHECK(wait_terminal(queue, ids[index], &snapshot)); @@ -689,10 +1589,12 @@ test_release_unblocks_producer(Lardon3DResourceGovernor *governor) .estimate = estimate, .work = &work[1], }; + QueueIngressProbe probe = {0}; + CHECK(queue_ingress_probe_init(&probe, queue, false)); pthread_t thread; CHECK(pthread_create(&thread, NULL, producer_thread, &producer) == 0); - short_pause(); - CHECK(!producer.added); + CHECK(queue_ingress_probe_wait( + &probe, LARDON3D_TASK_QUEUE_TEST_PRODUCER_WAITING)); CHECK(lardon3d_resource_governor_release(governor, reservation)); lardon3d_task_queue_resources_changed(queue); Lardon3DTaskSnapshot snapshot; @@ -701,6 +1603,7 @@ test_release_unblocks_producer(Lardon3DResourceGovernor *governor) CHECK(lardon3d_task_queue_remove(queue, first_id)); CHECK(pthread_join(thread, NULL) == 0); CHECK(producer.added); + queue_ingress_probe_disable(&probe); CHECK(wait_terminal(queue, producer.id, &snapshot)); CHECK(snapshot.state == TASK_COMPLETED); lardon3d_task_queue_destroy(queue); @@ -788,15 +1691,19 @@ test_shutdown_unblocks_producer(Lardon3DResourceGovernor *governor) .estimate = estimate, .work = &work[1], }; + QueueIngressProbe probe = {0}; + CHECK(queue_ingress_probe_init(&probe, queue, false)); pthread_t thread; CHECK(pthread_create(&thread, NULL, producer_thread, &producer) == 0); - short_pause(); - CHECK(!producer.added); - // destroy réveille le producteur via broadcast(¬_full) + CHECK(queue_ingress_probe_wait( + &probe, LARDON3D_TASK_QUEUE_TEST_PRODUCER_WAITING)); + /* The hook fires with the Queue mutex held after producer registration and + * immediately before not_full wait. Destroy must wake and await that exact + * in-progress add call, not a thread presumed to have run after a sleep. */ lardon3d_task_queue_destroy(queue); - // Le producteur peut maintenant sortir de add et terminer CHECK(pthread_join(thread, NULL) == 0); CHECK(!producer.added); + queue_ingress_probe_disable(&probe); CHECK(lardon3d_resource_governor_release(governor, reservation)); CHECK(pthread_mutex_destroy(&log.mutex) == 0); return true; @@ -1328,7 +2235,14 @@ main(void) if (!governor) { return EXIT_FAILURE; } - bool ok = test_enqueue_under_capacity(governor) + bool ok = test_registered_ingress_blocks_destroy(governor) + && test_generated_id_exhaustion_is_sticky(governor) + && test_finished_read_only_reentrancy(governor) + && test_terminal_lifetime(governor) + && test_concurrent_terminal_access(governor) + && test_shutdown_userdata_exact_once(governor) + && test_saturation_observes_active(governor) + && test_enqueue_under_capacity(governor) && test_capacity_reached_blocks(governor) && test_release_unblocks_producer(governor) && test_multiple_producers(governor) diff --git a/tests/test_tui_layout.c b/tests/test_tui_layout.c new file mode 100644 index 0000000..401a13e --- /dev/null +++ b/tests/test_tui_layout.c @@ -0,0 +1,518 @@ +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#define CHECK(condition) \ + do { \ + if (!(condition)) { \ + (void)fprintf(stderr, "TUI layout failure line %d: %s\n", \ + __LINE__, #condition); \ + return false; \ + } \ + } while (0) + +size_t +lardon3d_image_catalog_count(const Lardon3DImageCatalog *catalog) +{ + (void)catalog; + return 0; +} + +void +lardon3d_image_catalog_format_size( + uint64_t size_bytes, + char *text, + size_t text_size +) +{ + (void)snprintf(text, text_size, "%llu B", + (unsigned long long)size_bytes); +} + +size_t +lardon3d_image_view_count(const Lardon3DImageView *view) +{ + (void)view; + return 0; +} + +const Lardon3DImageEntry * +lardon3d_image_view_get(const Lardon3DImageView *view, size_t visible_index) +{ + (void)view; + (void)visible_index; + return NULL; +} + +Lardon3DImageSort +lardon3d_image_view_sort(const Lardon3DImageView *view) +{ + (void)view; + return LARDON3D_IMAGE_SORT_IMPORT_ORDER; +} + +const char * +lardon3d_image_view_filter(const Lardon3DImageView *view) +{ + (void)view; + return ""; +} + +const char * +lardon3d_image_view_sort_name(Lardon3DImageSort sort) +{ + (void)sort; + return "import"; +} + +size_t +lardon3d_image_view_selection(const Lardon3DImageView *view) +{ + (void)view; + return 0; +} + +size_t +lardon3d_image_view_offset(const Lardon3DImageView *view) +{ + (void)view; + return 0; +} + +const char * +lardon3d_task_state_name(Lardon3DTaskState state) +{ + (void)state; + return "RUNNING"; +} + +const char * +lardon3d_ssd_state_name(Lardon3DSsdState state) +{ + static const char *const names[] = { + "ABSENT", "DETECTED", "ENABLING", "ENABLED", "IN_USE", + "DRAINING", "SAFE_TO_UNPLUG", "ERROR", + }; + return state >= LARDON3D_SSD_ABSENT && state <= LARDON3D_SSD_ERROR + ? names[state] : "UNKNOWN"; +} + +const char * +lardon3d_tui_ssd_action_name(Lardon3DTuiSsdAction action) +{ + (void)action; + return "OBSERVE"; +} + +static bool +separator_intact(int rows) +{ + chtype observed = mvwinch(stdscr, rows - 4, 5); + return (observed & A_CHARTEXT) == (ACS_HLINE & A_CHARTEXT); +} + +static bool +row_contains(int row, int columns, const char *needle) +{ + char line[256] = {0}; + int width = columns < (int)sizeof(line) - 1 + ? columns : (int)sizeof(line) - 1; + return mvwinnstr(stdscr, row, 0, line, width) != ERR + && strstr(line, needle) != NULL; +} + +static bool +screen_contains(int rows, int columns, const char *needle) +{ + for (int row = 0; row < rows; ++row) { + if (row_contains(row, columns, needle)) { + return true; + } + } + return false; +} + +static bool +draw_supported_size( + int rows, + int columns, + Lardon3DAppState *state, + Lardon3DRuntimeSnapshot *runtime, + Lardon3DTuiSsdAsyncSnapshot *operation, + Lardon3DTuiOpticsSnapshot *optics, + Lardon3DTuiPalette *palette, + Lardon3DTuiInteractionMode interaction_mode +) +{ + CHECK(resizeterm(rows, columns) == OK); + for (int screen = LARDON3D_SCREEN_HOME; + screen <= LARDON3D_SCREEN_SSD; ++screen) { + state->screen = (Lardon3DScreen)screen; + const char *input = interaction_mode + == LARDON3D_TUI_INTERACTION_TEXT_INPUT + ? "50" : NULL; + lardon3d_layout_draw_runtime(state, input, "test input", NULL, + runtime, operation, optics, 0, palette, interaction_mode, + rows, columns); + CHECK(separator_intact(rows)); + CHECK(row_contains(rows - 2, columns, "F10 SSD")); + if (screen == LARDON3D_SCREEN_HOME) { + CHECK(row_contains(8, columns, "NOT_APPLICABLE")); + if (rows < 30 || columns < 100) { + CHECK(row_contains(10, columns, "50%")); + CHECK(row_contains(10, columns, "[####----]")); + } + } + if (screen == LARDON3D_SCREEN_SSD) { + CHECK(row_contains(4, columns, "SAFE TO UNPLUG")); + } + } + return true; +} + +static bool +run_test(void) +{ + CHECK(setenv("TERM", "xterm-256color", 1) == 0); + FILE *output = tmpfile(); + FILE *input = tmpfile(); + CHECK(output && input); + SCREEN *screen = newterm(NULL, output, input); + CHECK(screen); + (void)set_term(screen); + + Lardon3DAppState state = {.running = true}; + (void)snprintf(state.status_message, sizeof(state.status_message), + "status"); + Lardon3DRuntimeSnapshot runtime = { + .task_count = 1, + .active_task_known = true, + .active_task_index = 0, + .active_progress = { + .present = true, + .durable_counts_known = true, + .completed = 2, + .total = 4, + .percentage_known = true, + .percentage = 50, + .eta_state = LARDON3D_TUI_ETA_CALCULATING, + }, + .resources = { + .valid = true, + .governor_pressure = LARDON3D_RESOURCE_PRESSURE_YELLOW, + .cpu_logical_total = 8, + .cpu_active = 2, + .cpu_admitted_known = true, + .cpu_admitted = 2, + .cpu_available = 4, + .gpu_present = true, + .gpu_backend = LARDON3D_TUI_GPU_BACKEND_ORB_VULKAN, + .ram_total_bytes = UINT64_C(16) << 30, + .ram_available_bytes = UINT64_C(8) << 30, + .ram_reserve_bytes = UINT64_C(3) << 30, + .swap_total_known = true, + .swap_used_known = true, + .batch_known = true, + .batch_size = 8, + .inflight_known = true, + .inflight_limit = 2, + .helpers_known = true, + .helper_limit = 1, + }, + .ssd_controller_available = true, + .ssd = { + .state = LARDON3D_SSD_SAFE_TO_UNPLUG, + .model_known = true, + .pairing_valid = true, + }, + }; + runtime.tasks[0] = (Lardon3DTaskObservation) { + .id = 1, + .state = TASK_RUNNING, + .progress = 50, + .durable_progress_known = true, + .durable_completed = 2, + .durable_total = 4, + }; + (void)snprintf(runtime.tasks[0].name, + sizeof(runtime.tasks[0].name), "active"); + (void)snprintf(runtime.resources.governor_reason, + sizeof(runtime.resources.governor_reason), "caution band"); + (void)snprintf(runtime.resources.cpu_reason, + sizeof(runtime.resources.cpu_reason), "whole cores"); + (void)snprintf(runtime.resources.gpu_backend_reason, + sizeof(runtime.resources.gpu_backend_reason), "Vulkan ready"); + for (size_t index = 0; index < LARDON3D_TUI_STAGE_COUNT; ++index) { + runtime.stages[index].stage = (Lardon3DTuiStage)index; + runtime.stages[index].state = index == LARDON3D_TUI_STAGE_DENSE + ? LARDON3D_TUI_STAGE_NOT_APPLICABLE + : LARDON3D_TUI_STAGE_READY; + } + Lardon3DTuiSsdAsyncSnapshot operation = { + .running = true, + .action = LARDON3D_TUI_SSD_ACTION_OBSERVE, + }; + Lardon3DTuiOpticsSnapshot optics = { + .project_bound = true, + .capture_status = LARDON3D_TUI_OPTICS_UNRESOLVED, + }; + Lardon3DTuiPalette palette; + lardon3d_tui_palette_plan(false, 0, &palette); + + CHECK(draw_supported_size(30, 100, &state, &runtime, &operation, + &optics, &palette, LARDON3D_TUI_INTERACTION_IDLE)); + CHECK(draw_supported_size(20, 72, &state, &runtime, &operation, + &optics, &palette, LARDON3D_TUI_INTERACTION_IDLE)); + CHECK(draw_supported_size(15, 60, &state, &runtime, &operation, + &optics, &palette, LARDON3D_TUI_INTERACTION_IDLE)); + /* This final growth proves resize decisions are not sticky. */ + CHECK(draw_supported_size(30, 100, &state, &runtime, &operation, + &optics, &palette, LARDON3D_TUI_INTERACTION_IDLE)); + + state.screen = LARDON3D_SCREEN_RESOURCES; + CHECK(resizeterm(30, 100) == OK); + lardon3d_layout_draw_runtime(&state, NULL, "", NULL, &runtime, + &operation, &optics, 0, &palette, LARDON3D_TUI_INTERACTION_IDLE, + 30, 100); + CHECK(row_contains(16, 100, "Governor SSD: UNREGISTERED")); + runtime.resources.external_storage_registered = true; + runtime.resources.external_storage_status = + LARDON3D_RESOURCE_EXTERNAL_STORAGE_IN_USE; + runtime.resources.scratch_new_allocations_allowed = false; + runtime.resources.scratch_total_known = true; + runtime.resources.scratch_free_known = true; + runtime.resources.scratch_total_bytes = UINT64_C(400) << 30; + runtime.resources.scratch_free_bytes = UINT64_C(350) << 30; + runtime.resources.scratch_leases = 2; + runtime.resources.external_swap_total_known = true; + runtime.resources.external_swap_used_known = true; + runtime.resources.external_swap_total_bytes = UINT64_C(8) << 30; + runtime.resources.external_swap_used_bytes = UINT64_C(1) << 30; + (void)snprintf(runtime.resources.external_storage_identity, + sizeof(runtime.resources.external_storage_identity), "drive-1"); + (void)snprintf(runtime.resources.external_storage_reason, + sizeof(runtime.resources.external_storage_reason), "drain pending"); + lardon3d_layout_draw_runtime(&state, NULL, "", NULL, &runtime, + &operation, &optics, 0, &palette, + LARDON3D_TUI_INTERACTION_IDLE, 30, 100); + CHECK(row_contains(16, 100, "Governor SSD IN_USE")); + CHECK(row_contains(16, 100, "alloc non")); + CHECK(row_contains(16, 100, "leases 2")); + CHECK(row_contains(17, 100, "swap total/used")); + CHECK(resizeterm(15, 60) == OK); + lardon3d_layout_draw_runtime(&state, NULL, "", NULL, &runtime, + &operation, &optics, 0, &palette, + LARDON3D_TUI_INTERACTION_IDLE, 15, 60); + CHECK(row_contains(10, 60, "Governor SSD IN_USE")); + CHECK(row_contains(13, 60, "F10 SSD")); + CHECK(resizeterm(30, 100) == OK); + + runtime.resources.cpu_admitted_known = false; + lardon3d_layout_draw_runtime(&state, NULL, "", NULL, &runtime, + &operation, &optics, 0, &palette, LARDON3D_TUI_INTERACTION_IDLE, + 30, 100); + CHECK(row_contains(6, 100, "/UNKNOWN/")); + runtime.resources.cpu_admitted_known = true; + + state.screen = LARDON3D_SCREEN_SSD; + runtime.ssd_controller_available = false; + CHECK(resizeterm(15, 60) == OK); + lardon3d_layout_draw_runtime(&state, NULL, "", NULL, &runtime, + &operation, &optics, 0, &palette, LARDON3D_TUI_INTERACTION_IDLE, + 15, 60); + CHECK(row_contains(4, 60, "UNKNOWN")); + CHECK(!row_contains(10, 60, "INACTIVE")); + runtime.ssd_controller_available = true; + + operation.controller_snapshot_actionable = true; + runtime.ssd = (Lardon3DSsdSnapshot) { + .state = LARDON3D_SSD_ERROR, + .pairing_valid = true, + }; + (void)snprintf(runtime.ssd.reason, sizeof(runtime.ssd.reason), + "EXACT HAZARD REASON"); + CHECK(resizeterm(15, 60) == OK); + lardon3d_layout_draw_runtime(&state, NULL, "", NULL, &runtime, + &operation, &optics, 0, &palette, LARDON3D_TUI_INTERACTION_IDLE, + 15, 60); + CHECK(row_contains(6, 60, "EXACT HAZARD REASON")); + + operation.running = true; + operation.action = LARDON3D_TUI_SSD_ACTION_ENABLE; + lardon3d_layout_draw_runtime(&state, NULL, "", NULL, &runtime, + &operation, &optics, 0, &palette, LARDON3D_TUI_INTERACTION_IDLE, + 15, 60); + CHECK(row_contains(4, 60, "ENABLING")); + operation.running = false; + + operation.controller_snapshot_known = true; + operation.controller_snapshot_actionable = false; + operation.controller_snapshot = (Lardon3DSsdSnapshot) { + .state = LARDON3D_SSD_ERROR, + }; + runtime.ssd = operation.controller_snapshot; + lardon3d_layout_draw_runtime(&state, NULL, "", NULL, &runtime, + &operation, &optics, 0, &palette, LARDON3D_TUI_INTERACTION_IDLE, + 15, 60); + CHECK(row_contains(7, 60, "UNKNOWN")); + CHECK(!row_contains(10, 60, "INACTIVE")); + CHECK(!row_contains(10, 60, "UNMOUNTED")); + + state.screen = LARDON3D_SCREEN_PROJECTS; + CHECK(resizeterm(30, 100) == OK); + lardon3d_layout_draw_runtime(&state, "project", "name", NULL, &runtime, + &operation, &optics, 0, &palette, + LARDON3D_TUI_INTERACTION_TEXT_INPUT, 30, 100); + CHECK(row_contains(28, 100, "Enter valider")); + CHECK(row_contains(28, 100, "ESC annuler")); + CHECK(!row_contains(28, 100, "Q quitter")); + CHECK(resizeterm(15, 60) == OK); + lardon3d_layout_draw_runtime(&state, "project", "name", NULL, &runtime, + &operation, &optics, 0, &palette, + LARDON3D_TUI_INTERACTION_TEXT_INPUT, 15, 60); + CHECK(row_contains(13, 60, "F10 SSD")); + CHECK(row_contains(13, 60, "Enter valider")); + + state.screen = LARDON3D_SCREEN_IMPORT; + Lardon3DImportTaskSnapshot import = { + .status = LARDON3D_IMPORT_TASK_RUNNING, + .processed = 1, + .total = 2, + }; + CHECK(resizeterm(30, 100) == OK); + lardon3d_layout_draw_runtime(&state, NULL, "", &import, &runtime, + &operation, &optics, 0, &palette, + LARDON3D_TUI_INTERACTION_IMPORT_RUNNING, 30, 100); + CHECK(row_contains(28, 100, "X annuler l'import")); + CHECK(row_contains(28, 100, "Q/ESC")); + CHECK(row_contains(9, 100, "X : annuler")); + CHECK(resizeterm(15, 60) == OK); + lardon3d_layout_draw_runtime(&state, NULL, "", &import, &runtime, + &operation, &optics, 0, &palette, + LARDON3D_TUI_INTERACTION_IMPORT_RUNNING, 15, 60); + CHECK(row_contains(13, 60, "F10 SSD")); + CHECK(row_contains(13, 60, "X annuler")); + + state.screen = LARDON3D_SCREEN_TASKS; + CHECK(resizeterm(30, 100) == OK); + runtime.task_count = 1; + runtime.tasks[0] = (Lardon3DTaskObservation) { + .id = 7, + .state = TASK_COMPLETED, + .progress = 100, + .has_task_kind = true, + .task_kind_version = 1, + .durable_progress_known = true, + .durable_completed = 2, + .durable_total = 7, + }; + (void)snprintf(runtime.tasks[0].name, + sizeof(runtime.tasks[0].name), "durable incomplete"); + (void)snprintf(runtime.tasks[0].task_kind, + sizeof(runtime.tasks[0].task_kind), "raw.develop"); + lardon3d_layout_draw_runtime(&state, NULL, "", NULL, &runtime, + &operation, &optics, 0, &palette, LARDON3D_TUI_INTERACTION_IDLE, + 30, 100); + CHECK(screen_contains(30, 100, "2/7")); + CHECK(screen_contains(30, 100, "INTEGRITY ERROR")); + runtime.tasks[0].durable_progress_known = false; + lardon3d_layout_draw_runtime(&state, NULL, "", NULL, &runtime, + &operation, &optics, 0, &palette, LARDON3D_TUI_INTERACTION_IDLE, + 30, 100); + CHECK(screen_contains(30, 100, "Progression scientifique")); + CHECK(!screen_contains(30, 100, "Progression runtime")); + + state.screen = LARDON3D_SCREEN_HOME; + runtime.active_task_known = true; + runtime.active_task_index = 0; + runtime.active_progress = (Lardon3DTuiProgressView) { + .present = true, + .task_id = 7, + .runtime_percentage = true, + .percentage_known = true, + .percentage = 50, + .throughput_known = true, + .units_per_second = 2.5, + .eta_state = LARDON3D_TUI_ETA_CALCULATING, + }; + runtime.tasks[0].has_task_kind = false; + runtime.tasks[0].state = TASK_RUNNING; + lardon3d_layout_draw_runtime(&state, NULL, "", NULL, &runtime, + &operation, &optics, 0, &palette, LARDON3D_TUI_INTERACTION_IDLE, + 30, 100); + CHECK(screen_contains(30, 100, "2.5%/s")); + CHECK(!screen_contains(30, 100, "2.5 unité/s")); + + state.screen = LARDON3D_SCREEN_OPTICS; + optics = (Lardon3DTuiOpticsSnapshot) {0}; + (void)snprintf(optics.message, sizeof(optics.message), + "Cannot list camera bodies: BUSY"); + lardon3d_layout_draw_runtime(&state, NULL, "", NULL, &runtime, + &operation, &optics, 0, &palette, LARDON3D_TUI_INTERACTION_IDLE, + 30, 100); + CHECK(screen_contains(30, 100, "BUSY")); + CHECK(screen_contains(30, 100, "R :")); + CHECK(screen_contains(30, 100, "Project DB")); + + optics = (Lardon3DTuiOpticsSnapshot) { + .project_bound = true, + .active_pane = LARDON3D_TUI_OPTICS_PANE_CALIBRATION, + .calibration_count = LARDON3D_TUI_OPTICS_PAGE_CAPACITY, + .calibrations_have_next = true, + }; + optics.calibrations[0].calibration_profile_id = 1; + optics.calibrations[0].profile_version = 1; + (void)snprintf(optics.calibrations[0].name, + sizeof(optics.calibrations[0].name), "first profile"); + lardon3d_layout_draw_runtime(&state, NULL, "", NULL, &runtime, + &operation, &optics, 0, &palette, LARDON3D_TUI_INTERACTION_IDLE, + 30, 100); + CHECK(screen_contains(30, 100, "1/16 affich")); + CHECK(screen_contains(30, 100, ", suite")); + CHECK(screen_contains(30, 100, "[ premi")); + CHECK(screen_contains(30, 100, "] page suivante")); + + /* The legacy symbol accepts only baseline-sized objects. This call also + * proves a NULL task array with a nonzero count is safely treated empty. */ + Lardon3DTaskSnapshot legacy = { + .id = 55, + .state = TASK_RUNNING, + .progress = 25, + }; + Lardon3DTaskQueueSummary legacy_summary = {.running = 1, .total = 1}; + Lardon3DResourceAvailability legacy_resources = {.cpu_available = 1}; + state.screen = LARDON3D_SCREEN_TASKS; + lardon3d_layout_draw(&state, NULL, "", NULL, &legacy, 1, + &legacy_summary, &legacy_resources, 30, 100); + CHECK(screen_contains(30, 100, "#55")); + lardon3d_layout_draw(&state, NULL, "", NULL, NULL, 1, + &legacy_summary, &legacy_resources, 30, 100); + CHECK(screen_contains(30, 100, "Aucune t")); + + CHECK(resizeterm(14, 59) == OK); + lardon3d_layout_draw_runtime(&state, NULL, "", NULL, &runtime, + &operation, &optics, 0, &palette, LARDON3D_TUI_INTERACTION_IDLE, + 14, 59); + char line[60] = {0}; + CHECK(mvwinnstr(stdscr, 7, 0, line, 59) != ERR); + CHECK(strstr(line, "Terminal trop petit") != NULL); + + (void)endwin(); + delscreen(screen); + CHECK(fclose(input) == 0); + CHECK(fclose(output) == 0); + return true; +} + +int +main(void) +{ + return run_test() ? EXIT_SUCCESS : EXIT_FAILURE; +} diff --git a/tests/test_tui_model.c b/tests/test_tui_model.c new file mode 100644 index 0000000..8427cca --- /dev/null +++ b/tests/test_tui_model.c @@ -0,0 +1,383 @@ +#include +#include +#include +#include +#include + +#include + +#define CHECK(condition) \ + do { \ + if (!(condition)) { \ + (void)fprintf(stderr, "TUI model failure line %d: %s\n", \ + __LINE__, #condition); \ + return false; \ + } \ + } while (0) + +/* tui_model only needs the bounded state label; production supplies the same + * public Task helper without changing any model decision. */ +const char * +lardon3d_task_state_name(Lardon3DTaskState state) +{ + static const char *const names[] = { + "PENDING", "RUNNING", "PAUSED", "CANCELLED", "FAILED", "COMPLETED", + }; + return state >= TASK_PENDING && state <= TASK_COMPLETED + ? names[state] : "UNKNOWN"; +} + +static Lardon3DTaskObservation +typed_task(const char *kind, Lardon3DTaskState state, unsigned int progress) +{ + Lardon3DTaskObservation task = { + .id = 7, + .has_task_kind = true, + .task_kind_version = 1, + .progress = progress, + .state = state, + }; + (void)snprintf(task.name, sizeof(task.name), "test task"); + (void)snprintf(task.task_kind, sizeof(task.task_kind), "%s", kind); + (void)snprintf(task.message, sizeof(task.message), "observed"); + return task; +} + +static bool +test_viewports_and_palette(void) +{ + CHECK(lardon3d_tui_viewport_classify(30, 100) + == LARDON3D_TUI_VIEWPORT_FULL); + CHECK(lardon3d_tui_viewport_classify(20, 72) + == LARDON3D_TUI_VIEWPORT_COMPACT); + CHECK(lardon3d_tui_viewport_classify(15, 60) + == LARDON3D_TUI_VIEWPORT_COMPACT); + CHECK(lardon3d_tui_viewport_classify(14, 60) + == LARDON3D_TUI_VIEWPORT_TOO_SMALL); + CHECK(lardon3d_tui_viewport_classify(15, 59) + == LARDON3D_TUI_VIEWPORT_TOO_SMALL); + + Lardon3DTuiPalette palette; + lardon3d_tui_palette_plan(false, 0, &palette); + CHECK(!palette.color_enabled); + CHECK(palette.color_pair[LARDON3D_TUI_SEMANTIC_ERROR] == 0); + CHECK((palette.attributes[LARDON3D_TUI_SEMANTIC_ERROR] + & LARDON3D_TUI_STYLE_BOLD) != 0); + CHECK((palette.attributes[LARDON3D_TUI_SEMANTIC_DIM] + & LARDON3D_TUI_STYLE_DIM) != 0); + + lardon3d_tui_palette_plan(true, 4, &palette); + CHECK(palette.color_enabled); + CHECK(palette.color_pair[LARDON3D_TUI_SEMANTIC_HEALTHY] == 1); + CHECK(palette.color_pair[LARDON3D_TUI_SEMANTIC_ERROR] == 3); + CHECK(palette.color_pair[LARDON3D_TUI_SEMANTIC_GPU] == 0); + lardon3d_tui_palette_plan(true, 16, &palette); + CHECK(palette.color_pair[LARDON3D_TUI_SEMANTIC_GPU] == 4); + CHECK(palette.color_pair[LARDON3D_TUI_SEMANTIC_CPU] == 5); + CHECK(palette.color_pair[LARDON3D_TUI_SEMANTIC_SSD] == 6); + return true; +} + +static bool +test_stage_matrix(void) +{ + static const struct { + const char *kind; + Lardon3DTuiStage stage; + } cases[] = { + {"acquisition_campaign.run", LARDON3D_TUI_STAGE_ACQUISITION}, + {"raw.develop", LARDON3D_TUI_STAGE_RAW}, + {"photo_quality.triage", LARDON3D_TUI_STAGE_QUALITY}, + {"features.extract", LARDON3D_TUI_STAGE_FEATURES}, + {"features.extract.sift", LARDON3D_TUI_STAGE_FEATURES}, + {"features.extract.rootsift", LARDON3D_TUI_STAGE_FEATURES}, + {"visual_index.update", LARDON3D_TUI_STAGE_VISUAL_INDEX}, + {"candidate_pair.generate", LARDON3D_TUI_STAGE_CANDIDATE}, + {"matcher.run", LARDON3D_TUI_STAGE_MATCHER}, + {"geometric_verifier.run", LARDON3D_TUI_STAGE_GV}, + {"track_builder.run", LARDON3D_TUI_STAGE_TRACKS}, + {"sparse_sfm.run", LARDON3D_TUI_STAGE_SPARSE_SFM}, + {"incremental_reconstruction.run", LARDON3D_TUI_STAGE_SPARSE_SFM}, + }; + for (size_t index = 0; index < sizeof(cases) / sizeof(cases[0]); ++index) { + Lardon3DTaskObservation task = typed_task( + cases[index].kind, TASK_RUNNING, 25); + Lardon3DTuiStageView stages[LARDON3D_TUI_STAGE_COUNT]; + lardon3d_tui_stage_views_build(true, &task, 1, + LARDON3D_RESOURCE_PRESSURE_GREEN, stages); + CHECK(stages[cases[index].stage].state + == LARDON3D_TUI_STAGE_RUNNING); + CHECK(stages[LARDON3D_TUI_STAGE_DENSE].state + == LARDON3D_TUI_STAGE_NOT_APPLICABLE); + } + + static const struct { + Lardon3DTaskState task_state; + Lardon3DResourcePressure pressure; + Lardon3DTuiStageState expected; + } states[] = { + {TASK_PENDING, LARDON3D_RESOURCE_PRESSURE_GREEN, + LARDON3D_TUI_STAGE_QUEUED}, + {TASK_PENDING, LARDON3D_RESOURCE_PRESSURE_YELLOW, + LARDON3D_TUI_STAGE_THROTTLED}, + {TASK_RUNNING, LARDON3D_RESOURCE_PRESSURE_RED, + LARDON3D_TUI_STAGE_RUNNING}, + {TASK_PAUSED, LARDON3D_RESOURCE_PRESSURE_GREEN, + LARDON3D_TUI_STAGE_BLOCKED}, + {TASK_CANCELLED, LARDON3D_RESOURCE_PRESSURE_GREEN, + LARDON3D_TUI_STAGE_BLOCKED}, + {TASK_COMPLETED, LARDON3D_RESOURCE_PRESSURE_GREEN, + LARDON3D_TUI_STAGE_COMPLETE}, + {TASK_FAILED, LARDON3D_RESOURCE_PRESSURE_GREEN, + LARDON3D_TUI_STAGE_FAILED}, + }; + for (size_t index = 0; index < sizeof(states) / sizeof(states[0]); ++index) { + Lardon3DTaskObservation task = typed_task( + "raw.develop", states[index].task_state, 20); + Lardon3DTuiStageView stages[LARDON3D_TUI_STAGE_COUNT]; + lardon3d_tui_stage_views_build(true, &task, 1, + states[index].pressure, stages); + CHECK(stages[LARDON3D_TUI_STAGE_RAW].state + == states[index].expected); + } + + /* Live execution wins over a newer pending record of the same kind; the + * Queue deliberately exposes newest live submissions first. */ + Lardon3DTaskObservation concurrent[2] = { + typed_task("matcher.run", TASK_PENDING, 0), + typed_task("matcher.run", TASK_RUNNING, 25), + }; + Lardon3DTuiStageView concurrent_stages[LARDON3D_TUI_STAGE_COUNT]; + lardon3d_tui_stage_views_build(true, concurrent, 2, + LARDON3D_RESOURCE_PRESSURE_GREEN, concurrent_stages); + CHECK(concurrent_stages[LARDON3D_TUI_STAGE_MATCHER].state + == LARDON3D_TUI_STAGE_RUNNING); + + Lardon3DTuiStageView empty[LARDON3D_TUI_STAGE_COUNT]; + lardon3d_tui_stage_views_build(false, NULL, 0, + LARDON3D_RESOURCE_PRESSURE_GREEN, empty); + CHECK(empty[LARDON3D_TUI_STAGE_ACQUISITION].state + == LARDON3D_TUI_STAGE_NOT_READY); + CHECK(empty[LARDON3D_TUI_STAGE_DENSE].state + == LARDON3D_TUI_STAGE_NOT_APPLICABLE); + return true; +} + +static bool +test_progress_eta(void) +{ + Lardon3DTuiProgressTracker tracker = {0}; + Lardon3DTuiProgressView view; + Lardon3DTuiProgressSample sample = { + .task_id = 42, + .monotonic_ns = UINT64_C(1000000000), + .task_state = TASK_RUNNING, + .typed_task = true, + .progress_percent = 20, + .durable_counts_known = true, + .durable_completed = 20, + .durable_total = 100, + }; + CHECK(lardon3d_tui_progress_update(&tracker, &sample, &view)); + CHECK(view.percentage == 20); + CHECK(view.eta_state == LARDON3D_TUI_ETA_CALCULATING); + CHECK(view.resumed_prefix_excluded); + CHECK(!view.throughput_known); + + sample.monotonic_ns += UINT64_C(1000000000); + sample.durable_completed = 30; + CHECK(lardon3d_tui_progress_update(&tracker, &sample, &view)); + CHECK(view.eta_state == LARDON3D_TUI_ETA_CALCULATING); + CHECK(!view.throughput_known); + + sample.monotonic_ns += UINT64_C(1000000000); + sample.durable_completed = 50; + CHECK(lardon3d_tui_progress_update(&tracker, &sample, &view)); + CHECK(view.eta_state == LARDON3D_TUI_ETA_KNOWN); + CHECK(view.eta_seconds == 4); + CHECK(view.units_per_second > 13.4 && view.units_per_second < 13.6); + + sample.monotonic_ns += UINT64_C(5000000000); + CHECK(lardon3d_tui_progress_update(&tracker, &sample, &view)); + CHECK(view.eta_state == LARDON3D_TUI_ETA_STALLED); + sample.pressure_limited = true; + CHECK(lardon3d_tui_progress_update(&tracker, &sample, &view)); + CHECK(view.eta_state == LARDON3D_TUI_ETA_THROTTLED); + + sample.pressure_limited = false; + sample.task_state = TASK_COMPLETED; + sample.durable_completed = 100; + CHECK(lardon3d_tui_progress_update(&tracker, &sample, &view)); + CHECK(view.percentage == 100); + CHECK(view.eta_state == LARDON3D_TUI_ETA_COMPLETE); + CHECK(view.eta_seconds == 0); + + sample = (Lardon3DTuiProgressSample) { + .task_id = 44, + .monotonic_ns = UINT64_C(19000000000), + .task_state = TASK_RUNNING, + .typed_task = true, + .progress_percent = 99, + .durable_counts_known = true, + .durable_completed = UINT64_MAX - 1, + .durable_total = UINT64_MAX, + }; + CHECK(lardon3d_tui_progress_update(&tracker, &sample, &view)); + CHECK(view.percentage == 99); + + sample = (Lardon3DTuiProgressSample) { + .task_id = 45, + .monotonic_ns = UINT64_C(30000000000), + .task_state = TASK_PENDING, + .typed_task = true, + .progress_percent = 0, + .durable_counts_known = true, + .durable_total = 100, + }; + CHECK(lardon3d_tui_progress_update(&tracker, &sample, &view)); + sample.monotonic_ns += UINT64_C(10000000000); + sample.task_state = TASK_RUNNING; + sample.durable_completed = 10; + CHECK(lardon3d_tui_progress_update(&tracker, &sample, &view)); + CHECK(!view.throughput_known + && view.eta_state == LARDON3D_TUI_ETA_CALCULATING); + sample.monotonic_ns += UINT64_C(5000000000); + CHECK(lardon3d_tui_progress_update(&tracker, &sample, &view)); + CHECK(view.eta_state == LARDON3D_TUI_ETA_STALLED); + + sample = (Lardon3DTuiProgressSample) { + .task_id = 43, + .monotonic_ns = UINT64_C(20000000000), + .task_state = TASK_PENDING, + .progress_percent = 0, + }; + CHECK(lardon3d_tui_progress_update(&tracker, &sample, &view)); + CHECK(view.eta_state == LARDON3D_TUI_ETA_INDETERMINATE); + CHECK(!view.durable_counts_known); + + sample = (Lardon3DTuiProgressSample) { + .task_id = 46, + .monotonic_ns = UINT64_C(40000000000), + .task_state = TASK_COMPLETED, + .typed_task = true, + .progress_percent = 100, + .durable_counts_known = true, + .durable_completed = 2, + .durable_total = 7, + }; + CHECK(lardon3d_tui_progress_update(&tracker, &sample, &view)); + CHECK(view.integrity_error); + CHECK(view.durable_counts_known && view.completed == 2 && view.total == 7); + CHECK(view.percentage_known && view.percentage == 28); + CHECK(view.eta_state == LARDON3D_TUI_ETA_INDETERMINATE); + + sample = (Lardon3DTuiProgressSample) { + .task_id = 47, + .monotonic_ns = UINT64_C(41000000000), + .task_state = TASK_COMPLETED, + .typed_task = true, + .progress_percent = 100, + }; + CHECK(lardon3d_tui_progress_update(&tracker, &sample, &view)); + CHECK(!view.percentage_known && !view.runtime_percentage); + CHECK(view.eta_state == LARDON3D_TUI_ETA_INDETERMINATE); + + sample = (Lardon3DTuiProgressSample) { + .task_id = 48, + .monotonic_ns = UINT64_C(42000000000), + .task_state = TASK_COMPLETED, + .progress_percent = 100, + }; + CHECK(lardon3d_tui_progress_update(&tracker, &sample, &view)); + CHECK(view.percentage_known && view.percentage == 100); + CHECK(view.runtime_percentage); + CHECK(view.eta_state == LARDON3D_TUI_ETA_COMPLETE); + CHECK(!lardon3d_tui_progress_update(&tracker, + &(Lardon3DTuiProgressSample) { + .task_id = 1, + .progress_percent = 101, + }, &view)); + return true; +} + +static bool +test_durable_stage_truth_and_keys(void) +{ + Lardon3DTaskObservation incomplete = typed_task( + "raw.develop", TASK_COMPLETED, 100); + incomplete.durable_progress_known = true; + incomplete.durable_completed = 2; + incomplete.durable_total = 7; + Lardon3DTuiStageView stages[LARDON3D_TUI_STAGE_COUNT]; + lardon3d_tui_stage_views_build(true, &incomplete, 1, + LARDON3D_RESOURCE_PRESSURE_GREEN, stages); + CHECK(stages[LARDON3D_TUI_STAGE_RAW].state + == LARDON3D_TUI_STAGE_BLOCKED); + CHECK(strstr(stages[LARDON3D_TUI_STAGE_RAW].reason, "2/7") != NULL); + + Lardon3DTaskObservation unknown = typed_task( + "raw.develop", TASK_COMPLETED, 100); + lardon3d_tui_stage_views_build(true, &unknown, 1, + LARDON3D_RESOURCE_PRESSURE_GREEN, stages); + CHECK(stages[LARDON3D_TUI_STAGE_RAW].state + == LARDON3D_TUI_STAGE_COMPLETE); + CHECK(strstr(stages[LARDON3D_TUI_STAGE_RAW].reason, + "indeterminate") != NULL); + + Lardon3DTuiKeyContract keys = lardon3d_tui_key_contract( + LARDON3D_TUI_INTERACTION_TEXT_INPUT); + CHECK(keys.enter && keys.escape && keys.f10); + CHECK(!keys.cancel_import && !keys.quit && !keys.navigate); + keys = lardon3d_tui_key_contract( + LARDON3D_TUI_INTERACTION_IMPORT_RUNNING); + CHECK(keys.cancel_import && keys.f10); + CHECK(!keys.enter && !keys.escape && !keys.quit && !keys.navigate); + keys = lardon3d_tui_key_contract(LARDON3D_TUI_INTERACTION_IDLE); + CHECK(keys.escape && keys.f10 && keys.quit && keys.navigate); + CHECK(!keys.enter && !keys.cancel_import); + return true; +} + +static bool +test_labels_and_optics(void) +{ + CHECK(strcmp(lardon3d_tui_pressure_name( + LARDON3D_RESOURCE_PRESSURE_GREEN), "GREEN") == 0); + CHECK(strcmp(lardon3d_tui_pressure_name( + LARDON3D_RESOURCE_PRESSURE_YELLOW), "YELLOW") == 0); + CHECK(strcmp(lardon3d_tui_pressure_name( + LARDON3D_RESOURCE_PRESSURE_RED), "RED") == 0); + for (int backend = LARDON3D_TUI_GPU_BACKEND_UNKNOWN; + backend <= LARDON3D_TUI_GPU_BACKEND_MIXED; ++backend) { + CHECK(strcmp(lardon3d_tui_gpu_backend_name( + (Lardon3DTuiGpuBackendStatus)backend), "UNKNOWN") != 0 + || backend == LARDON3D_TUI_GPU_BACKEND_UNKNOWN); + } + CHECK(lardon3d_tui_optics_classify(false, false, false, false, 0, + false, false) == LARDON3D_TUI_OPTICS_NO_PROJECT); + CHECK(lardon3d_tui_optics_classify(true, false, false, true, 0, + false, false) == LARDON3D_TUI_OPTICS_UNRESOLVED); + CHECK(lardon3d_tui_optics_classify(true, true, true, true, 0, + false, false) == LARDON3D_TUI_OPTICS_CONFIGURATION_ONLY); + CHECK(lardon3d_tui_optics_classify(true, true, true, false, 2, + false, false) == LARDON3D_TUI_OPTICS_SELECTION_REQUIRED); + CHECK(lardon3d_tui_optics_classify(true, true, true, false, 1, + true, true) == LARDON3D_TUI_OPTICS_SELECTED); + CHECK(lardon3d_tui_optics_classify(true, true, true, false, 1, + true, false) == LARDON3D_TUI_OPTICS_INCOMPATIBLE); + CHECK(strstr(lardon3d_tui_optics_status_explanation( + LARDON3D_TUI_OPTICS_CONFIGURATION_ONLY, true), + "Manual/no-EXIF") != NULL); + return true; +} + +int +main(void) +{ + return test_viewports_and_palette() + && test_stage_matrix() + && test_progress_eta() + && test_durable_stage_truth_and_keys() + && test_labels_and_optics() + ? EXIT_SUCCESS : EXIT_FAILURE; +} diff --git a/tests/test_tui_optics.c b/tests/test_tui_optics.c new file mode 100644 index 0000000..3a5ecaf --- /dev/null +++ b/tests/test_tui_optics.c @@ -0,0 +1,285 @@ +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include + +#define CHECK(condition) \ + do { \ + if (!(condition)) { \ + (void)fprintf(stderr, "TUI optics failure line %d: %s\n", \ + __LINE__, #condition); \ + return false; \ + } \ + } while (0) + +static bool +copy_text(char *destination, size_t capacity, const char *source) +{ + int written = snprintf(destination, capacity, "%s", source); + return written >= 0 && (size_t)written < capacity; +} + +static bool +create_sparse_calibration( + Lardon3DProjectDb *database, + Lardon3DSparseCalibration *output +) +{ + Lardon3DSparseCalibration input = { + .model_kind = LARDON3D_SPARSE_SFM_CALIBRATION_KIND_PINHOLE, + .model_version = LARDON3D_SPARSE_SFM_CALIBRATION_VERSION, + .width = 6000, + .height = 4000, + .fx = 4500.0, + .fy = 4500.0, + .cx = 3000.0, + .cy = 2000.0, + .provenance_kind = LARDON3D_SPARSE_SFM_PROVENANCE_USER_EXPLICIT, + }; + input.provenance_fingerprint[0] = 0x5a; + return lardon3d_sparse_calibration_create(database, &input, output) + == LARDON3D_PROJECT_DB_OK; +} + +static bool +seed_campaign( + Lardon3DProjectDb *database, + uint64_t task_id, + uint64_t scanset_id, + uint32_t group_count +) +{ + Lardon3DTaskDurableSnapshot snapshot = { + .id = task_id, + .saved_state = TASK_PENDING, + .recovery_state = TASK_PENDING, + }; + CHECK(copy_text(snapshot.name, sizeof(snapshot.name), "TUI campaign")); + static const unsigned char request[] = {0x4c, 0x33, 0x44, 0x4f}; + Lardon3DProjectDbAcquisitionCampaignTask campaign = { + .task_id = task_id, + .scanset_id = scanset_id, + .group_count = group_count, + .request = request, + .request_size = sizeof(request), + }; + return lardon3d_project_db_record_acquisition_campaign_task( + database, &snapshot, LARDON3D_ACQUISITION_CAMPAIGN_TASK_KIND, + LARDON3D_ACQUISITION_CAMPAIGN_TASK_KIND_VERSION, NULL, + &campaign, 1) == LARDON3D_PROJECT_DB_OK; +} + +static bool +run_test(void) +{ + char directory[] = "/tmp/lardon3d-tui-optics-XXXXXX"; + CHECK(mkdtemp(directory)); + char path[512]; + int written = snprintf(path, sizeof(path), "%s/project.db", directory); + CHECK(written > 0 && (size_t)written < sizeof(path)); + char error[LARDON3D_PROJECT_DB_ERROR_CAPACITY]; + Lardon3DProjectDb *database = NULL; + CHECK(lardon3d_project_db_open(path, &database, error) + == LARDON3D_PROJECT_DB_OK); + + Lardon3DTuiOptics *optics = lardon3d_tui_optics_create(); + CHECK(optics); + Lardon3DTuiOpticsSnapshot view; + memset(&view, 0xa5, sizeof(view)); + CHECK(!lardon3d_tui_optics_snapshot(NULL, &view)); + CHECK(!view.project_bound && view.body_count == 0 && !view.message[0]); + CHECK(!lardon3d_tui_optics_snapshot(optics, NULL)); + CHECK(!lardon3d_tui_optics_bind(NULL, database)); + CHECK(!lardon3d_tui_optics_bind(optics, NULL)); + lardon3d_tui_optics_unbind(NULL); + lardon3d_tui_optics_destroy(NULL); + CHECK(lardon3d_tui_optics_snapshot(optics, &view)); + CHECK(!view.project_bound); + CHECK(view.capture_status == LARDON3D_TUI_OPTICS_NO_PROJECT); + CHECK(lardon3d_tui_optics_bind(optics, database)); + CHECK(lardon3d_tui_optics_snapshot(optics, &view)); + CHECK(view.project_bound); + CHECK(view.body_count == 0 && view.lens_count == 0); + + /* A bind failure preserves its exact operational class and no DB borrow. + * Releasing the lock then binding the same pointer proves explicit retry + * is not suppressed by pointer identity. */ + lardon3d_tui_optics_unbind(optics); + sqlite3 *locker = NULL; + CHECK(sqlite3_open_v2(path, &locker, + SQLITE_OPEN_READWRITE | SQLITE_OPEN_FULLMUTEX, NULL) == SQLITE_OK); + CHECK(sqlite3_exec(locker, "PRAGMA busy_timeout=0;BEGIN EXCLUSIVE;", + NULL, NULL, NULL) == SQLITE_OK); + CHECK(!lardon3d_tui_optics_bind(optics, database)); + CHECK(lardon3d_tui_optics_snapshot(optics, &view)); + CHECK(!view.project_bound && strstr(view.message, "BUSY") != NULL); + CHECK(sqlite3_exec(locker, "ROLLBACK;", NULL, NULL, NULL) == SQLITE_OK); + CHECK(sqlite3_close(locker) == SQLITE_OK); + CHECK(lardon3d_tui_optics_bind(optics, database)); + CHECK(lardon3d_tui_optics_snapshot(optics, &view)); + CHECK(view.project_bound); + + Lardon3DOpticalCameraBodyProfile body = {0}; + CHECK(copy_text(body.manufacturer, sizeof(body.manufacturer), "Generic")); + CHECK(copy_text(body.model, sizeof(body.model), "Test body")); + CHECK(copy_text(body.name, sizeof(body.name), "Immutable test body")); + CHECK(lardon3d_tui_optics_create_body(optics, &body)); + + Lardon3DOpticalLensProfile meike = { + .interface_kind = LARDON3D_OPTICAL_LENS_MANUAL, + .focal_range_kind = LARDON3D_OPTICAL_FOCAL_RANGE_PRIME, + .minimum_focal_um = 50000, + .maximum_focal_um = 50000, + }; + CHECK(copy_text(meike.manufacturer, sizeof(meike.manufacturer), "Meike")); + CHECK(copy_text(meike.model, sizeof(meike.model), "50mm F2")); + CHECK(copy_text(meike.name, sizeof(meike.name), "Meike manual 50mm")); + CHECK(lardon3d_tui_optics_create_lens(optics, &meike)); + CHECK(lardon3d_tui_optics_create_configuration(optics, true, 50000)); + CHECK(lardon3d_tui_optics_snapshot(optics, &view)); + CHECK(view.body_count == 1 && view.lens_count == 1); + CHECK(view.lenses[0].interface_kind == LARDON3D_OPTICAL_LENS_MANUAL); + CHECK(strstr(view.message, "configuration") != NULL); + uint64_t body_id = view.bodies[0].camera_body_profile_id; + uint64_t manual_lens_id = view.lenses[0].lens_profile_id; + uint64_t configuration_id = + view.configurations[0].optical_configuration_id; + CHECK(body_id && manual_lens_id && configuration_id); + + /* Manual/no-electronics profiles legitimately have no metadata alias. */ + Lardon3DOpticalLensAlias aliases[1]; + size_t alias_count = 99; + uint64_t next_alias = 99; + CHECK(lardon3d_optical_lens_alias_list(database, manual_lens_id, 0, + aliases, 1, &alias_count, &next_alias) == LARDON3D_PROJECT_DB_OK); + CHECK(alias_count == 0 && next_alias == 0); + + Lardon3DProjectDbScanSet scanset; + CHECK(lardon3d_project_db_create_scanset( + database, "TUI optics", &scanset) == LARDON3D_PROJECT_DB_OK); + Lardon3DProjectDbCapture capture; + CHECK(lardon3d_project_db_create_capture(database, scanset.scanset_id, + 1, &capture) == LARDON3D_PROJECT_DB_OK); + CHECK(seed_campaign(database, 100, scanset.scanset_id, 1)); + CHECK(lardon3d_tui_optics_assign_campaign_group(optics, 100, 1)); + Lardon3DOpticalCampaignGroupAssignment group_assignment; + CHECK(lardon3d_optical_campaign_group_load( + database, 100, 1, &group_assignment) == LARDON3D_PROJECT_DB_OK); + CHECK(group_assignment.optical_configuration_id == configuration_id); + + CHECK(!lardon3d_tui_optics_inspect_capture(optics, UINT64_C(999999))); + CHECK(lardon3d_tui_optics_snapshot(optics, &view)); + CHECK(strstr(view.message, "NOT_FOUND") != NULL); + CHECK(lardon3d_tui_optics_assign_capture(optics, capture.capture_id)); + CHECK(lardon3d_tui_optics_snapshot(optics, &view)); + CHECK(view.capture_status == LARDON3D_TUI_OPTICS_CONFIGURATION_ONLY); + CHECK(view.capture_lens_found); + CHECK(view.capture_lens.interface_kind == LARDON3D_OPTICAL_LENS_MANUAL); + CHECK(strstr(view.message, "Manual/no-EXIF") != NULL); + + Lardon3DSparseCalibration sparse; + CHECK(create_sparse_calibration(database, &sparse)); + Lardon3DOpticalCalibrationProfile profile = { + .optical_configuration_id = configuration_id, + .sparse_calibration_id = sparse.calibration_id, + .profile_version = 1, + .applicability = + LARDON3D_OPTICAL_CALIBRATION_EXACT_CONFIGURATION, + }; + CHECK(copy_text(profile.name, sizeof(profile.name), "Meike exact 50mm")); + CHECK(copy_text(profile.provenance, sizeof(profile.provenance), + "caller explicit test calibration")); + Lardon3DOpticalCalibrationProfile created_profile; + CHECK(lardon3d_optical_calibration_profile_create( + database, &profile, &created_profile) == LARDON3D_PROJECT_DB_OK); + CHECK(lardon3d_tui_optics_inspect_capture(optics, capture.capture_id)); + CHECK(lardon3d_tui_optics_snapshot(optics, &view)); + CHECK(view.calibration_count == 1); + CHECK(view.capture_status == LARDON3D_TUI_OPTICS_SELECTION_REQUIRED); + CHECK(lardon3d_tui_optics_select_capture_calibration(optics)); + CHECK(lardon3d_tui_optics_snapshot(optics, &view)); + CHECK(view.capture_status == LARDON3D_TUI_OPTICS_SELECTED); + CHECK(view.capture_selection_found); + CHECK(view.capture_selection.calibration_profile_id + == created_profile.calibration_profile_id); + + /* Compatible profiles remain bounded but every page is reachable. */ + for (size_t index = 0; index < LARDON3D_TUI_OPTICS_PAGE_CAPACITY; ++index) { + Lardon3DOpticalCalibrationProfile extra = profile; + CHECK(snprintf(extra.name, sizeof(extra.name), "Extra profile %02zu", + index) > 0); + Lardon3DOpticalCalibrationProfile extra_output; + CHECK(lardon3d_optical_calibration_profile_create( + database, &extra, &extra_output) == LARDON3D_PROJECT_DB_OK); + } + CHECK(lardon3d_tui_optics_inspect_capture(optics, capture.capture_id)); + CHECK(lardon3d_tui_optics_snapshot(optics, &view)); + CHECK(view.calibration_count == LARDON3D_TUI_OPTICS_PAGE_CAPACITY); + CHECK(view.calibrations_have_next); + CHECK(lardon3d_tui_optics_select_pane( + optics, LARDON3D_TUI_OPTICS_PANE_CALIBRATION)); + CHECK(lardon3d_tui_optics_page( + optics, LARDON3D_TUI_OPTICS_PANE_CALIBRATION, true)); + CHECK(lardon3d_tui_optics_snapshot(optics, &view)); + CHECK(view.calibration_count == 1 && !view.calibrations_have_next); + CHECK(lardon3d_tui_optics_page( + optics, LARDON3D_TUI_OPTICS_PANE_CALIBRATION, false)); + CHECK(lardon3d_tui_optics_snapshot(optics, &view)); + CHECK(view.calibration_count == LARDON3D_TUI_OPTICS_PAGE_CAPACITY); + + Lardon3DOpticalCameraBodyAlias body_alias; + CHECK(lardon3d_optical_camera_body_alias_add(database, body_id, + "Exact Make", "Exact Model", &body_alias) == LARDON3D_PROJECT_DB_OK); + Lardon3DOpticalLensProfile electronic = { + .interface_kind = LARDON3D_OPTICAL_LENS_ELECTRONIC, + .focal_range_kind = LARDON3D_OPTICAL_FOCAL_RANGE_ZOOM, + .minimum_focal_um = 16000, + .maximum_focal_um = 50000, + }; + CHECK(copy_text(electronic.manufacturer, + sizeof(electronic.manufacturer), "Generic")); + CHECK(copy_text(electronic.model, sizeof(electronic.model), "16-50")); + CHECK(copy_text(electronic.name, sizeof(electronic.name), + "Electronic zoom")); + CHECK(lardon3d_tui_optics_create_lens(optics, &electronic)); + CHECK(lardon3d_tui_optics_snapshot(optics, &view)); + uint64_t electronic_id = view.lenses[0].lens_profile_id; + Lardon3DOpticalLensAlias lens_alias; + CHECK(lardon3d_optical_lens_alias_add(database, electronic_id, + "Exact Lens Make", "Exact Lens", &lens_alias) + == LARDON3D_PROJECT_DB_OK); + CHECK(lardon3d_tui_optics_lookup_exact_metadata(optics, + "Exact Make", "Exact Model", "Exact Lens Make", "Exact Lens")); + CHECK(lardon3d_tui_optics_snapshot(optics, &view)); + CHECK(view.metadata_body_found && view.metadata_lens_found); + CHECK(view.metadata_lens.lens_profile_id == electronic_id); + + CHECK(lardon3d_tui_optics_lookup_exact_metadata(optics, + "Exact Make", "Exact Model", "", "")); + CHECK(lardon3d_tui_optics_snapshot(optics, &view)); + CHECK(view.metadata_body_found && !view.metadata_lens_found); + CHECK(strstr(view.message, "no lens electronics is normal") != NULL); + + lardon3d_tui_optics_unbind(optics); + CHECK(lardon3d_tui_optics_snapshot(optics, &view)); + CHECK(!view.project_bound); + lardon3d_tui_optics_destroy(optics); + lardon3d_project_db_close(database); + CHECK(unlink(path) == 0); + CHECK(rmdir(directory) == 0); + return true; +} + +int +main(void) +{ + return run_test() ? EXIT_SUCCESS : EXIT_FAILURE; +} diff --git a/tests/test_tui_ssd_async.c b/tests/test_tui_ssd_async.c new file mode 100644 index 0000000..5490706 --- /dev/null +++ b/tests/test_tui_ssd_async.c @@ -0,0 +1,642 @@ +#include +#include +#include +#include +#include +#include + +#include + +#include "../src/tui_ssd_async_internal.h" + +#define GIB (UINT64_C(1024) * UINT64_C(1024) * UINT64_C(1024)) +#define CHECK(condition) \ + do { \ + if (!(condition)) { \ + (void)fprintf(stderr, "TUI SSD async failure line %d: %s\n", \ + __LINE__, #condition); \ + return false; \ + } \ + } while (0) + +typedef struct { + pthread_mutex_t mutex; + pthread_cond_t condition; + uint64_t now_ns; + Lardon3DSsdSnapshot snapshot; + bool snapshot_result; + bool block_snapshot; + bool release_snapshot; + bool snapshot_entered; + size_t snapshot_calls; + size_t enable_calls; + size_t disable_calls; + size_t cancel_calls; + bool destroyed; +} FakeProvider; + +static bool +fake_now(void *context, uint64_t *now_ns) +{ + FakeProvider *fake = context; + (void)pthread_mutex_lock(&fake->mutex); + *now_ns = fake->now_ns; + (void)pthread_mutex_unlock(&fake->mutex); + return true; +} + +static bool +fake_snapshot(void *context, Lardon3DSsdSnapshot *snapshot) +{ + FakeProvider *fake = context; + (void)pthread_mutex_lock(&fake->mutex); + ++fake->snapshot_calls; + fake->snapshot_entered = true; + (void)pthread_cond_broadcast(&fake->condition); + while (fake->block_snapshot && !fake->release_snapshot) { + (void)pthread_cond_wait(&fake->condition, &fake->mutex); + } + *snapshot = fake->snapshot; + bool result = fake->snapshot_result; + (void)pthread_mutex_unlock(&fake->mutex); + return result; +} + +static Lardon3DSsdControlResult +fake_enable(void *context) +{ + FakeProvider *fake = context; + (void)pthread_mutex_lock(&fake->mutex); + ++fake->enable_calls; + fake->snapshot.state = LARDON3D_SSD_ENABLED; + fake->snapshot.pairing_valid = true; + fake->snapshot.swap_active = true; + fake->snapshot.scratch_mounted = true; + fake->snapshot.can_enable = false; + fake->snapshot.can_disable = true; + fake->snapshot.can_cancel_drain = false; + fake->snapshot.scratch_allocations_allowed = true; + fake->snapshot.scratch_lease_capacity = + LARDON3D_SSD_MAX_SCRATCH_LEASES; + (void)snprintf(fake->snapshot.scratch_mount_path, + sizeof(fake->snapshot.scratch_mount_path), "%s", + LARDON3D_SSD_SCRATCH_MOUNT_PATH); + if (fake->snapshot.generation != UINT64_MAX) { + ++fake->snapshot.generation; + } + (void)pthread_mutex_unlock(&fake->mutex); + return LARDON3D_SSD_CONTROL_OK; +} + +static Lardon3DSsdControlResult +fake_disable(void *context) +{ + FakeProvider *fake = context; + (void)pthread_mutex_lock(&fake->mutex); + ++fake->disable_calls; + fake->snapshot.state = LARDON3D_SSD_SAFE_TO_UNPLUG; + fake->snapshot.swap_active = false; + fake->snapshot.scratch_mounted = false; + fake->snapshot.drain_requested = false; + fake->snapshot.can_enable = true; + fake->snapshot.can_disable = false; + fake->snapshot.can_cancel_drain = false; + fake->snapshot.scratch_allocations_allowed = false; + (void)snprintf(fake->snapshot.scratch_mount_path, + sizeof(fake->snapshot.scratch_mount_path), "UNKNOWN"); + if (fake->snapshot.generation != UINT64_MAX) { + ++fake->snapshot.generation; + } + (void)pthread_mutex_unlock(&fake->mutex); + return LARDON3D_SSD_CONTROL_OK; +} + +static bool +fake_cancel(void *context) +{ + FakeProvider *fake = context; + (void)pthread_mutex_lock(&fake->mutex); + ++fake->cancel_calls; + fake->snapshot.state = LARDON3D_SSD_ENABLED; + fake->snapshot.drain_requested = false; + fake->snapshot.can_enable = false; + fake->snapshot.can_disable = true; + fake->snapshot.can_cancel_drain = false; + fake->snapshot.scratch_allocations_allowed = true; + if (fake->snapshot.generation != UINT64_MAX) { + ++fake->snapshot.generation; + } + (void)pthread_mutex_unlock(&fake->mutex); + return true; +} + +static void +fake_destroy(void *context) +{ + FakeProvider *fake = context; + (void)pthread_mutex_lock(&fake->mutex); + fake->destroyed = true; + (void)pthread_mutex_unlock(&fake->mutex); +} + +static const Lardon3DTuiSsdAsyncProviderOps fake_ops = { + .monotonic_now_ns = fake_now, + .snapshot = fake_snapshot, + .enable = fake_enable, + .disable = fake_disable, + .cancel_drain = fake_cancel, + .destroy = fake_destroy, +}; + +static void +set_exact_pair_identity(Lardon3DSsdSnapshot *snapshot) +{ + snapshot->device_detected = true; + snapshot->swap_detected = true; + snapshot->scratch_detected = true; + snapshot->swap_partition_size_known = true; + snapshot->scratch_partition_size_known = true; + snapshot->swap_partition_size_bytes = 8 * GIB; + snapshot->scratch_partition_size_bytes = 400 * GIB; + (void)snprintf(snapshot->drive_identity, + sizeof(snapshot->drive_identity), "async-fake-drive"); + (void)snprintf(snapshot->swap_uuid, + sizeof(snapshot->swap_uuid), "async-fake-swap"); + (void)snprintf(snapshot->scratch_uuid, + sizeof(snapshot->scratch_uuid), "async-fake-scratch"); + (void)snprintf(snapshot->swap_device, + sizeof(snapshot->swap_device), "/dev/fake-swap"); + (void)snprintf(snapshot->scratch_device, + sizeof(snapshot->scratch_device), "/dev/fake-scratch"); +} + +static bool +wait_for_snapshot_entry(FakeProvider *fake) +{ + (void)pthread_mutex_lock(&fake->mutex); + while (!fake->snapshot_entered) { + (void)pthread_cond_wait(&fake->condition, &fake->mutex); + } + (void)pthread_mutex_unlock(&fake->mutex); + return true; +} + +static size_t +snapshot_calls(FakeProvider *fake) +{ + (void)pthread_mutex_lock(&fake->mutex); + size_t calls = fake->snapshot_calls; + (void)pthread_mutex_unlock(&fake->mutex); + return calls; +} + +static bool +test_action_matrix(void) +{ + static const struct { + Lardon3DSsdState state; + bool pairing_valid; + bool can_enable; + bool can_disable; + bool can_cancel_drain; + bool drain_requested; + bool expected; + Lardon3DTuiSsdAction action; + } cases[] = { + {LARDON3D_SSD_ABSENT, false, false, false, false, false, + false, LARDON3D_TUI_SSD_ACTION_NONE}, + /* DETECTED can also mean an incomplete pair; only capability grants + * enable authority. */ + {LARDON3D_SSD_DETECTED, false, false, false, false, false, + false, LARDON3D_TUI_SSD_ACTION_NONE}, + {LARDON3D_SSD_DETECTED, true, true, false, false, false, + true, LARDON3D_TUI_SSD_ACTION_ENABLE}, + {LARDON3D_SSD_ENABLING, true, false, false, false, false, + false, LARDON3D_TUI_SSD_ACTION_NONE}, + {LARDON3D_SSD_ENABLED, true, false, true, false, false, + true, LARDON3D_TUI_SSD_ACTION_DRAIN}, + {LARDON3D_SSD_IN_USE, true, false, true, false, false, + true, LARDON3D_TUI_SSD_ACTION_DRAIN}, + {LARDON3D_SSD_DRAINING, true, false, false, true, true, + true, + LARDON3D_TUI_SSD_ACTION_CANCEL_DRAIN}, + {LARDON3D_SSD_SAFE_TO_UNPLUG, true, true, false, false, false, + true, + LARDON3D_TUI_SSD_ACTION_ENABLE}, + /* A clean-looking ERROR has no inferred action; an exact original + * sticky pair may explicitly grant drain-only recovery. */ + {LARDON3D_SSD_ERROR, true, false, false, false, false, + false, LARDON3D_TUI_SSD_ACTION_NONE}, + {LARDON3D_SSD_ERROR, true, false, true, false, false, + true, LARDON3D_TUI_SSD_ACTION_DRAIN}, + }; + for (size_t index = 0; index < sizeof(cases) / sizeof(cases[0]); ++index) { + Lardon3DSsdSnapshot snapshot = { + .state = cases[index].state, + .pairing_valid = cases[index].pairing_valid, + .can_enable = cases[index].can_enable, + .can_disable = cases[index].can_disable, + .can_cancel_drain = cases[index].can_cancel_drain, + .drain_requested = cases[index].drain_requested, + .scratch_lease_capacity = LARDON3D_SSD_MAX_SCRATCH_LEASES, + .scratch_lease_count = cases[index].state == LARDON3D_SSD_IN_USE + ? 1 : 0, + }; + if (snapshot.pairing_valid) { + set_exact_pair_identity(&snapshot); + } else if (snapshot.state == LARDON3D_SSD_DETECTED) { + /* Production DETECTED without a complete pair still represents at + * least one exact observed reserved partition. */ + snapshot.device_detected = true; + snapshot.swap_detected = true; + snapshot.swap_partition_size_known = true; + snapshot.swap_partition_size_bytes = 8 * GIB; + (void)snprintf(snapshot.swap_uuid, + sizeof(snapshot.swap_uuid), "partial-swap"); + } + if (cases[index].state == LARDON3D_SSD_ENABLED + || cases[index].state == LARDON3D_SSD_IN_USE + || cases[index].state == LARDON3D_SSD_DRAINING) { + snapshot.swap_active = true; + snapshot.scratch_mounted = true; + (void)snprintf(snapshot.scratch_mount_path, + sizeof(snapshot.scratch_mount_path), "%s", + LARDON3D_SSD_SCRATCH_MOUNT_PATH); + } + Lardon3DTuiSsdAction action = LARDON3D_TUI_SSD_ACTION_ENABLE; + CHECK(lardon3d_tui_ssd_action_for_snapshot(&snapshot, &action) + == cases[index].expected); + CHECK(action == cases[index].action); + } + Lardon3DSsdSnapshot clean_error = { + .state = LARDON3D_SSD_ERROR, + .scratch_lease_capacity = LARDON3D_SSD_MAX_SCRATCH_LEASES, + }; + Lardon3DTuiSsdAction action; + CHECK(!lardon3d_tui_ssd_action_for_snapshot(&clean_error, &action)); + CHECK(action == LARDON3D_TUI_SSD_ACTION_NONE); + + Lardon3DSsdSnapshot malformed_authority = { + .state = LARDON3D_SSD_ABSENT, + .pairing_valid = true, + .can_disable = true, + }; + CHECK(!lardon3d_tui_ssd_action_for_snapshot( + &malformed_authority, &action)); + + Lardon3DSsdSnapshot invalid = { + .state = LARDON3D_SSD_ENABLED, + .pairing_valid = true, + .swap_active = true, + .scratch_mounted = true, + .can_disable = true, + .scratch_allocations_allowed = true, + .scratch_lease_capacity = LARDON3D_SSD_MAX_SCRATCH_LEASES, + }; + set_exact_pair_identity(&invalid); + (void)snprintf(invalid.scratch_mount_path, + sizeof(invalid.scratch_mount_path), "%s", + LARDON3D_SSD_SCRATCH_MOUNT_PATH); + const Lardon3DSsdSnapshot valid_enabled = invalid; + + invalid.device_detected = false; + invalid.swap_detected = false; + invalid.scratch_detected = false; + invalid.swap_partition_size_known = false; + invalid.scratch_partition_size_known = false; + invalid.swap_partition_size_bytes = 0; + invalid.scratch_partition_size_bytes = 0; + action = LARDON3D_TUI_SSD_ACTION_DRAIN; + CHECK(!lardon3d_tui_ssd_action_for_snapshot(&invalid, &action)); + CHECK(action == LARDON3D_TUI_SSD_ACTION_NONE); + + invalid = valid_enabled; + invalid.swap_partition_size_known = false; + invalid.scratch_partition_size_known = false; + invalid.swap_partition_size_bytes = 0; + invalid.scratch_partition_size_bytes = 0; + action = LARDON3D_TUI_SSD_ACTION_DRAIN; + CHECK(!lardon3d_tui_ssd_action_for_snapshot(&invalid, &action)); + CHECK(action == LARDON3D_TUI_SSD_ACTION_NONE); + + invalid = valid_enabled; + invalid.swap_partition_size_bytes = 0; + action = LARDON3D_TUI_SSD_ACTION_DRAIN; + CHECK(!lardon3d_tui_ssd_action_for_snapshot(&invalid, &action)); + CHECK(action == LARDON3D_TUI_SSD_ACTION_NONE); + + invalid = valid_enabled; + invalid.state = LARDON3D_SSD_ABSENT; + action = LARDON3D_TUI_SSD_ACTION_DRAIN; + CHECK(!lardon3d_tui_ssd_action_for_snapshot(&invalid, &action)); + CHECK(action == LARDON3D_TUI_SSD_ACTION_NONE); + + invalid = valid_enabled; + invalid.can_enable = true; + action = LARDON3D_TUI_SSD_ACTION_DRAIN; + CHECK(!lardon3d_tui_ssd_action_for_snapshot(&invalid, &action)); + CHECK(action == LARDON3D_TUI_SSD_ACTION_NONE); + return true; +} + +static bool +test_async_owner(void) +{ + FakeProvider fake = { + .now_ns = UINT64_C(1000000000), + .snapshot = { + .state = LARDON3D_SSD_DETECTED, + .generation = 1, + .pairing_valid = true, + .can_enable = true, + .scratch_lease_capacity = LARDON3D_SSD_MAX_SCRATCH_LEASES, + }, + .snapshot_result = true, + .block_snapshot = true, + }; + set_exact_pair_identity(&fake.snapshot); + CHECK(pthread_mutex_init(&fake.mutex, NULL) == 0); + CHECK(pthread_cond_init(&fake.condition, NULL) == 0); + Lardon3DTuiSsdAsync *operation = + lardon3d_tui_ssd_async_create_with_provider( + (Lardon3DTuiSsdAsyncProvider) { + .ops = &fake_ops, + .context = &fake, + }); + CHECK(operation); + + CHECK(lardon3d_tui_ssd_async_refresh(operation)); + CHECK(wait_for_snapshot_entry(&fake)); + Lardon3DTuiSsdAsyncSnapshot view; + CHECK(lardon3d_tui_ssd_async_poll(operation, &view)); + CHECK(view.running); + CHECK(view.action == LARDON3D_TUI_SSD_ACTION_OBSERVE); + CHECK(!view.controller_snapshot_known); + CHECK(lardon3d_tui_ssd_async_refresh(operation)); + CHECK(snapshot_calls(&fake) == 1); + CHECK(!lardon3d_tui_ssd_async_request( + operation, LARDON3D_TUI_SSD_ACTION_ENABLE)); + + (void)pthread_mutex_lock(&fake.mutex); + fake.release_snapshot = true; + fake.block_snapshot = false; + (void)pthread_cond_broadcast(&fake.condition); + (void)pthread_mutex_unlock(&fake.mutex); + CHECK(lardon3d_tui_ssd_async_wait_idle( + operation, UINT64_C(1000000000))); + CHECK(lardon3d_tui_ssd_async_poll(operation, &view)); + CHECK(!view.running); + CHECK(view.controller_snapshot_known); + CHECK(view.controller_snapshot_actionable); + CHECK(view.controller_snapshot.state == LARDON3D_SSD_DETECTED); + CHECK(view.generation == 1); + + CHECK(lardon3d_tui_ssd_async_refresh(operation)); + CHECK(snapshot_calls(&fake) == 1); + (void)pthread_mutex_lock(&fake.mutex); + fake.now_ns += UINT64_C(1000000000); + fake.snapshot_entered = false; + (void)pthread_mutex_unlock(&fake.mutex); + CHECK(lardon3d_tui_ssd_async_refresh(operation)); + CHECK(lardon3d_tui_ssd_async_wait_idle( + operation, UINT64_C(1000000000))); + CHECK(snapshot_calls(&fake) == 2); + + CHECK(lardon3d_tui_ssd_async_request( + operation, LARDON3D_TUI_SSD_ACTION_ENABLE)); + CHECK(lardon3d_tui_ssd_async_wait_idle( + operation, UINT64_C(1000000000))); + CHECK(lardon3d_tui_ssd_async_poll(operation, &view)); + CHECK(view.result_known && view.result == LARDON3D_SSD_CONTROL_OK); + CHECK(view.action == LARDON3D_TUI_SSD_ACTION_ENABLE); + CHECK(view.controller_snapshot.state == LARDON3D_SSD_ENABLED); + + CHECK(lardon3d_tui_ssd_async_request( + operation, LARDON3D_TUI_SSD_ACTION_DRAIN)); + CHECK(lardon3d_tui_ssd_async_wait_idle( + operation, UINT64_C(1000000000))); + CHECK(lardon3d_tui_ssd_async_poll(operation, &view)); + CHECK(view.controller_snapshot.state == LARDON3D_SSD_SAFE_TO_UNPLUG); + + (void)pthread_mutex_lock(&fake.mutex); + fake.snapshot.state = LARDON3D_SSD_DRAINING; + fake.snapshot.swap_active = true; + fake.snapshot.scratch_mounted = true; + fake.snapshot.drain_requested = true; + fake.snapshot.can_enable = false; + fake.snapshot.can_disable = false; + fake.snapshot.can_cancel_drain = true; + fake.snapshot.scratch_allocations_allowed = false; + (void)snprintf(fake.snapshot.scratch_mount_path, + sizeof(fake.snapshot.scratch_mount_path), "%s", + LARDON3D_SSD_SCRATCH_MOUNT_PATH); + ++fake.snapshot.generation; + (void)pthread_mutex_unlock(&fake.mutex); + /* Refresh the controller-issued authority before requesting cancellation. + */ + (void)pthread_mutex_lock(&fake.mutex); + fake.now_ns += UINT64_C(1000000000); + (void)pthread_mutex_unlock(&fake.mutex); + CHECK(lardon3d_tui_ssd_async_refresh(operation)); + CHECK(lardon3d_tui_ssd_async_wait_idle( + operation, UINT64_C(1000000000))); + CHECK(lardon3d_tui_ssd_async_request( + operation, LARDON3D_TUI_SSD_ACTION_CANCEL_DRAIN)); + CHECK(lardon3d_tui_ssd_async_wait_idle( + operation, UINT64_C(1000000000))); + CHECK(lardon3d_tui_ssd_async_poll(operation, &view)); + CHECK(view.controller_snapshot.state == LARDON3D_SSD_ENABLED); + + (void)pthread_mutex_lock(&fake.mutex); + fake.now_ns += UINT64_C(1000000000); + fake.snapshot_result = false; + fake.snapshot.state = LARDON3D_SSD_ERROR; + fake.snapshot.scratch_allocations_allowed = false; + ++fake.snapshot.generation; + (void)pthread_mutex_unlock(&fake.mutex); + CHECK(lardon3d_tui_ssd_async_refresh(operation)); + CHECK(lardon3d_tui_ssd_async_wait_idle( + operation, UINT64_C(1000000000))); + CHECK(lardon3d_tui_ssd_async_poll(operation, &view)); + CHECK(view.controller_snapshot_known); + CHECK(view.controller_snapshot_actionable); + CHECK(view.controller_snapshot.state == LARDON3D_SSD_ERROR); + + /* A malformed provider copy is visible as ERROR but can never authorize + * F10 control or retain a stale SAFE_TO_UNPLUG observation. */ + (void)pthread_mutex_lock(&fake.mutex); + fake.now_ns += UINT64_C(1000000000); + fake.snapshot_result = true; + fake.snapshot.state = LARDON3D_SSD_DETECTED; + ++fake.snapshot.generation; + (void)memset(fake.snapshot.model, 'X', sizeof(fake.snapshot.model)); + (void)pthread_mutex_unlock(&fake.mutex); + CHECK(lardon3d_tui_ssd_async_refresh(operation)); + CHECK(lardon3d_tui_ssd_async_wait_idle( + operation, UINT64_C(1000000000))); + CHECK(lardon3d_tui_ssd_async_poll(operation, &view)); + CHECK(view.controller_snapshot_known); + CHECK(!view.controller_snapshot_actionable); + CHECK(view.controller_snapshot.state == LARDON3D_SSD_ERROR); + + CHECK(lardon3d_tui_ssd_async_destroy_checked(&operation)); + CHECK(operation == NULL); + (void)pthread_mutex_lock(&fake.mutex); + CHECK(fake.destroyed); + CHECK(fake.enable_calls == 1); + CHECK(fake.disable_calls == 1); + CHECK(fake.cancel_calls == 1); + (void)pthread_mutex_unlock(&fake.mutex); + CHECK(pthread_cond_destroy(&fake.condition) == 0); + CHECK(pthread_mutex_destroy(&fake.mutex) == 0); + return true; +} + +static Lardon3DResourceGovernor * +test_governor(void) +{ + Lardon3DHardwareProfile profile = { + .logical_cpu_count = 8, + .page_size_bytes = 4096, + .memory_total_bytes = UINT64_C(16) * 1024 * 1024 * 1024, + .cpu_architecture = "synthetic", + }; + Lardon3DResourcePolicy policy; + return lardon3d_resource_policy_default(&profile, &policy) + ? lardon3d_resource_governor_create(&profile, &policy) : NULL; +} + +static bool +test_bound_governor_publication(void) +{ + FakeProvider fake = { + .now_ns = UINT64_C(1000000000), + .snapshot = { + .state = LARDON3D_SSD_DETECTED, + .generation = 1, + .pairing_valid = true, + .can_enable = true, + .scratch_lease_capacity = LARDON3D_SSD_MAX_SCRATCH_LEASES, + }, + .snapshot_result = true, + }; + set_exact_pair_identity(&fake.snapshot); + (void)snprintf(fake.snapshot.reason, sizeof(fake.snapshot.reason), + "healthy fake pair detected"); + CHECK(pthread_mutex_init(&fake.mutex, NULL) == 0); + CHECK(pthread_cond_init(&fake.condition, NULL) == 0); + Lardon3DResourceGovernor *governor = test_governor(); + CHECK(governor); + unsigned char identity_storage = 0; + Lardon3DSsdController *identity = + (Lardon3DSsdController *)(void *)&identity_storage; + Lardon3DTuiSsdAsync *operation = + lardon3d_tui_ssd_async_internal_create_with_provider_and_governor( + (Lardon3DTuiSsdAsyncProvider) { + .ops = &fake_ops, + .context = &fake, + }, + governor, + identity); + CHECK(operation); + + Lardon3DResourceExternalStorage external; + CHECK(lardon3d_resource_governor_get_external_storage( + governor, &external)); + CHECK(external.status == LARDON3D_RESOURCE_EXTERNAL_STORAGE_ERROR); + CHECK(!external.new_scratch_allocations_allowed); + + CHECK(lardon3d_tui_ssd_async_refresh(operation)); + CHECK(lardon3d_tui_ssd_async_wait_idle( + operation, UINT64_C(1000000000))); + CHECK(lardon3d_resource_governor_get_external_storage( + governor, &external)); + CHECK(external.status == LARDON3D_RESOURCE_EXTERNAL_STORAGE_DETECTED); + CHECK(strcmp(external.stable_identity, "async-fake-drive") == 0); + + CHECK(lardon3d_tui_ssd_async_request( + operation, LARDON3D_TUI_SSD_ACTION_ENABLE)); + CHECK(lardon3d_tui_ssd_async_wait_idle( + operation, UINT64_C(1000000000))); + CHECK(lardon3d_resource_governor_get_external_storage( + governor, &external)); + CHECK(external.status == LARDON3D_RESOURCE_EXTERNAL_STORAGE_AVAILABLE); + CHECK(external.new_scratch_allocations_allowed); + + /* Exact names without current detection and positive extents are not a + * physical pair. The async boundary must publish ERROR and F10 must remain + * non-actionable rather than trusting the friendly ENABLED flags. */ + Lardon3DSsdSnapshot malformed_authority; + (void)pthread_mutex_lock(&fake.mutex); + fake.now_ns += UINT64_C(1000000000); + ++fake.snapshot.generation; + fake.snapshot.device_detected = false; + fake.snapshot.swap_detected = false; + fake.snapshot.scratch_detected = false; + fake.snapshot.swap_partition_size_known = false; + fake.snapshot.scratch_partition_size_known = false; + fake.snapshot.swap_partition_size_bytes = 0; + fake.snapshot.scratch_partition_size_bytes = 0; + malformed_authority = fake.snapshot; + (void)pthread_mutex_unlock(&fake.mutex); + Lardon3DTuiSsdAction action = LARDON3D_TUI_SSD_ACTION_DRAIN; + CHECK(!lardon3d_tui_ssd_action_for_snapshot( + &malformed_authority, &action)); + CHECK(action == LARDON3D_TUI_SSD_ACTION_NONE); + CHECK(lardon3d_tui_ssd_async_refresh(operation)); + CHECK(lardon3d_tui_ssd_async_wait_idle( + operation, UINT64_C(1000000000))); + CHECK(lardon3d_resource_governor_get_external_storage( + governor, &external)); + CHECK(external.status == LARDON3D_RESOURCE_EXTERNAL_STORAGE_ERROR); + CHECK(!external.new_scratch_allocations_allowed); + + (void)pthread_mutex_lock(&fake.mutex); + fake.now_ns += UINT64_C(1000000000); + ++fake.snapshot.generation; + set_exact_pair_identity(&fake.snapshot); + fake.snapshot.swap_partition_size_bytes = 0; + (void)pthread_mutex_unlock(&fake.mutex); + CHECK(lardon3d_tui_ssd_async_refresh(operation)); + CHECK(lardon3d_tui_ssd_async_wait_idle( + operation, UINT64_C(1000000000))); + CHECK(lardon3d_resource_governor_get_external_storage( + governor, &external)); + CHECK(external.status == LARDON3D_RESOURCE_EXTERNAL_STORAGE_ERROR); + CHECK(!external.new_scratch_allocations_allowed); + + (void)pthread_mutex_lock(&fake.mutex); + fake.now_ns += UINT64_C(1000000000); + ++fake.snapshot.generation; + set_exact_pair_identity(&fake.snapshot); + fake.snapshot.swap_partition_size_bytes = 8 * GIB; + (void)memset(fake.snapshot.reason, 'X', sizeof(fake.snapshot.reason)); + (void)pthread_mutex_unlock(&fake.mutex); + CHECK(lardon3d_tui_ssd_async_refresh(operation)); + CHECK(lardon3d_tui_ssd_async_wait_idle( + operation, UINT64_C(1000000000))); + CHECK(lardon3d_resource_governor_get_external_storage( + governor, &external)); + CHECK(external.status == LARDON3D_RESOURCE_EXTERNAL_STORAGE_ERROR); + CHECK(!external.new_scratch_allocations_allowed); + CHECK(strstr(external.reason, "Malformed SSD telemetry") != NULL); + + CHECK(lardon3d_tui_ssd_async_destroy_checked(&operation)); + CHECK(operation == NULL); + external = (Lardon3DResourceExternalStorage) {.generation = UINT64_MAX}; + CHECK(!lardon3d_resource_governor_get_external_storage( + governor, &external)); + CHECK(external.generation == 0); + lardon3d_resource_governor_destroy(governor); + CHECK(pthread_cond_destroy(&fake.condition) == 0); + CHECK(pthread_mutex_destroy(&fake.mutex) == 0); + return true; +} + +int +main(void) +{ + return test_action_matrix() && test_async_owner() + && test_bound_governor_publication() + ? EXIT_SUCCESS : EXIT_FAILURE; +} diff --git a/tests/test_visual_index.c b/tests/test_visual_index.c index 4df4e72..8e3ea21 100644 --- a/tests/test_visual_index.c +++ b/tests/test_visual_index.c @@ -577,7 +577,9 @@ static bool run_test(void) { size_t parallel_membership_count = 0; Lardon3DVisualIndexCandidate parallel_candidates[4]; size_t parallel_candidate_count = 0; - CHECK(lardon3d_visual_index_update_once_parallel(root, database, index_id, 0, 0, 16, 4, + /* cpu_threads=16 exercises the intrinsic segment/participant ceiling; only + * the five selected members do useful work, and canonical bytes stay exact. */ + CHECK(lardon3d_visual_index_update_once_parallel(root, database, index_id, 0, 0, 16, 16, ¶llel_last, ¶llel_indexed) == LARDON3D_VISUAL_INDEX_OK && parallel_indexed == indexed && parallel_last == last &&