Skip to content

Loop binding-type inference picks candidate types in hash-set order #68

Description

@skydread1

Problem

Loop binding-type inference reduces its candidate types in hash-set iteration order, which varies from process to process. A namespace with a tie-prone loop/recur can therefore infer different binding types on different runs and compile to different DLL bytes from unchanged sources, breaking the byte-reproducibility guarantee that bb check-drift and CI enforce.

There are two layers to it. First, the candidate reduce itself, in magic.analyzer.loop-bindings/infer-binding-types (and the same shape again in the :loop case of typed_passes.clj):

candidate-types  (->> binding-types
                      (conj recur-expr-types)
                      (apply map hash-set)          ;; one candidate set per binding position
                      (map #(reduce best-type %))   ;; folded in hash-set iteration order
                      vec)

Second, best-type itself breaks ties by keeping whichever argument it saw first, and its inputs also come out of set operations (ancestors returns a hash set, s/intersection keeps that order):

heaviest-interface (when-not (empty? common-interfaces)
                     (reduce
                      (fn [t1 t2]
                        (if (> (count (.GetInterfaces t2))
                               (count (.GetInterfaces t1)))
                          t2 t1))          ;; equal counts keep t1, so the winner
                      common-interfaces))  ;; depends on set iteration order

Concretely: if two candidate interfaces implement the same number of interfaces, the "heaviest" one is just whichever the hash set yielded first, and .NET hashes Type objects by runtime handle, so that order changes across processes.

This is the same identity-hash-order family as #60, in the inference path instead of the type-reuse path. No failing namespace is known yet (while pinning #60, instrumentation showed inference outcomes were stable for clojure.math.combinatorics), so this is a latent hazard rather than an observed failure, but the tie condition is reachable by ordinary code.

Suggestion

Sort every one of these type collections by name before reducing, exactly what #61 did for fresh-type in generated_types.clj. One helper in loop_bindings.clj:

(defn ordinal-sort [types]
  (sort-by str #(String/CompareOrdinal %1 %2) types))

applied at the three order-sensitive points:

;; infer-binding-types (and the :loop pass in typed_passes.clj)
(map #(reduce best-type (ordinal-sort %)))

;; inside best-type
common-types       (ordinal-sort (remove #(.IsInterface %) common-ancestors))
common-interfaces  (ordinal-sort (filter #(.IsInterface %) common-ancestors))

Ordinal comparison rather than default compare, because CLR string comparison is culture-sensitive and check-drift byte-diffs across machines with different locales. Ties still exist after this, but they resolve the same way in every process, which is all determinism needs.

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions