Miguel de Benito Delgado cc6dcb58d2 ♻️ Consolidate case declaration macros and helpers
AI-assisted-by: deepseek-flash
2026-11-06 16:20:43 +01:00

371 lines
14 KiB
Clojure

;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns benches.render-wasm.declarations
"Declarations, registry and contracts for renderer benchmark scenes and cases.
A scene declares an id, description, parameters schema and build function.
A case declares an id namespaced by its scene, parameters, view, context
policy, completion mode and optional operation identity and run function.
Ordinary :require loads the declaration macros and runtime helpers.
Collection validates declarations and produces plain descriptors."
#?(:cljs (:require-macros
[app.common.exceptions :as ex]
[benches.render-wasm.declarations]))
#?(:clj (:require [app.common.schema :as-alias sm]))
#?(:cljs
(:require
[app.common.schema :as sm]
[app.common.transit :as t]
[clojure.string :as str])))
#?(:clj
(do
(defn- single-arg-vector?
"True when `form` is a vector holding one plain symbol."
[form]
(and (vector? form)
(= 1 (count form))
(symbol? (first form))
(not= '& (first form))))
(defn- check-arg-vector!
[macro id argv]
(when-not (single-arg-vector? argv)
(throw (ex-info (str macro " " id " requires one argument vector with one symbol")
{:type ::invalid-run-function
:id id
:form argv}))))
(defmacro defscene
"Registers a scene and returns its id.
`opts` supplies :description and :params-schema. `argv` names one scene
parameter symbol, and `body` builds the snapshot.
Example:
(defscene :rects opts [params] (scene params))"
[id opts argv & body]
(check-arg-vector! "defscene" id argv)
`(benches.render-wasm.declarations/register-scene!
(assoc ~opts :id ~id :ns ~(str *ns*) :build (fn ~argv ~@body))))
(defn- case-registration-form
[id scene opts run-fn]
(let [options (gensym "options")]
`(let [~options ~opts]
(when (contains? ~options :run!)
(throw (ex-info (str "defcase " ~id " requires its run function as the fourth argument")
{:type :benches.render-wasm.declarations/invalid-run-function
:id ~id})))
(when (nil? ~scene)
(throw (ex-info (str "defcase " ~id " requires a :scene/case id")
{:type :benches.render-wasm.declarations/invalid-run-function
:id ~id
:form ~id})))
(benches.render-wasm.declarations/register-case!
(assoc ~options :id ~id :scene ~scene :ns ~(str *ns*)
~@(when run-fn [:run! run-fn]))))))
(defn- scene-from-id
"Returns the scene keyword from a qualified case id, or nil."
[id]
(when-let [scene-ns (namespace id)]
(keyword scene-ns)))
(defmacro defcase
"Registers a case and returns its id.
The scene comes from the namespace part of the :scene/case id. An
optional argument vector and body supply the run function, which accepts
one execution context. Collection produces plain case descriptors.
Example:
(defcase :rects/pan options [context] (pan! context))"
([id opts]
(case-registration-form id (scene-from-id id) opts nil))
([id opts argv & body]
(check-arg-vector! "defcase" id argv)
(case-registration-form id (scene-from-id id) opts `(fn ~argv ~@body))))))
#?(:cljs
(do
;; Case execution context:
;; :case - collected descriptor, including operation and completion
;; :scene - scene snapshot {:objects ... :refs ...}
;; :module - WASM module handle
;; :view - prepared camera view
;; :hooks - clock, scheduling, guard and renderer operations
;; Case bodies return a measurement or a Promise of one.
;; Declarations and their transitive imports load in Node and the browser.
;; Browser capabilities enter through the execution context; resource
;; acquisition belongs to untimed preparation. Scenes keep their imports
;; safe for Node discovery.
(def default-viewport
"Default viewport dimensions and device pixel ratio for a case."
{:width 1920 :height 1080 :dpr 2})
(def schema:viewport
[:map {:closed true}
[:width {:optional true} ::sm/positive-safe-number]
[:height {:optional true} ::sm/positive-safe-number]
[:dpr {:optional true} ::sm/positive-safe-number]])
(def schema:view
"Camera placement with finite coordinates, positive scale and optional
viewport overrides."
[:map {:closed true}
[:scale ::sm/positive-safe-number]
[:x ::sm/safe-number]
[:y ::sm/safe-number]
[:viewport {:optional true} schema:viewport]])
(defn resolve-viewport
"Resolves a view's viewport overrides against the default viewport."
[view]
(merge default-viewport (:viewport view)))
(def schema:scene
[:map {:closed true}
[:id simple-keyword?]
[:ns string?]
[:description string?]
[:params-schema vector?]
[:build fn?]])
(def schema:registered-case
[:map {:closed true}
[:id qualified-keyword?]
[:scene simple-keyword?]
[:ns string?]
[:params [:map]]
[:view schema:view]
[:context [:enum :fresh :reuse]]
[:completion {:optional true} [:enum :call :await :render-full]]
[:batch-size {:optional true} [:int {:min 1}]]
[:operation {:optional true} [:map]]
[:preparation {:optional true} [:map]]
[:run! {:optional true} fn?]])
(def schema:collected-case
[:map {:closed true}
[:id qualified-keyword?]
[:scene simple-keyword?]
[:scene-description string?]
[:scene-seed [:int {:min 0 :max 4294967295}]]
[:params [:map]]
[:view schema:view]
[:context [:enum :fresh :reuse]]
[:completion [:enum :call :await :render-full]]
[:batch-size [:int {:min 1}]]
[:preparation [:map]]
[:operation {:optional true} [:map]]])
(defonce registry
(atom {:scenes {}
:scene-order []
:cases {}
:case-order []}))
(defn- register-entry
[entries-key order-key kind duplicate-type {:keys [id ns] :as entry}]
(let [existing (get-in @registry [entries-key id])]
(when (and (some? existing) (not= ns (:ns existing)))
(throw (ex-info (str "duplicate " kind " id: " id)
{:type duplicate-type
:id id
:ns ns
:existing-ns (:ns existing)})))
(swap! registry
(fn [reg]
(cond-> (assoc-in reg [entries-key id] entry)
(not (contains? (get reg entries-key) id))
(update order-key conj id)))))
id)
(defn register-scene!
"Registers a scene. The same id from the same namespace replaces the
entry in declaration order. Another namespace raises ::duplicate-scene."
[scene]
(register-entry :scenes :scene-order "scene" ::duplicate-scene scene))
(defn register-case!
"Registers a case. The same id from the same namespace replaces the
entry in declaration order. Another namespace raises ::duplicate-case."
[bench-case]
(register-entry :cases :case-order "case" ::duplicate-case bench-case))
(defn- unregister-entry
[entries-key order-key id]
(swap! registry
(fn [reg]
(-> reg
(update entries-key dissoc id)
(update order-key #(vec (remove #{id} %))))))
id)
(defn unregister-scene!
"Removes a scene registration and returns its id."
[id]
(unregister-entry :scenes :scene-order id))
(defn unregister-case!
"Removes a case registration and returns its id."
[id]
(unregister-entry :cases :case-order id))
(defn scenes
"Returns registered scenes in declaration order."
[]
(mapv (:scenes @registry) (:scene-order @registry)))
(defn cases
"Returns registered cases in declaration order."
[]
(mapv (:cases @registry) (:case-order @registry)))
(defn registered-scene
"Returns the full registration for a scene id."
[id]
(get-in @registry [:scenes id]))
(defn registered-case
"Returns the full registration for a case id, including its run function."
[id]
(get-in @registry [:cases id]))
(defn- check-params-schema!
[{:keys [id params-schema]}]
(try
(sm/schema params-schema)
(catch :default cause
(throw (ex-info (str "invalid params schema for scene " id)
{:type ::invalid-scene :id id}
cause)))))
(defn- check-declaration
[schema declaration message error-type]
(when-not (sm/validate schema declaration)
(throw (ex-info (str message (:id declaration))
{:type error-type
:id (:id declaration)
::sm/explain (sm/explain schema declaration)})))
declaration)
(defn check-registered-scene!
"Validates a scene declaration and returns it unchanged."
[scene]
(check-params-schema! scene)
(check-declaration schema:scene scene "invalid scene declaration: " ::invalid-scene))
(defn check-registered-case!
"Validates a registered case declaration and returns it unchanged."
[bench-case]
(check-declaration schema:registered-case bench-case "invalid case declaration: " ::invalid-case))
(defn- check-param-keys!
"Rejects params keys that `canonical-params` cannot tell apart: only
simple keywords render distinctly by name."
[scene-id params]
(letfn [(check [value path]
(cond
(map? value)
(doseq [[k v] value]
(when-not (simple-keyword? k)
(throw (ex-info (str "seed identity requires simple keyword keys at " path)
{:type ::invalid-params
:scene scene-id
:path path
:key k})))
(check v (str path "/" (name k))))
(vector? value)
(dorun (map-indexed (fn [index v] (check v (str path "[" index "]"))) value))))]
(check params "")))
(defn- canonical-params
"Stable textual identity of a params map: keys render sorted by name,
vectors keep their order, nested values render the same way. Map key
order is an accident of construction, so sorting keeps the identity on
the parameters themselves: equal parameters give the same seed whatever
their literal order. Keys render by name, so params maps use simple
keyword keys; derive-seed rejects anything else."
[params]
(letfn [(render [value]
(cond
(map? value)
(str "{"
(str/join ","
(map (fn [[k v]] (str (name k) ":" (render v)))
(sort-by (comp name key) value)))
"}")
(vector? value)
(str "[" (str/join "," (map render value)) "]")
:else
(pr-str value)))]
(render params)))
(defn derive-seed
"FNV-1a 32-bit keyed hash of a case's parameter identity.
Fowler-Noll-Vo, 32-bit, offset basis 2166136261, prime 16777619, hashed
over `<master-seed>\\0<scene-id>/<canonical params>`. A keyed hash, not
the scene PRNG: the scene's mulberry32 generator is seeded with the
result, and a sequential draw cannot substitute because it would couple
cases through collection order. Keyed by scene and params only, so the
seed does not depend on case name, collection order, operation body,
attempts or A/B feature selection. Params maps use simple keyword keys;
namespaced keywords and string keys cannot be told apart by name and make
derive-seed throw ::invalid-params."
[master-seed scene-id params]
(check-param-keys! scene-id params)
(let [text (str master-seed "\u0000" (name scene-id) "/" (canonical-params params))]
(loop [index 0
seed 2166136261]
(if (< index (count text))
(recur (inc index)
(unsigned-bit-shift-right
(js/Math.imul (bit-xor seed (.charCodeAt text index)) 16777619)
0))
seed))))
(defn transit-round-trips?
"True when `value` survives Transit encoding and decoding with the same
wire representation, including NaN and infinities."
[value]
(true? (ex/ignoring
(let [encoded (t/encode-str value)]
(= encoded (-> encoded t/decode-str t/encode-str))))))
(defn project-case
"Returns the case's descriptor fields with defaults for completion,
batch size and preparation."
[bench-case]
(-> bench-case
(select-keys [:id :scene :params :view :context :completion :batch-size
:operation :preparation])
(update :completion #(or % :render-full))
(update :batch-size #(or % 1))
(update :preparation #(or % {}))))
(defn check-collected-case
"Validates a collected case descriptor and its Transit representation,
returning the descriptor unchanged."
[projected]
(check-declaration schema:collected-case projected "invalid collected case: " ::invalid-case)
(when-not (transit-round-trips? projected)
(throw (ex-info (str "collected case does not survive the transit wire: " (:id projected))
{:type ::non-serializable-case
:id (:id projected)})))
projected)))