Repository navigation
v1.9.0
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,BotorchRustorAuto), which Unity passes to Python asBO_MODEL_LIBRARY(botorch,botorch_rs,auto). Per suggestion, botorch-rust measured 10–14× faster forbo.pyand 3.5–17× faster formobo.py, both on one thread.bo.pyandmobo.pyrun on it now. Contextual optimization runs on it with every embedding source (Learned: botorch-rust'sLCEMGP;Manual/Image: itsProvidedEmbeddingLCEMGP, 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.BotorchRustwithout botorch-rust installed refuses to start;Autothen 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.pyruns botorch-rust onBO_TORCH_THREADSthreads (default 1, as for PyTorch);mobo.pyand MetaTAF useBO_TORCH_THREADSwhen 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 inrequirements.txt). With the defaultBoTorchnothing changes. RunInfo.jsonin everybo.py/mobo.pyrun 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_version3) gets the samemodel_libraryblock, including the library openbo actually bound, and listsbotorch-rustamong its versions;meta_train.pystamps 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'spip 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 siblingpython-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 withoutpython3.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/pillowfor image embeddings — must be installed again with the interpreter the Console logs at startup asOptimizer 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.batinstalls Python 3.13 and the VC++ runtime,MacOs/install_python.shthe bundled Python 3.13,Linux/install_python.shPython 3.13 with itsvenvmodule (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 DIRadditionally creates an environment withrequirements.txtinDIRfor 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 inrequirements.txtwith what the backend's interpreter would import and reinstalls on a mismatch. The installed versions are written at the top ofoutput.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+cpusatisfies thetorch==2.14.1pin) instead of PyPI's CUDA build with several GB ofnvidia-*packages. Offline labs: wheels inStreamingAssets/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.appafterwards (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,
BoForUnityManagerprefab, the four example scenes, and the fallback ofbo.py,mobo.pyand 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 andHypervolumePerEvaluation.csvstayed 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 bymeta_train.pyand skipped by MetaTAF. The value (bo_normalize.HYPERVOLUME_REFERENCE_VALUE) is used by qLogNEHVI, openbo's population terms, the coverage metric, the loggedReferencePoint([-1.1,-1.1]) andmeta_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
wthat 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.Learnedembeddings: posteriors of observed contexts are unchanged. Normalize Embeddings(inspector label formerly "L2-Normalize Embeddings"; fieldnormalizeContextEmbeddings) 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.csvand its mirrorHypervolumePerEvaluation.csvwritten by BoTorch (bo.py) and DBO gainScale(frame and direction ofBestObjective) andBestObjectiveRaw(the same best observation in the objective's own units; empty on a contextual warm-start baseline row without current-context observations).BestObjectivestays the normalized [−1, 1] maximize-space value thatcoveragereports — 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 Tolerancewas 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 andFittsLawTask'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/IsParetoamong the live rows only. Evaluations are now numbered k + 1, k + 2, … in every log and in theparametersmessages, the compatibility metric file starts with a baseline row at Iteration k, and warm-start rows compete forIsBest/IsParetoas inbo.py/mobo.py(a live row better than none of them is not marked).CABOPMetricsPerEvaluation.csvkeeps 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, asbo.py/mobo.pysince 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 indbo_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), andbo.py/mobo.pyshare 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
parametersmessage carriesiteration: theIterationthe design will be logged under inObservationsPerEvaluation.csv(warm-start and sampling rows included). Unity keeps it asBoForUnityManager.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 printsStop requested by Unity: <reason>and exits with code 0, with every completed evaluation logged. Unity sends it for a perfect-rating stop (reasonperfect_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 reachedoutput.txtand 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 asP01_1— a differentuserIdsent to Python and written into every log row — contradicting README 6.2.1. NowP01running condition B after A logs toP01/B; only a repeatedP01/AgetsP01_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 withTime.timeScale = 0never advanced. - New optional Progress Text (
progressText, TMP_Text) showsIteration x / N(orFinal design) whenever a design is ready. New script API:BoForUnityManager.Instance(the running, persistent manager),LastSuggestionLogIteration,FinalDesignLogIteration(theIterationof the final-design row, −1 until it is selected) andTerminateWithError(text). Optimizerfinds the manager on first use (BoForUnityManager.Instance, else its own GameObject's): every call silently did nothing untilOptimizer.Startran, so calls from another script'sAwake/Startwere lost.GetParameterValue/GetParameter/GetObjectivewarn once per unknown key — a typo returned 0 (or a detachedParameterArgs) all study long without a message — andAddObjectiveValuewarns 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 theIterationthe 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 Downdefault,Pointer Click= previous behaviour). Movement times are shorter than with earlier versions: do not upgrade within a running study, or setSelection 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.csvgainsSelectionEvent,IsoTrialCount,IsoWidthPixels(W),IsoAmplitudePixels(D),IsoID,IsoEffectiveAmplitudePixels(De),IsoEffectiveWidthPixels(We = 4.133 · SD of the endpoint deviations along the task axis),IsoIDe,IsoMovementTimeMsandIsoThroughputBitsPerSecond(TP = IDe / MT), excluding each round's first trial;FittsLawTrialLog.csvgainsFromTargetX/Y,AmplitudePixels,EndpointDeviationPixels,EffectiveAmplitudePixelsandIncludedInIsoMetrics. Both files also gainAppliedButtonSizePixels/AppliedButtonDistancePixels(and the app logAppliedLayoutAdjusted; see Fixed). The objectives sent to the optimizer are unchanged. - Fitts law logs use the
Iterationof the same evaluation inObservationsPerEvaluation.csv(LastSuggestionLogIteration; the final-design round usesFinalDesignLogIteration): under warm start they were numbered 1, 2, … while Python numbered the evaluations k + 1, k + 2, …, so a join onIterationpaired telemetry with the wrong designs. Values are written with round-trip precision instead of 3 decimals, and the*Objectivecolumns 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 toFittsLawAppLog_1.csvetc. instead of being appended under a mismatching header. Restart With KeyonFittsLawTaskdefaults to off (also in the scene) and only restarts a running round (see Fixed).- QuestionnaireToolkit
started/finishedtimestamps 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);ColorGuesserandObjectVariableExposernormalize with the parameter's bounds, whileTargetClickerEvaluatorkeeps reading the value itself as a fraction (its bounds narrow the size/eccentricity range, as before) and writes click times to the objectiveAverageClickTimeMS(fallback: the second objective).ObjectVariableExposernow fills itscolorR/G/B/AandisActivefields, which were never assigned. The Examples scripts compile into their own auto-referenced assembly (BOforUnity.Examples), so scenes and user scripts keep working. MetaWeightsPerEvaluation.csvjoins onIterationlike every other log. ItsIterationwas the optimization step (1..N), so a join paired each weights row with the evaluationnumSamplingIterationsrows earlier — mostly a sampling-phase design the weights never influenced.Iterationis 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, addnumSamplingIterationstoIterationbefore joining.MetaRunState.jsonfollows the run (schema_version2): likeDboRunState.jsonit is rewritten atomically after every evaluation withprogress(evaluations completed, lastIteration, latest hypervolume and source weights) and ends withfinishedplusfinish_reason(completed,stop_requestedwith Unity's reason,error,startup_abort). A file still saying"finished": falsebelongs 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.pynames 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--outwith 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--outkeeps 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--forceis given, and--dry-runchecks 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.pywrites it into--outafter 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 Textbut never showed theOptimizer State Panelcontaining it, never ended the loop and never cancelled a pending automatic advance. All failures now go throughBoForUnityManager.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 ofoutput.txtinstead 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, andContextwith 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 Restartscrashed 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.csvafter every evaluation; while a researcher had it open, the write raisedPermissionErrorand 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 forIsBest/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 toObservationsPerEvaluation_finaldesign_<timestamp>.csvwith 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,
fsyncand 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
objectivesmessage without avaluesdict 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
JsonConvertcall picked upJsonConvert.DefaultSettings: a camelCase resolver lowercased the keys Unity sent (Trust→trust, a missing-key error in Python),MissingMemberHandling.Errormade every incoming message throw,Formatting.Indentedsplit messages across lines (breaking NDJSON framing), andPreserveReferencesHandling/DefaultValueHandling.Ignoreadded$idkeys or dropped0/falsesettings. 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 oroptimization_finishedto 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/IsParetoflags (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 loggedIsBestTRUE, TRUE, TRUE), MOBO and MetaTAFIsPareto = FALSEon 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/Startcould 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
Startread 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_1vsP01_2). The questionnaire bridge'sUserIdnow 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
tryaround the process start, so the coroutine threw, Python never started andUpdatethrew every frame. All logs (optimizer, questionnaires, Fitts telemetry, final-design row) now share one root that falls back to<persistentDataPath>/BOData/LogDatawhere 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
IterationwastotalIterations + 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. TheContextcolumn 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,5became 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 explicitrawformat accepts the same artifacts (it raised before), and the reading is announced inoutput.txtwhen 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 tometa_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
IsParetomarked 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.IsParetonow 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.csvstill 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 IDcontaining path characters (e.g.P/01) for the log-folder name; like the other backends they now printWarning: 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.jsonabort 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>.jsonfiles (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.pypublishes 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.pyretries 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), andmeta_train.pywrites LF on every OS. - MetaTAF frame check: a source frame whose
params/objslists 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-0no longer mismatches0.meta_train.pytreated an objective without"minimize"inframe.jsonas maximized — an inverted population model whose frame stamp still matched any study with the same omission; the flag is now required, and missingkey/low/highfields are named. A source whose name equals aMetaWeightsPerEvaluation.csvcolumn (Iteration,OptimizationStep,TargetWeight,DecayFactor) duplicated that column; such sources are skipped andmeta_train.pyrefuses such names.meta_train.pyalso 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-infofolder that an interrupted upgrade leaves behind (pip ignores them) and report a mismatch pip could never repair; such folders are now skipped. output.txtwas overwritten at every Play, so the log of a failed run was gone after one restart. The previous log is kept asoutput.prev.txt, and at the end of a sessionoutput.txtis copied into the run's log folder (run/output.txt; into the condition folder asoutput_<timestamp>.txtwhen the backend never created a run folder). The stale, always-emptyAssets/StreamingAssets/BOData/output.txtwas 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 anypython.exeexisted at the target path, and a successfulensurepipwas 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 DIRis relative to the folder the script was started from. The file is checked out with CRLF line endings (.gitattributes).install_python.sh(Linux) accepted anypython3.13onPATHbut then used the hard-coded/usr/bin/python3.13, so a Python 3.13 from pyenv or/usr/localfailed 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 onPATH. - Fitts law task with
Configure Bo Manager For Fitts Taskshowed the default design in every round.Awakeruns again after each scene reload against the persistent manager and replacedmanager.parameterswith 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; anIOException(CSV open in Excel, read-only folder) aborted the event and the study waited forever. Fitts log errors are now caught and reported, andQTQuestionnaireManagerreports a throwingonQuestionnaireFinishedlistener and still starts the optimization step. Wrong-target clicks no longer enter the accuracy objective whenCount Wrong Target Clicksis 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.GetKeyDowninFittsLawTaskand theStandaloneInputModulethat the Fitts task,QTQuestionnaireManagerandQTManageradd to a new EventSystem need the legacy Input Manager. The restart key is compiled only with the legacy Input Manager, and new EventSystems getInputSystemUIInputModulewhen it is disabled (preserved from IL2CPP stripping byAssets/BOforUnity/link.xml). - QuestionnaireToolkit: a questionnaire that failed to start was silent (the exception went to
Console.WriteLine); it is now logged. A corruptQTMetaData.txtcrashed 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,5on de-DE, splitting the row); they are written culture-invariantly with round-trip precision. The Editor play-mode handler was added again on everyStart; it is now registered once and removed inOnDestroy. - Standalone DBO bridge:
DboClient.csstarted one thread per call and serialized them with a lock, which is not FIFO, so the documentedReset(); 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. DeprecatedFindObjectOfType/FindObjectsOfTypecalls in the examples, demo scripts and QuestionnaireToolkit were replaced.
Security
- Standalone DBO bridge (
docs/standalone-bridge): the server setallow_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_EXCLUSIVEADDRUSEon Windows). Its unauthenticatedsavecommand 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-dictvalues; 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.csvcopy, a log that refuses only the replace, file permissions. Through stub harnesses for all five backends:iterationequals the loggedIteration(also under warm start); a stop endsmain()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.pychecksiterationand 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
mcSamplesfallback 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;
IsParetoequal to moocore's mask on 300 random Likert-like matrices. Newtests/test_cabop_runtime_stub.py(10 tests, CI) covers the runtime with a stand-in optimizer. DBO: the acquisition search withinit_batch_limit128 returns the unpatched suggestion. -
MetaTAF: 63 new tests (runtime 26, frame check 8, new
tests/test_meta_train.py29), each failing against the previous code except a backward-compatibility guard: stray/unreadable candidates, validation of the staged copy, trajectory stamps,MetaRunState.jsonprogress and finish reasons, negative seed, weightsIteration, 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, requiredminimize, held-file retries. Checked against openbomain@4327988: identical hypervolumes, weights rows at the globalIteration, a negative seed refused with an abort record. -
Unity EditMode (
BOforUnity.EditModeTests;com.unity.test-frameworkis now inPackages/manifest.json, so the assembly compiles and appears in the Test Runner): newBoObjectiveMath,LogDataFolderUtility(folder reuse across conditions, roots with and without a trailing separator) and Fitts math (ISO order, We/IDe/TP) tests, and 8 newFinalDesignSelectortests (computed fallback, aggression by configured bounds, multi-root search). -
CI: the full-stack job installed
torch==2.13.0whilerequirements.txtpins 2.14.1, so pip replaced it with PyPI's multi-GB CUDA build. It now reads the torch pin fromrequirements.txt, installs from the CPU index like the Linux launcher and asserts a matching CPU build, and also runstests/dbo_protocol_check.pyand 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) coversBO_MODEL_LIBRARYparsing, the fallbacks and their messages (botorch-rust missing, contextual mode without its LCEMGP, provided embeddings, MetaTAF without itsPosteriorMeanModel), the namesbo.py/mobo.pybind, the per-iteration seed (BoTorch's options unchanged), threads andRunInfo.json; MetaTAF (6 runtime tests: the library openbo is given and actually binds, fallbacks, the record) andmeta_train.py(4). Real stack, skipped unlessbotorch_rsis importable: complete plainbo.pyandmobo.pyloops 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 withLearnedandManualembeddings (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, andmeta_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_tafdoes not default to the decay (2, 0.3). On openbomain(4327988)run_mobo_tafhas no decay arguments andMOTAFConfigdefaults todecay_start_iter=0, decay_rate=0.0("never decay"), so simulations throughrun_mobo_tafstill 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 andmeta_train.pyoptions,population.json, theIteration/OptimizationStepcolumns, the −1.1 reference point,MetaRunState.json's progress and finish fields, the non-negative Seed, installing openbo into theOptimizer Pythoninterpreter, and troubleshooting rows for the new messages.docs/dbo-backend.mddocuments the local dbo-torch patch and to keep it when refreshing the vendored copy.