Skip to content

v1.9.0

Choose a tag to compare

@github-actions github-actions released this 18 Oct 05:50

Added

  • The BoTorch and MetaTAF backends can run on botorch-rust, a Rust implementation of BoTorch with the same API, selected per run with the new inspector option Model Library (BoForUnityManager.modelLibrary: BoTorch, the default, BotorchRust or Auto), which Unity passes to Python as BO_MODEL_LIBRARY (botorch, botorch_rs, auto). Per suggestion, botorch-rust measured 10–14× faster for bo.py and 3.5–17× faster for mobo.py, both on one thread. bo.py and mobo.py run on it now. Contextual optimization runs on it with every embedding source (Learned: botorch-rust's LCEMGP; Manual/Image: its ProvidedEmbeddingLCEMGP, this project's provided-embedding LCE-M model), and MetaTAF once openbo supports it; a botorch-rust or openbo without these falls back to BoTorch with a console warning. CABOP and DBO stay on BoTorch. BotorchRust without botorch-rust installed refuses to start; Auto then runs BoTorch. For the same Seed the initial Sobol design stays bit-identical to BoTorch's; botorch-rust runs are reproducible from the Seed (its acquisition optimization is seeded with Seed + iteration), but they are not BoTorch's runs. The library is a study factor: do not switch it within a study. Threads: bo.py runs botorch-rust on BO_TORCH_THREADS threads (default 1, as for PyTorch); mobo.py and MetaTAF use BO_TORCH_THREADS when it is set, otherwise half the logical CPUs, at most 4, so Unity keeps cores for rendering. botorch-rust is optional and installed from PyPI (botorch-rust==0.2.0, prebuilt wheels; README 8.16, commented line in requirements.txt). With the default BoTorch nothing changes.
  • RunInfo.json in every bo.py/mobo.py run folder: the model library requested and used (reason, version, threads and, for botorch-rust, the SHA-256 of its compiled extension, since its version number stays the same between source builds), the versions of torch, BoTorch, GPyTorch, botorch-rust, NumPy, SciPy, pandas and moocore, the Seed, Python and the platform. MetaRunState.json (schema_version 3) gets the same model_library block, including the library openbo actually bound, and lists botorch-rust among its versions; meta_train.py stamps the library of its fit into every population model's provenance.

Changed

  • Python packages now live in a private virtual environment, so the first Play after updating installs them once more. 1.8.0 ran pip install --user, which every virtual environment refuses (a Manually Installed Python pointing at a venv never worked), as do externally managed Pythons (PEP 668: Homebrew, Debian/Ubuntu system Pythons); the user site is also shared with every other project of that user, so another project's pip install --user numpy==… silently changed what the backends imported. Now a configured interpreter that is itself a venv/conda environment receives the packages; any other interpreter gets a private environment at <persistentDataPath>/BOData/python-venv (a sibling python-venv-<hash> for each further base interpreter, so switching interpreters never wipes a working environment; on Windows a short folder under %LOCALAPPDATA%\BOforUnity\ when that path would be too long), and the backends run with its Python. Only if no venv can be created (Debian/Ubuntu without python3.13-venv) does the launcher fall back to --user, and it says why. The first Play after updating takes a few minutes (it downloads several hundred MB). Optional packages — openbo for MetaTAF, open_clip_torch/pillow for image embeddings — must be installed again with the interpreter the Console logs at startup as Optimizer Python: … ("<that path>" -m pip install …); the backends' "not installed" messages now print that interpreter.
  • The install scripts no longer install the Python packages — Unity does, on the first Play. Windows/installation_python.bat installs Python 3.13 and the VC++ runtime, MacOs/install_python.sh the bundled Python 3.13, Linux/install_python.sh Python 3.13 with its venv module (deadsnakes PPA on Ubuntu releases without it, Debian 13+ from its own packages, instructions elsewhere); each checks that the interpreter can create virtual environments. --venv DIR additionally creates an environment with requirements.txt in DIR for Manually Installed Python.
  • Pinned versions are verified on every start instead of trusting a stamp from the last install: a metadata probe (no torch import, about a second) compares every == pin in requirements.txt with what the backend's interpreter would import and reinstalls on a mismatch. The installed versions are written at the top of output.txt.
  • Interpreter discovery is capped at the tested Python minor version (3.13) and prefers the bundled 3.13, as README 8.5 already stated; it used to take the newest 3.x, so a machine with Python 3.14 ran an untested stack. Newer minors are tried only after every 3.13 interpreter failed; if pip fails for one interpreter the next is tried (an unreachable package index stops at once); the same interpreter reached through several links is tried once; on macOS /usr/bin/python3 (Apple's stub, which opens a Command Line Tools install dialog) is skipped. Discovery and all probes run off the main thread with timeouts, so a hanging interpreter cannot freeze the Editor.
  • Linux installs take PyTorch from its CPU wheel index (https://download.pytorch.org/whl/cpu; 2.14.1+cpu satisfies the torch==2.14.1 pin) instead of PyPI's CUDA build with several GB of nvidia-* packages. Offline labs: wheels in StreamingAssets/BOData/Installation/wheels/ are tried first on their own (--no-index --find-links), then together with the package index.
  • Player builds keep only their own platform's Python installer: a build post-processor removes the other platforms' StreamingAssets/BOData/Installation/<OS> folders from the built player (macOS pkg 71 MB, Windows Python 28 MB + VC++ runtime 25 MB) and renews the ad-hoc signature of a macOS .app afterwards (an app signed with an identity is left untouched). The project's own Assets are never modified.
  • MC Samples defaults to 128 instead of 512 (inspector default, BoForUnityManager prefab, the four example scenes, and the fallback of bo.py, mobo.py and MetaTAF when the init message carries no value). Measured on the MOBO acquisition search (d = 4, 30–40 observations, fitted model fixed): 2–5 objectives 2.6–4.2× faster (e.g. 3 objectives 15.2 s → 5.0 s); the proposed design moved by 1e-3 to 1.7e-2 in the unit cube. Scenes created earlier keep their serialized value. DBO and CABOP do not use it. The acquisition estimate changes with the value: do not upgrade within a running study, or keep 512 in its scene.
  • MOBO and MetaTAF use the hypervolume reference point −1.1 in every objective (was −1). With exactly −1, a Pareto-optimal design rated worst on one objective (common with extreme Likert ratings) added no hypervolume: for the designs (best, worst) and (worst, best) both rows were IsPareto = TRUE, yet coverage and HypervolumePerEvaluation.csv stayed 0 (now 0.21, then 0.41), qLogNEHVI saw no improvement from such designs, and a population model whose front consisted of such designs was refused by meta_train.py and skipped by MetaTAF. The value (bo_normalize.HYPERVOLUME_REFERENCE_VALUE) is used by qLogNEHVI, openbo's population terms, the coverage metric, the logged ReferencePoint ([-1.1,-1.1]) and meta_train.py's front check. Suggestions after the sampling phase and logged hypervolumes differ from earlier versions (hypervolumes are larger and not comparable across the change): do not upgrade within a running study.
  • Contextual optimization with Manual or Image embeddings: context similarity now comes from the provided embeddings. Study-behaviour change: do not upgrade within a running study. BoTorch's LCEMGP always learns a 1-d embedding per context and concatenated the provided one to it. A context without observations — README 8.13's main case, a new participant warm-started from earlier ones — never moves its learned value away from the random initialization, so how much the model transferred to the new participant depended on the torch seed (the fitted correlation between two contexts with the same manual embedding, the new one without data, ranged from 0.00 to 1.00 over seeds 0–5). The task kernel now sees only the provided embeddings (LCEMGP's RBF kernel and lengthscale range) plus one learned weight w that starts at 0: context correlation = (1 − w)·RBF(embeddings) + w·[same context], so contexts with identical embeddings are pooled until their data disagree. Results no longer depend on the seed. Learned embeddings: posteriors of observed contexts are unchanged.
  • Normalize Embeddings (inspector label formerly "L2-Normalize Embeddings"; field normalizeContextEmbeddings) standardizes Manual embeddings per feature instead of L2-normalizing each context. Study-behaviour change: do not upgrade within a running study. L2 normalization keeps only a vector's direction: every positive 1-d embedding became 1.0 (ages 25, 40, 60 → three identical contexts) and proportional profiles merged ([3, 4] ≡ [6, 8]), so the model pooled the data of contexts that were meant to differ. Each Manual feature is now z-scored across the configured contexts (constant features, and every feature when there is only one context, become 0) and scaled so the contexts' mean squared distance from their centroid is 1. Image (CLIP) embeddings keep L2 normalization. The backend warns when contexts have identical embeddings or become identical through normalization. With only two or three contexts, see the caveat in README 8.13.1. Together with the previous entry and the unobserved-context fix below, on 40 simulated studies (2–4 contexts, positive 1–3-d feature profiles that determine the optimum, a current context with 0–3 observations) the current context's posterior RMSE fell from 0.454 (1.8.0) to 0.380; for a new participant without own data (30 studies) from 0.494 to 0.387 (Manual) and from 0.511 to 0.467 (Learned).
  • BestObjectivePerEvaluation.csv and its mirror HypervolumePerEvaluation.csv written by BoTorch (bo.py) and DBO gain Scale (frame and direction of BestObjective) and BestObjectiveRaw (the same best observation in the objective's own units; empty on a contextual warm-start baseline row without current-context observations). BestObjective stays the normalized [−1, 1] maximize-space value that coverage reports — sign-flipped for a minimized objective, unlike every other log column. The columns are appended, so scripts reading by name or position keep working.
  • CABOP: the reuse tolerance and the cost model now work in fractions of each parameter's range. Study-behaviour change: do not upgrade within a running study. CABOP Tolerance was compared with raw differences in parameter units, and the soft-cost kernel that drives cost-aware acquisition had a fixed bandwidth of 0.05 parameter units, so a parameter's units decided whether two designs counted as the same prototype. With the default tolerance 0.05, a parameter on [0, 0.1] treated every proposal within half its range as the design already built (2 distinct designs shown in 15 iterations), a parameter on [0, 100] was charged the full "acquired" cost for a 1 % move, and on [0, 0.01] every candidate cost about the same, so cost-awareness did nothing. Both are now fractions of the range (default 0.05 = 5 % of Upper − Lower); a run on [0, 0.1] or [0, 100] proposes the same designs, in relative terms, as on [0, 1], and parameters on [0, 1] behave exactly as before. Convert a tolerance given in parameter units by dividing it by the range (e.g. 2 on [18, 64] → 0.0435); tolerances outside [0, 1] are refused by the inspector check and the backend, the inspector field is a 0–1 slider, and Unity no longer silently turns a negative tolerance into 0. The backend prints each tolerance in parameter units at startup. The Fitts-law scene's and FittsLawTask's CABOP tolerances were converted (2/46, 2/80, 5/296 of the pixel ranges).
  • CABOP warm start logs like BoTorch and DBO. With k warm-start rows, CABOP numbered its evaluations from 1 again, so its logs did not continue the warm-start rows' numbering (README 8.12); it wrote no baseline metric row and set IsBest/IsPareto among the live rows only. Evaluations are now numbered k + 1, k + 2, … in every log and in the parameters messages, the compatibility metric file starts with a baseline row at Iteration k, and warm-start rows compete for IsBest/IsPareto as in bo.py/mobo.py (a live row better than none of them is not marked). CABOPMetricsPerEvaluation.csv keeps one row per evaluation of the run. The model takes over at the first evaluation whatever sampling count the init message carries (Unity already sends 0 under warm start, so its suggestions are unchanged). The final-design candidates of warm-started CABOP runs change: do not upgrade within a running study.
  • DBO suggestions are 1.3–1.7× faster with bit-identical suggestions: the vendored dbo-torch scored its 1024 raw acquisition samples 10 at a time (batch_limit = restarts), now 128 at a time (init_batch_limit, as bo.py/mobo.py since 1.8.0). Measured on the pinned stack, one thread: d = 2, 0.42 s → 0.25 s per suggestion; d = 4, 0.70 s → 0.54 s; suggestions and fitted alpha identical to the last bit. The local patch is recorded in dbo_torch/PROVENANCE.md.
  • The five Python backends share one protocol module, bo_protocol.py (socket listener, NDJSON reader, objectives wait, init parsing and validation, run-folder naming, log writers), and bo.py/mobo.py share their warm-start loader, model construction and observation-log append (botorch_common.py). The copies had drifted (three versions of the reader, two of the objectives wait); a protocol or robustness fix now reaches every backend. Apart from the fixes below, the backends send the same messages and write the same logs as before.
  • Every parameters message carries iteration: the Iteration the design will be logged under in ObservationsPerEvaluation.csv (warm-start and sampling rows included). Unity keeps it as BoForUnityManager.LastSuggestionLogIteration.
  • Init messages up to 64 MiB (was 1 MiB; environment variable BO_MAX_RECV_BUF_BYTES, which the error now names). Manual context embeddings take about 16 KB per context at 1280 dimensions and 51 KB at 4096, so a few dozen contexts ended the study at startup with "possible framing error or oversized message". Unity now refuses to send a larger init and says so. Large messages are also received in linear time (the reader re-scanned its whole buffer after every read).
  • New Unity → Python message {"type": "stop", "reason": "..."}: the backend prints Stop requested by Unity: <reason> and exits with code 0, with every completed evaluation logged. Unity sends it for a perfect-rating stop (reason perfect_rating) and in place of the init message when it refuses an invalid configuration (invalid_configuration); before, it could only kill the process, so the reason never reached output.txt and the exit looked like a crash.
  • An existing user log folder from another condition is now reused. Unity reserved its folder with "suffix if LogData/<UserID> exists at all", so a participant's second condition ran as P01_1 — a different userId sent to Python and written into every log row — contradicting README 6.2.1. Now P01 running condition B after A logs to P01/B; only a repeated P01/A gets P01_1. Study-behaviour change: multi-condition participants keep one User ID across conditions — do not upgrade within a running study.
  • A perfect-rating stop now runs the final-design round (when Enable Final Design Round is on) instead of going straight to the end screen, and tells the backend to stop instead of killing it while it waited for objectives; Unity gives it up to 1 s to exit by itself. Study-behaviour change for studies that combine Perfect Rating with the final-design round: participants who stop early now get the final round too — do not upgrade within a running study.
  • The perfect-rating check judges the value Python received — the mean of the objective's finite sub-measures within its window — instead of the average of all collected values: one unanswered item (NaN) made a perfect rating "imperfect" although Python got a perfect mean.
  • Automatic advance counts real time (WaitForSecondsRealtime): a scene that paused with Time.timeScale = 0 never advanced.
  • New optional Progress Text (progressText, TMP_Text) shows Iteration x / N (or Final design) whenever a design is ready. New script API: BoForUnityManager.Instance (the running, persistent manager), LastSuggestionLogIteration, FinalDesignLogIteration (the Iteration of the final-design row, −1 until it is selected) and TerminateWithError(text).
  • Optimizer finds the manager on first use (BoForUnityManager.Instance, else its own GameObject's): every call silently did nothing until Optimizer.Start ran, so calls from another script's Awake/Start were lost. GetParameterValue/GetParameter/GetObjective warn once per unknown key — a typo returned 0 (or a detached ParameterArgs) all study long without a message — and AddObjectiveValue warns once per name that matches no objective.
  • Final-design selection searches the shared log root first (the condition's folder or its CABOP/<mode> folder; the StreamingAssets, persistentDataPath and legacy roots as fallbacks), and the Console names the candidate pool (IsPareto/IsBest/computed/all) and the Iteration the row will be logged under, with a warning when no backend flag was available. Tie-break 2 ("least aggressive") now normalizes parameters by their configured bounds and measures the distance to the middle of the design space; it normalized by the spread observed in the log, so a dimension the optimizer had barely varied (e.g. 0.50–0.52 of [0, 1]) dominated the tie-break. Only ties on the first two criteria are affected.
  • Fitts law task: selections are registered on pointer-down (ISO 9241-9) instead of on release. A release-based click only counted when press and release landed on the same target and added the release delay to every movement time. New inspector option Selection Event (Pointer Down default, Pointer Click = previous behaviour). Movement times are shorter than with earlier versions: do not upgrade within a running study, or set Selection Event = Pointer Click.
  • Fitts law task: odd target counts use the ISO 9241-9 order (step (n + 1) / 2), so every movement has the amplitude D · cos(90° / n); the previous pairing alternated between two amplitudes (0.993 · D and 0.935 · D for n = 13). Even counts (the provided scene uses 12) keep their order. Changes the target order for odd counts: do not upgrade within a running study.
  • Fitts law logs report ISO 9241-9 metrics per round: FittsLawAppLog.csv gains SelectionEvent, IsoTrialCount, IsoWidthPixels (W), IsoAmplitudePixels (D), IsoID, IsoEffectiveAmplitudePixels (De), IsoEffectiveWidthPixels (We = 4.133 · SD of the endpoint deviations along the task axis), IsoIDe, IsoMovementTimeMs and IsoThroughputBitsPerSecond (TP = IDe / MT), excluding each round's first trial; FittsLawTrialLog.csv gains FromTargetX/Y, AmplitudePixels, EndpointDeviationPixels, EffectiveAmplitudePixels and IncludedInIsoMetrics. Both files also gain AppliedButtonSizePixels/AppliedButtonDistancePixels (and the app log AppliedLayoutAdjusted; see Fixed). The objectives sent to the optimizer are unchanged.
  • Fitts law logs use the Iteration of the same evaluation in ObservationsPerEvaluation.csv (LastSuggestionLogIteration; the final-design round uses FinalDesignLogIteration): under warm start they were numbered 1, 2, … while Python numbered the evaluations k + 1, k + 2, …, so a join on Iteration paired telemetry with the wrong designs. Values are written with round-trip precision instead of 3 decimals, and the *Objective columns hold exactly the value sent to Python (finite mean, clamped to the bounds; one unanswered item used to blank the column). When an existing log has the old columns, new rows go to FittsLawAppLog_1.csv etc. instead of being appended under a mismatching header.
  • Restart With Key on FittsLawTask defaults to off (also in the scene) and only restarts a running round (see Fixed).
  • QuestionnaireToolkit started/finished timestamps are ISO 8601 UTC (2026-10-09T14:03:04.123Z) instead of the machine's culture format (10/9/2026 2:03:04 PM, 09.10.2026 14:03:04). Column format change.
  • Demo scripts read their parameters by key (Color-Red…, TargetSize/TargetEccentricity, <Cube|Cylinder>ColorR…; positional fallback when a key is missing); ColorGuesser and ObjectVariableExposer normalize with the parameter's bounds, while TargetClickerEvaluator keeps reading the value itself as a fraction (its bounds narrow the size/eccentricity range, as before) and writes click times to the objective AverageClickTimeMS (fallback: the second objective). ObjectVariableExposer now fills its colorR/G/B/A and isActive fields, which were never assigned. The Examples scripts compile into their own auto-referenced assembly (BOforUnity.Examples), so scenes and user scripts keep working.
  • MetaWeightsPerEvaluation.csv joins on Iteration like every other log. Its Iteration was the optimization step (1..N), so a join paired each weights row with the evaluation numSamplingIterations rows earlier — mostly a sampling-phase design the weights never influenced. Iteration is now the global evaluation index, and a new second column, OptimizationStep, keeps the step. Scripts reading columns by position must skip the new column; for files written earlier, add numSamplingIterations to Iteration before joining.
  • MetaRunState.json follows the run (schema_version 2): like DboRunState.json it is rewritten atomically after every evaluation with progress (evaluations completed, last Iteration, latest hypervolume and source weights) and ends with finished plus finish_reason (completed, stop_requested with Unity's reason, error, startup_abort). A file still saying "finished": false belongs to a killed backend. It also records the hypervolume reference point, sources with identical trajectories and the population manifest. A locked file never ends the study.
  • meta_train.py names each population model after its run's path (<participant>_<condition>_<run>, e.g. p01_main_run) instead of its position on the command line (00_p01_main_run). Rebuilding a population into the same --out with one more, or reordered, runs published renamed copies next to the existing ones (3 participants → 5 sources), so those participants were weighted twice in every later run. A run already in --out keeps the name it has there (also a 1.8.0 index-prefixed name, and also when it was built from another path: on another machine or before the project folder moved), two runs with the same path tail get a short path hash, existing artifacts are kept ([KEEP]) unless --force is given, and --dry-run checks the frame, runs and names without fitting, writing or needing torch/openbo. Explicit names: --name (once per run, also next to its run path) or --names a,b,c.
  • Population manifest population.json: meta_train.py writes it into --out after every build (or alone with --manifest-only): name and SHA-256 of both files of every source and the frame digest. While it is present, MetaTAF aborts before the first trial if the population it loads differs in any way (a source added, removed, replaced or no longer loadable) and lists the differences. This enforces the guide's "freeze the population" rule, which was documentation only: a source folder that changed during a study (a sync client, a colleague adding a participant) silently gave later participants another treatment. Without a manifest nothing changes.
  • The launcher checks the configuration (the shared BoConfigValidator, see Fixed) before the dependency setup, so a configuration error is reported at once instead of after a multi-minute install, and Python is never started for it.

Fixed

  • A backend failure left the participant on a blank screen. When Python rejected the init message, sent an invalid message or died mid-study, Unity wrote the error into Output Text but never showed the Optimizer State Panel containing it, never ended the loop and never cancelled a pending automatic advance. All failures now go through BoForUnityManager.TerminateWithError, which shows the panel with the message, hides loading and Next, ends the loop and stops Python; a failure to send the objectives does the same (a second questionnaire used to try again). The screen shows the backend's own last error line (e.g. OSError: Port 56001 is already in use …) with the path of output.txt instead of "The system could not be started…", and configuration errors found while building the init message are shown verbatim.
  • Configuration errors that crashed the backend after startup — often after the participant had finished the sampling phase — are caught before the study starts: empty, duplicate or shared parameter/objective keys, keys equal to a fixed log column (UserID, ConditionID, GroupID, Timestamp, Iteration, Phase, IsBest, IsPareto, and Context with contextual optimization; case-insensitive), a parameter Lower Bound not below its Upper Bound, an objective Lower Bound above its Upper Bound, Raw Samples below Optimizer Restarts, a negative Seed with MetaTAF, and with CABOP a tolerance outside [0, 1] or prefabricated values outside the bounds. The inspector lists them in red under the objective list; the launcher and the manager refuse to start with the list on screen. The backends repeat the checks: Raw Samples < Num Restarts crashed BoTorch, MOBO, DBO and MetaTAF after the sampling phase ("n (10) cannot be larger than the number of provided samples (5)"), and a parameter with Lower Bound = Upper Bound is now refused by all five backends (BoTorch, MOBO and MetaTAF ran with a dimension that could never vary; DBO and CABOP already refused it). Study-behaviour change: a study with such a parameter no longer starts — do not upgrade within a running study; remove a fixed parameter from the optimizer and set it in the scene instead.
  • A log open in Excel ended the participant's session (Windows). The backends rewrite ObservationsPerEvaluation.csv after every evaluation; while a researcher had it open, the write raised PermissionError and the backend died mid-study. Writes are now retried for 2 s; a log that stays locked is reported once (Warning: could not write <file> …), kept in memory and written as soon as the file is free again, and the study goes on. No row is lost — also not in the logs that are read back and rewritten for IsBest/IsPareto. While a log is locked, its complete content is also kept next to it as <name>.unsaved.csv (deleted after the catch-up), which survives a session that Unity ends by terminating the backend. Appended logs (metrics, execution times, DBO diagnostics, MetaTAF weights) behave the same. Unity's final-design row is retried for about a second and otherwise written to ObservationsPerEvaluation_finaldesign_<timestamp>.csv with an error naming the file, and the final-design selection reads logs that another program holds open (it skipped them and fell back to "no final design").
  • A crash during a log rewrite could truncate the study log. Rewrites now go through a temporary file in the same folder, fsync and an atomic replace, so the log is always the previous or the new version. Where Windows refuses only the replace (a viewer that holds the file open without allowing deletion), the log is written in place as before. Logs keep their usual file permissions.
  • A malformed line while waiting for objectives hung both sides for an hour. The reader skipped it; if it was the objectives reply, Unity was already waiting for the next design, so both sides waited for the 3600 s socket timeout. The backend now stops at once and shows the line (malformed lines at other times are still skipped with a warning). An objectives message without a values dict was reported by BoTorch, MOBO, DBO and MetaTAF as "No objectives received from Unity", as if Unity had disconnected; every backend now reports the malformed message.
  • A host project's global Json.NET settings broke the protocol. Every JsonConvert call picked up JsonConvert.DefaultSettings: a camelCase resolver lowercased the keys Unity sent (Trust → trust, a missing-key error in Python), MissingMemberHandling.Error made every incoming message throw, Formatting.Indented split messages across lines (breaking NDJSON framing), and PreserveReferencesHandling/DefaultValueHandling.Ignore added $id keys or dropped 0/false settings. Unity now uses private serializer settings with explicit values in both directions (and for the launcher's installed-package list).
  • A multi-byte UTF-8 character split across two socket reads was also corrupted on the Unity side (Größe → Gr��ße, then a missing-key error that ended the session); received bytes are now decoded incrementally (1.8.0 fixed the Python side).
  • Stopping play mode or quitting mid-study reported a crash: Python was killed before SocketNetwork learned the stop was intended, so "Socket closed by Python unexpectedly" was logged and a connection-failure handler queued. With domain reload disabled (Enter Play Mode Options), that handler and the manager singleton survived into the next play session; both are now reset at startup. A study stopped before the socket connected left the Python process running until its accept timeout, holding port 56001; it is now stopped.
  • The backend's normal exit after optimization_finished (or after Unity's stop for a perfect rating) is no longer treated as a failure: when it was noticed while a round was running — typically the final-design round — 1.8.0 replaced the status text with "The system could not be started...". A non-zero exit at that point is logged as an error without interrupting the final round, and Unity gives Python up to 1 s after a stop or optimization_finished to save locked logs and exit before terminating it, so the final-design selection reads the finished logs.
  • A perfect-rating stop during the sampling phase left wrong IsBest/IsPareto flags (Perfect Rating In Initial Rounds): the backends write provisional flags while sampling and correct them after the last sampling design, which the stop skipped. BoTorch kept its best-so-far flags (ratings 2, 5, 8 logged IsBest TRUE, TRUE, TRUE), MOBO and MetaTAF IsPareto = FALSE on every row (MetaTAF also after an error), so FinalDesignSelector chose among the wrong designs. The flags are now finalized before the backend exits.
  • Reload Scene On Advance works for scenes outside the Build Profiles scene list. The scene was reloaded by name, which in the Editor fails for a scene missing from the list (the example scenes are not in the project's empty list), leaving the loading screen up after the first evaluation; by name, Unity could also load another folder's scene of the same name. It is now reloaded by build index, or by its asset path when it has none (Editor; AssetBundle scenes in players). An unsaved scene stops the study before it starts, with the fix on screen.
  • Duplicate managers in reloaded scenes could capture other scripts. With Reload Scene On Advance each reload instantiates the scene's manager again, and it was destroyed only at the end of the frame, so FindObjectOfType<BoForUnityManager>() in other scripts' Awake/Start could bind to the doomed copy. The copy is now deactivated before it is destroyed, and a warning names what differs when it was configured differently (keys, backend or IDs) — e.g. a second condition scene that silently kept the first, finished manager running.
  • Questionnaire and optimizer could log one participant to different user folders. A questionnaire that initialized before the manager's Start read the raw User ID, and the questionnaire's log root kept a trailing separator (…/LogData/) while the optimizer's had none, so their folder reservations never matched; when the folder existed, each chose its own suffix (P01_1 vs P01_2). The questionnaire bridge's UserId now resolves the folder on first use, and roots are normalized.
  • Installed players with a read-only StreamingAssets folder could not run a study: the launcher created the log root outside the try around the process start, so the coroutine threw, Python never started and Update threw every frame. All logs (optimizer, questionnaires, Fitts telemetry, final-design row) now share one root that falls back to <persistentDataPath>/BOData/LogData where StreamingAssets is not writable, and every launcher failure ends the session with the error on screen. Custom questionnaire result paths are unchanged.
  • Objectives silently dropped sub-measures beyond Number Of Sub Measures (the last N were kept: 4 questionnaire items matching an objective with N = 3 discarded the first answer); a warning now says how many were ignored, and the submitted values are no longer trimmed in place.
  • Final-design row: its objective value used the old "one NaN → whole objective = midpoint" rule and 3 decimals, while Python's rows use the finite-mean rule and 10 significant digits since 1.8.0; it now uses the same reduction and round-trip precision. Its Iteration was totalIterations + 1, which under warm start could repeat a live row's Iteration; it is now one past the largest Iteration logged for this User/Condition/Group. The Context column was empty in contextual runs (now the current context key), and empty IDs are written as -1, as the backends log them.
  • Final-design round skipped after a warm start. When none of the participant's rows was flagged IsBest/IsPareto (a non-contextual warm start whose incumbent is a warm-start row: the backend flags warm-start and live rows together but writes only the participant's rows), selection failed. The selector now computes the best (one objective) or non-dominated (several objectives, first copy of duplicates) rows over the participant's own rows, using each objective's direction and bounds. It also normalizes user-folder names like the writers for control characters U+007F–U+009F, so such IDs find their log.
  • Typed questionnaire answers were parsed with the invariant culture only, so a German participant's 3,5 became NaN; answers are now parsed invariantly, then in the system culture, then with a decimal comma.
  • Warm-start objective columns from logs written before 1.8.0 could be read as already normalized. Objective columns used an absolute 1e-8 tolerance: a minimized objective on [0, 0.0045] logged at 3 decimals as 0, 0.002, 0.005 did not fit its raw bounds but fit [−1, 1], and was silently read as normalized — every design then sat at mid-scale. Objective columns now accept values up to half a unit of the third decimal outside their bounds (bo_normalize.objective_raw_bounds_tolerance; deliberately not a fraction of the range, which on [0, 10000] would read every normalized column as raw), the explicit raw format accepts the same artifacts (it raised before), and the reading is announced in output.txt when both readings fit and differ, and always on the normalized fallback. Parameter columns: the 1.8.0 tolerance (0.05 % of the range) missed 3-decimal logs on ranges narrower than 1 (0.1235 on [0.1, 0.1235] logged as 0.124 is 2 % of the range outside); they now accept half a unit of the third decimal or 0.05 % of the range, whichever is larger (raw_bounds_tolerance). Applies to BoTorch, MOBO, DBO and CABOP (CABOP's objective columns used their own silent check) and to meta_train.py. Changes how such warm-start files are read: do not upgrade within a running study.
  • A context without observations was predicted at the wrong level. MultiTaskGP fits one constant mean per context; an unobserved context's constant stayed at its initial 0, so a new participant's prediction was offset from the observed contexts it is correlated with (with the same embedding as the only observed participant: RMSE 0.52 instead of the twin's 0.09). It now takes the average of the observed contexts' constants, for every embedding source. Study-behaviour change for runs whose current context has no observations yet: do not upgrade within a running study.
  • CABOP multi-objective IsPareto marked only the rows with the lowest weighted score, so FinalDesignSelector got one candidate (or its copies) instead of the Pareto front, never seeing the trade-offs the final-design round exists to choose from. IsPareto now marks the non-dominated rows of the raw objective values in each objective's direction (the weights only steer the optimizer), first copy of duplicates only, as in MOBO and MetaTAF. This changes the final-design candidates of CABOP multi studies: do not upgrade within a running study.
  • CABOP's ObservationsPerEvaluation.csv still rounded parameters and objectives to 3 decimals — the only log the 1.8.0 precision fix missed — so small ranges were erased there and warm starts and FinalDesignSelector worked from values the participant never saw. Prefabricated values outside a parameter's bounds were accepted, and a proposal snapped to one was shown outside the configured range; they are now refused at startup. The expected cost CABOP reports with a proposal (not logged) was computed from unit-space coordinates read as parameter units.
  • MetaTAF and CABOP silently rewrote a User ID/Condition ID containing path characters (e.g. P/01) for the log-folder name; like the other backends they now print Warning: userId 'P/01' was normalized to safe log-folder token 'P_01'.
  • A negative Seed crashed MetaTAF at startup without an abort record (openbo seeds numpy's generator with it, which rejects negative values). It is now refused at init, before staging, with an explanation and a MetaRunState.json abort record; the inspector flags it. It is not mapped into range, because that would change the initial Sobol design, which must equal the BoTorch condition's for the same Seed.
  • A stray or unreadable file in the MetaTAF source folder aborted the start with a traceback: macOS AppleDouble ._<name>.json files (created by copies via exFAT/FAT32 or network shares), a gp_state that is valid JSON but not an object, a locked file or an undownloaded cloud placeholder. Such candidates are now skipped with their reason. Sources are copied into the run folder first and the copy — exactly what openbo loads — is validated; a sync client replacing a file between check and copy could stage unchecked content (e.g. a flipped minimize flag).
  • A half-replaced population model was accepted. meta_train.py publishes the trajectory, then the gp_state; when the second replace failed (Windows: the old gp_state held open by an editor, antivirus or sync client), the new trajectory sat next to the old hyperparameters and the frame check still passed. The gp_state now carries its trajectory's SHA-256 and the backend skips a pair that does not match; meta_train.py retries a held file for up to 5 s, restores the previous trajectory when the gp_state cannot be replaced, and fsyncs each file before publishing it.
  • MetaTAF: a population shared through git was refused on the other OS. Hashes compared raw bytes, so a checkout that converts line endings (core.autocrlf, the Git for Windows default) changed every gp_state's hash. Artifact hashes (manifest, trajectory stamp, MetaRunState.json, duplicate detection) now read CRLF as LF (meta_fingerprint.artifact_sha256), and meta_train.py writes LF on every OS.
  • MetaTAF frame check: a source frame whose params/objs lists were missing or shorter than the study's was reported compatible (the comparison stopped at the shorter list), and malformed entries (non-numeric bounds, a minimize flag other than 0/1) were compared as strings or crashed; they are now rejected with the field. A bound of -0 no longer mismatches 0. meta_train.py treated an objective without "minimize" in frame.json as maximized — an inverted population model whose frame stamp still matched any study with the same omission; the flag is now required, and missing key/low/high fields are named. A source whose name equals a MetaWeightsPerEvaluation.csv column (Iteration, OptimizationStep, TargetWeight, DecayFactor) duplicated that column; such sources are skipped and meta_train.py refuses such names. meta_train.py also recognizes the same run under another spelling on case-insensitive file systems (macOS).
  • A macOS Editor with a Windows build target ran the Windows Python setup, and a Windows Editor targeting macOS ran the macOS architecture check and failed every Play: the platform branches used UNITY_STANDALONE_*, which in the Editor follow the build target, not the OS the Editor runs on. The host OS is now decided at runtime.
  • Stopping Play during the dependency install left pip running, and the next Play started a second pip into the same environment, which can leave torch half-installed. The setup now runs once per project at a time (an in-process guard plus a lock file across script reloads and Unity instances; a second instance waits, also for the bundled Python installer, and a half-written lock file is re-read instead of taken for stale), a restart reuses a setup that is still running, and leaving Play mode, quitting or a script reload stops the setup and kills its pip. The whole setup has a 60-minute limit (45 minutes for pip) and reports a timeout instead of "Installing Python dependencies…" forever.
  • Failure reasons were lost. A failed bundled-Python install logged a generic error; pip's output was logged at Info level and cut to its first 500 characters, dropping the deciding error at the end. Now the reason is shown, the last lines of a failed pip run are logged as an error, the full output goes to <persistentDataPath>/BOData/pip-install.log, and known causes get a hint (PEP 668, Windows long paths, a Python too new for the pins, no network). The status text shows pip's progress during the install. The installed-package check could read the version of a stale ~name-x.y.dist-info folder that an interrupted upgrade leaves behind (pip ignores them) and report a mismatch pip could never repair; such folders are now skipped.
  • output.txt was overwritten at every Play, so the log of a failed run was gone after one restart. The previous log is kept as output.prev.txt, and at the end of a session output.txt is copied into the run's log folder (run/output.txt; into the condition folder as output_<timestamp>.txt when the backend never created a run folder). The stale, always-empty Assets/StreamingAssets/BOData/output.txt was removed from the repository.
  • Apple Silicon with an Intel (Rosetta) Unity: the universal2 Python inherited the x86_64 architecture and was rejected with a misleading "unsupported architecture" message. Python is now started natively via /usr/bin/arch -arm64 (probes, pip and the backend).
  • installation_python.bat: %errorlevel% inside parenthesised blocks is expanded when the block is parsed, so "Python 3.13 already installed?" was always answered yes once any python.exe existed at the target path, and a successful ensurepip was always reported as a failure. Error levels are now read after the command ran, timeout (which fails without a console, as when Unity runs the script) is no longer used, and the script works from paths with spaces or on a network share. A relative --venv DIR is relative to the folder the script was started from. The file is checked out with CRLF line endings (.gitattributes).
  • install_python.sh (Linux) accepted any python3.13 on PATH but then used the hard-coded /usr/bin/python3.13, so a Python 3.13 from pyenv or /usr/local failed later; the detected interpreter is now used throughout, and the deadsnakes PPA is only added on Ubuntu. The Windows and macOS scripts and the launcher verify the bundled Python and VC++ installers against their SHA-256 before running them with administrator rights, so a truncated copy or a Git LFS pointer file is reported instead of executed.
  • A manually configured Python path copied with surrounding quotes (Windows "Copy as path") or spaces is accepted; ~/ is expanded on macOS/Linux, and a bare command name (python3) is looked up on PATH.
  • Fitts law task with Configure Bo Manager For Fitts Task showed the default design in every round. Awake runs again after each scene reload against the persistent manager and replaced manager.parameters with the inspector defaults — discarding the suggestion just applied — and reset the IDs to -1, so Python logged its suggestion while the participant saw the defaults. The setup now runs once per manager.
  • Pressing R after a Fitts round (questionnaire open) sent 0 ms / 0 px to the optimizer — the best possible values — because the restart zeroed the measurements before they were submitted. The restart key is off by default and ignored once the round is complete.
  • Fitts layout safety adjustments were attributed to the wrong design. When the window was too small, the task shrank the button size/distance (and wrapped a hue of 1.0 to 0) and wrote the altered values into the manager's parameters, which Python never saw: the observation was stored under the suggested design while the final-design row logged the altered one. The manager's parameters now keep the suggested design; the rendered layout is logged in AppliedButtonSizePixels/AppliedButtonDistancePixels/AppliedLayoutAdjusted, with a warning.
  • A log write error stalled the study. The Fitts task writes its logs from the questionnaire's onQuestionnaireFinished, which runs before the questionnaire starts the optimization step; an IOException (CSV open in Excel, read-only folder) aborted the event and the study waited forever. Fitts log errors are now caught and reported, and QTQuestionnaireManager reports a throwing onQuestionnaireFinished listener and still starts the optimization step. Wrong-target clicks no longer enter the accuracy objective when Count Wrong Target Clicks is disabled, and the task's missing-component repair now actually adds missing components.
  • Input-System-only projects (the Unity 6 default) threw every frame: Input.GetKeyDown in FittsLawTask and the StandaloneInputModule that the Fitts task, QTQuestionnaireManager and QTManager add to a new EventSystem need the legacy Input Manager. The restart key is compiled only with the legacy Input Manager, and new EventSystems get InputSystemUIInputModule when it is disabled (preserved from IL2CPP stripping by Assets/BOforUnity/link.xml).
  • QuestionnaireToolkit: a questionnaire that failed to start was silent (the exception went to Console.WriteLine); it is now logged. A corrupt QTMetaData.txt crashed every start; it now falls back to 0 with a warning and is written via a temporary file. Additional CSV Items were formatted with the machine's culture (0,5 on de-DE, splitting the row); they are written culture-invariantly with round-trip precision. The Editor play-mode handler was added again on every Start; it is now registered once and removed in OnDestroy.
  • Standalone DBO bridge: DboClient.cs started one thread per call and serialized them with a lock, which is not FIFO, so the documented Reset(); Suggest(); could reach the server as suggest-then-reset; calls now go through one worker thread and a FIFO queue.
  • The single-objective demo's color wheel (ColorWheelMarker) was a linear texture and looked washed out next to the swatch in Linear color space; it is now sRGB. Deprecated FindObjectOfType/FindObjectsOfType calls in the examples, demo scripts and QuestionnaireToolkit were replaced.

Security

  • Standalone DBO bridge (docs/standalone-bridge): the server set allow_reuse_address (SO_REUSEADDR), which on Windows lets a second process bind the same port and receive the client's connections; it now uses the backends' policy (SO_EXCLUSIVEADDRUSE on Windows). Its unauthenticated save command wrote to any path the client sent; it now writes only inside the server's --save-dir (default: its working directory) and refuses absolute, drive-qualified and UNC paths, .. components and symlinks leading outside.

Tests

  • New tests/test_bo_protocol.py (44 tests, stub suite, CI): a UTF-8 character split at every byte; a malformed line while waiting for objectives raises without reading further; stop (also in place of init); non-dict values; the 64 MiB default and its override, a ~7 MB init with 300 1280-d embeddings; atomic rewrites, a brief lock retried, a lasting lock reported once and caught up, the .unsaved.csv copy, a log that refuses only the replace, file permissions. Through stub harnesses for all five backends: iteration equals the logged Iteration (also under warm start); a stop ends main() cleanly with complete logs; init checks fire before the first design; a log held open for the whole session neither ends the study nor loses a row, also when the backend is killed; rewritten IDs are announced. tests/dbo_protocol_check.py checks iteration and runs a second session that ends with a stop (exit code 0, every evaluation logged).

  • BoTorch/MOBO/contexts (real stack, skipped on CI): a new participant with the same or a nearby manual embedding gets the same fitted correlation and prediction for every torch seed (fails with stock LCEMGP); a context sharing its embedding with an oppositely behaving one follows its own data; an unobserved context with the same embedding as the only observed one gets that context's posterior; ages 25/40/60 stay distinct; MOBO with designs (best, worst) and (worst, best) logs hypervolume 0.21 → 0.41 at [−1.1, −1.1]. Stub tests (CI): finalized flags after a stop during sampling (failed before); reference point and the mcSamples fallback 128; manual-embedding standardization and the identical/merged-context warnings; objective and parameter warm-start tolerances (the [0, 0.0045] and 0.124 cases, wide normalized ranges not read as raw, announcements); Scale/BestObjectiveRaw.

  • CABOP: real-stack tests that the soft cost and the proposed designs are the same on [0, 1], [0, 100], [0, 0.01] and [−3, 7]; tolerances outside [0, 1] and out-of-bounds prefabricated values refused; 10 significant digits; warm start numbering, baseline row and flag competition; IsPareto equal to moocore's mask on 300 random Likert-like matrices. New tests/test_cabop_runtime_stub.py (10 tests, CI) covers the runtime with a stand-in optimizer. DBO: the acquisition search with init_batch_limit 128 returns the unpatched suggestion.

  • MetaTAF: 63 new tests (runtime 26, frame check 8, new tests/test_meta_train.py 29), each failing against the previous code except a backward-compatibility guard: stray/unreadable candidates, validation of the staged copy, trajectory stamps, MetaRunState.json progress and finish reasons, negative seed, weights Iteration, reference point −1.1, the population manifest (frozen set accepted; missing, added, replaced, unloadable or CRLF-converted sources), path-tail naming and [KEEP] on rebuilds (including 1.8.0-named and moved folders), --dry-run, --force, --name/--names, required minimize, held-file retries. Checked against openbo main @ 4327988: identical hypervolumes, weights rows at the global Iteration, a negative seed refused with an abort record.

  • Unity EditMode (BOforUnity.EditModeTests; com.unity.test-framework is now in Packages/manifest.json, so the assembly compiles and appears in the Test Runner): new BoObjectiveMath, LogDataFolderUtility (folder reuse across conditions, roots with and without a trailing separator) and Fitts math (ISO order, We/IDe/TP) tests, and 8 new FinalDesignSelector tests (computed fallback, aggression by configured bounds, multi-root search).

  • CI: the full-stack job installed torch==2.13.0 while requirements.txt pins 2.14.1, so pip replaced it with PyPI's multi-GB CUDA build. It now reads the torch pin from requirements.txt, installs from the CPU index like the Linux launcher and asserts a matching CPU build, and also runs tests/dbo_protocol_check.py and the standalone bridge tests. The bridge's transport tests (26 tests in total, 11 before) run in every PR job (Python 3.11–3.14).

  • Model library: new tests/test_bo_library.py (45 tests, stub suite, CI) covers BO_MODEL_LIBRARY parsing, the fallbacks and their messages (botorch-rust missing, contextual mode without its LCEMGP, provided embeddings, MetaTAF without its PosteriorMeanModel), the names bo.py/mobo.py bind, the per-iteration seed (BoTorch's options unchanged), threads and RunInfo.json; MetaTAF (6 runtime tests: the library openbo is given and actually binds, fallbacks, the record) and meta_train.py (4). Real stack, skipped unless botorch_rs is importable: complete plain bo.py and mobo.py loops on botorch-rust, the initial design bit-identical to BoTorch's (also for general bounds, float32 and q > 1), two runs with the same Seed giving identical suggestions, negative seeds, contextual runs with Learned and Manual embeddings (or their fallbacks), botorch-rust's provided-embedding model against this project's (same MLL and posterior before fitting, to 1e-9), LCE-M's seeding from torch's generator, and meta_train.py's fit (same hyperparameters as BoTorch's to 1e-3).

Documentation

  • New README section 8.16 (model library: selecting it, what runs on botorch-rust, fallbacks, speed, threads, the library as a study factor, installation); 8.7, 8.11 and 8.12 refer to it, and 8.12 lists RunInfo.json.
  • Correction to 1.8.0 ("keep the population-weight decay enabled"): openbo's run_mobo_taf does not default to the decay (2, 0.3). On openbo main (4327988) run_mobo_taf has no decay arguments and MOTAFConfig defaults to decay_start_iter=0, decay_rate=0.0 ("never decay"), so simulations through run_mobo_taf still run without decay. Unity runs are unaffected: the backend always passes the inspector's values (default d1 = 2, d2 = 0.3).
  • Student guide and MetaSources/README.md: the new artifact names and meta_train.py options, population.json, the Iteration/OptimizationStep columns, the −1.1 reference point, MetaRunState.json's progress and finish fields, the non-negative Seed, installing openbo into the Optimizer Python interpreter, and troubleshooting rows for the new messages. docs/dbo-backend.md documents the local dbo-torch patch and to keep it when refreshing the vendored copy.