Repository: tweag/nickel
Pull request #1635 (2023-09-27 14:42:56)

Track more metrics
Add a facility for recording numeric samples and aggregating them as a metric. Added tracked metrics:
- number of calls to `Thunk::new`
- number of calls to `Thunk::new_rev`
- number of calls to `Environment::insert`
- number of calls to `Environment::get`
- the size of an environment when it is actually cloned in `Environment:clone()`
- the size of each environment layer inspected during `Environment::get`
- the number of environment layers traversed during `Environment::get`


Patch:
diff --git a/Cargo.lock b/Cargo.lock
index ed32b8c3..9ba3621b 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1131,6 +1131,15 @@ version = "0.12.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
 
+[[package]]
+name = "hashbrown"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "33ff8ae62cd3a9102e5637afc8452c55acf3844001bd5374e0b0bd7b6616c038"
+dependencies = [
+ "ahash",
+]
+
 [[package]]
 name = "hashbrown"
 version = "0.14.0"
@@ -1464,6 +1473,15 @@ dependencies = [
  "url",
 ]
 
+[[package]]
+name = "mach2"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6d0d1830bcd151a6fc4aea1369af235b36c1528fe976b8ff678683c9995eade8"
+dependencies = [
+ "libc",
+]
+
 [[package]]
 name = "malachite"
 version = "0.3.2"
@@ -1576,6 +1594,25 @@ dependencies = [
  "syn 2.0.33",
 ]
 
+[[package]]
+name = "metrics-util"
+version = "0.15.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4de2ed6e491ed114b40b732e4d1659a9d53992ebd87490c44a6ffe23739d973e"
+dependencies = [
+ "aho-corasick",
+ "crossbeam-epoch",
+ "crossbeam-utils",
+ "hashbrown 0.13.1",
+ "indexmap 1.9.3",
+ "metrics",
+ "num_cpus",
+ "ordered-float",
+ "quanta",
+ "radix_trie",
+ "sketches-ddsketch",
+]
+
 [[package]]
 name = "minimad"
 version = "0.12.0"
@@ -1631,6 +1668,7 @@ dependencies = [
  "git-version",
  "insta",
  "metrics",
+ "metrics-util",
  "nickel-lang-core",
  "nickel-lang-utils",
  "serde",
@@ -1828,6 +1866,15 @@ version = "0.3.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5"
 
+[[package]]
+name = "ordered-float"
+version = "3.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2a54938017eacd63036332b4ae5c8a49fc8c0c1d6d629893057e4f13609edd06"
+dependencies = [
+ "num-traits",
+]
+
 [[package]]
 name = "os_str_bytes"
 version = "6.5.1"
@@ -2145,6 +2192,22 @@ dependencies = [
  "syn 1.0.109",
 ]
 
+[[package]]
+name = "quanta"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a17e662a7a8291a865152364c20c7abc5e60486ab2001e8ec10b24862de0b9ab"
+dependencies = [
+ "crossbeam-utils",
+ "libc",
+ "mach2",
+ "once_cell",
+ "raw-cpuid",
+ "wasi 0.11.0+wasi-snapshot-preview1",
+ "web-sys",
+ "winapi",
+]
+
 [[package]]
 name = "quick-xml"
 version = "0.26.0"
@@ -2232,6 +2295,15 @@ dependencies = [
  "rand_core",
 ]
 
+[[package]]
+name = "raw-cpuid"
+version = "10.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6c297679cb867470fa8c9f67dbba74a78d78e3e98d7cf2b08d6d71540f797332"
+dependencies = [
+ "bitflags 1.3.2",
+]
+
 [[package]]
 name = "rayon"
 version = "1.7.0"
@@ -2605,6 +2677,12 @@ version = "0.3.11"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d"
 
+[[package]]
+name = "sketches-ddsketch"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68a406c1882ed7f29cd5e248c9848a80e7cb6ae0fea82346d2746f2f941c07e1"
+
 [[package]]
 name = "slab"
 version = "0.4.9"
diff --git a/Cargo.toml b/Cargo.toml
index 982d8318..6c8a5714 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -91,6 +91,7 @@ unicode-segmentation = "1.10.1"
 void = "1"
 
 metrics = "0.21"
+metrics-util = "0.15"
 
 topiary = { git = "https://github.com/tweag/topiary.git", rev = "8299a04bf83c4a2774cbbff7a036c022efa939b3" }
 topiary-queries = { git = "https://github.com/tweag/topiary.git", rev = "8299a04bf83c4a2774cbbff7a036c022efa939b3", package = "topiary-queries", default-features = false, features = ["nickel"] }
diff --git a/cli/Cargo.toml b/cli/Cargo.toml
index e5f899c0..bf5d8f9f 100644
--- a/cli/Cargo.toml
+++ b/cli/Cargo.toml
@@ -20,7 +20,7 @@ default = ["repl", "doc", "format"]
 repl = ["nickel-lang-core/repl"]
 doc = ["nickel-lang-core/doc"]
 format = ["nickel-lang-core/format", "dep:tempfile"]
-metrics = ["dep:metrics", "nickel-lang-core/metrics"]
+metrics = ["dep:metrics", "dep:metrics-util", "nickel-lang-core/metrics"]
 
 [dependencies]
 nickel-lang-core = { workspace = true, features = [ "markdown" ], default-features = false }
@@ -35,6 +35,7 @@ git-version = { workspace = true }
 clap_complete = { workspace = true }
 
 metrics = { workspace = true, optional = true }
+metrics-util = { workspace = true, optional = true }
 
 [dev-dependencies]
 nickel-lang-utils.workspace = true
diff --git a/cli/src/metrics.rs b/cli/src/metrics.rs
index 9fec3aaa..eca0ff48 100644
--- a/cli/src/metrics.rs
+++ b/cli/src/metrics.rs
@@ -1,33 +1,23 @@
 use std::{
-    collections::HashMap,
-    hash::BuildHasherDefault,
-    sync::{atomic::Ordering, Arc, PoisonError, RwLock},
+    fmt::Display,
+    sync::{atomic::Ordering, Arc},
 };
 
-use metrics::{
-    atomics::AtomicU64, Counter, Gauge, Histogram, Key, KeyHasher, KeyName, SharedString, Unit,
-};
+use metrics::{Counter, Gauge, Key, KeyName, SharedString, Unit};
+use metrics_util::registry::AtomicStorage;
 
-/// The actual store for recorded metrics and their descriptions. We only need
-/// to take the write lock once when creating a new metric, in all other cases
-/// we only take a read lock. The `metrics` crate implements the necessary
-/// trait [`::metrics::CounterFn`] for `Arc<AtomicU64>` and in any case expects its
-/// counter types to be `Arc`s. For this reason, we use `Arc<AtomicU64>` as the
-/// value type of the counters `HashMap`.
-///
-/// The `metrics` crate expects to be used in a thread safe manner, so follow
-/// suit here.
-#[derive(Default)]
-pub(super) struct Registry {
-    counters: RwLock<HashMap<Key, Arc<AtomicU64>, BuildHasherDefault<KeyHasher>>>,
-    descriptions: RwLock<HashMap<KeyName, SharedString>>,
+/// A handle to the actual store for recorded metrics.
+#[derive(Clone)]
+pub(super) struct Recorder {
+    registry: Arc<metrics_util::registry::Registry<Key, AtomicStorage>>,
 }
 
-/// A metrics recorder utilizing [`Registry`] for storing metrics and their
-/// descriptions. It currently only supports [`Counter`]s and will panic if
-/// gauges or histograms are submitted.
-pub(super) struct Recorder {
-    inner: Arc<Registry>,
+impl Default for Recorder {
+    fn default() -> Recorder {
+        Recorder {
+            registry: Arc::new(metrics_util::registry::Registry::atomic()),
+        }
+    }
 }
 
 impl Recorder {
@@ -36,24 +26,17 @@ impl Recorder {
     /// retrieve the stored values later.
     ///
     /// This function should only be called once at the start of the CLI.
-    pub(super) fn install() -> Arc<Registry> {
-        let registry = Arc::<Registry>::default();
-        metrics::set_boxed_recorder(Box::new(Recorder {
-            inner: registry.clone(),
-        }))
-        .expect("registering a metrics recorder shouldn't fail");
-        registry
+    pub(super) fn install() -> Recorder {
+        let recorder = Recorder::default();
+        metrics::set_boxed_recorder(Box::new(recorder.clone()))
+            .expect("registering a metrics recorder shouldn't fail");
+        recorder
     }
 }
 
 impl metrics::Recorder for Recorder {
-    fn describe_counter(&self, key: KeyName, _unit: Option<Unit>, description: SharedString) {
-        self.inner
-            .descriptions
-            .write()
-            .unwrap_or_else(PoisonError::into_inner)
-            .entry(key)
-            .or_insert(description);
+    fn describe_counter(&self, _key: KeyName, _unit: Option<Unit>, _description: SharedString) {
+        unimplemented!()
     }
 
     fn describe_gauge(&self, _key: KeyName, _unit: Option<Unit>, _description: SharedString) {
@@ -65,67 +48,117 @@ impl metrics::Recorder for Recorder {
     }
 
     fn register_counter(&self, key: &Key) -> Counter {
-        let read = self
-            .inner
-            .counters
-            .read()
-            .unwrap_or_else(PoisonError::into_inner);
-        if let Some(counter) = read.get(key) {
-            Counter::from_arc(counter.clone())
-        } else {
-            drop(read);
-            let mut write = self
-                .inner
-                .counters
-                .write()
-                .unwrap_or_else(PoisonError::into_inner);
-            if let Some(counter) = write.get(key) {
-                Counter::from_arc(counter.clone())
-            } else {
-                let counter = Arc::new(AtomicU64::new(0));
-                write.insert(key.clone(), counter.clone());
-                Counter::from_arc(counter)
-            }
+        self.registry
+            .get_or_create_counter(key, |c| c.clone().into())
+    }
+
+    fn register_gauge(&self, key: &Key) -> Gauge {
+        self.registry.get_or_create_gauge(key, |c| c.clone().into())
+    }
+
+    fn register_histogram(&self, key: &Key) -> metrics::Histogram {
+        self.registry
+            .get_or_create_histogram(key, |c| c.clone().into())
+    }
+}
+
+struct BucketStatistics {
+    min: f64,
+    max: f64,
+    avg: f64,
+    count: usize,
+}
+
+impl BucketStatistics {
+    fn new() -> BucketStatistics {
+        BucketStatistics {
+            min: f64::NAN,
+            max: f64::NAN,
+            avg: 0 as f64,
+            count: 0,
         }
     }
 
-    fn register_gauge(&self, _key: &Key) -> Gauge {
-        unimplemented!()
+    fn update(&mut self, data: &[f64]) {
+        let mut sum = 0 as f64;
+
+        for sample in data {
+            self.min = self.min.min(*sample);
+            self.max = self.max.max(*sample);
+            sum += *sample;
+        }
+
+        self.avg = (self.count as f64) * self.avg + sum;
+        self.count += data.len();
+        self.avg /= self.count as f64;
     }
+}
 
-    fn register_histogram(&self, _key: &Key) -> Histogram {
-        unimplemented!()
+impl Default for BucketStatistics {
+    fn default() -> Self {
+        Self::new()
     }
 }
 
-impl Registry {
+impl Display for BucketStatistics {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        write!(
+            f,
+            "min {}, max {}, avg {:.1}; samples {}",
+            self.min, self.max, self.avg, self.count
+        )
+    }
+}
+
+impl Recorder {
     /// Print out a report of all metrics that have been collected so far, with
     /// their description if available.
     ///
     /// We expect this function to be called once at the end of the CLI, but it
     /// should be safe to call it in the middle of a Nickel execution as well.
     pub(super) fn report(&self) {
-        for (key, counter) in self
-            .counters
-            .read()
-            .unwrap_or_else(PoisonError::into_inner)
-            .iter()
-        {
-            if let Some(description) = self
-                .descriptions
-                .read()
-                .unwrap_or_else(PoisonError::into_inner)
-                .get(key.name())
-            {
-                eprintln!(
-                    "{}: {}\n  {}",
-                    key.name(),
-                    description,
-                    counter.load(Ordering::Relaxed)
-                );
-            } else {
-                eprintln!("{}: {}", key.name(), counter.load(Ordering::Relaxed));
-            }
-        }
+        self.registry.visit_counters(|key, counter| {
+            eprintln!("{}: {}", key.name(), counter.load(Ordering::Relaxed))
+        });
+        self.registry.visit_histograms(|key, bucket| {
+            let mut stats = BucketStatistics::new();
+            bucket.data_with(|data| stats.update(data));
+            eprintln!("{}: {}", key.name(), stats);
+        });
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn averge_tracking() {
+        let mut overall_stats = BucketStatistics::new();
+        let mut incremental_stats = BucketStatistics::new();
+
+        let data = [3.0, 2.0, 6.0, 12.0, 56.0, 82.0, 202.0, 100.0, 29.0];
+        overall_stats.update(&data);
+
+        assert_eq!(overall_stats.count, data.len());
+        assert_eq!(
+            overall_stats.min,
+            data.iter().copied().fold(f64::NAN, f64::min)
+        );
+        assert_eq!(
+            overall_stats.max,
+            data.iter().copied().fold(f64::NAN, f64::max)
+        );
+        assert_eq!(
+            overall_stats.avg,
+            data.iter().copied().sum::<f64>() / (data.len() as f64)
+        );
+
+        incremental_stats.update(&data[0..5]);
+        incremental_stats.update(&data[5..]);
+        assert_eq!(incremental_stats.min, overall_stats.min);
+        assert_eq!(incremental_stats.max, overall_stats.max);
+        assert_eq!(incremental_stats.avg, overall_stats.avg);
+        assert_eq!(incremental_stats.count, overall_stats.count);
     }
 }
diff --git a/core/src/environment.rs b/core/src/environment.rs
index a76ac8de..d13a15a4 100644
--- a/core/src/environment.rs
+++ b/core/src/environment.rs
@@ -7,7 +7,7 @@ use std::marker::PhantomData;
 use std::ptr::NonNull;
 use std::rc::Rc;
 
-use crate::metrics::increment;
+use crate::metrics::{increment, sample};
 
 /// An environment as a linked-list of hashmaps.
 ///
@@ -42,6 +42,10 @@ impl<K: Hash + Eq, V: PartialEq> Clone for Environment<K, V> {
     fn clone(&self) -> Self {
         increment!("Environment::clone");
         if !self.current.is_empty() && !self.was_cloned() {
+            sample!(
+                "Environment.curr_layer_size_at_clone",
+                self.current.len() as f64
+            );
             self.previous.replace_with(|old| {
                 Some(Rc::new(Environment {
                     current: self.current.clone(),
@@ -73,6 +77,7 @@ impl<K: Hash + Eq, V: PartialEq> Environment<K, V> {
 
     /// Inserts a key-value pair into the Environment.
     pub fn insert(&mut self, key: K, value: V) {
+        increment!("Environment::insert");
         if self.was_cloned() {
             self.current = Rc::new(HashMap::new());
         }
@@ -81,7 +86,16 @@ impl<K: Hash + Eq, V: PartialEq> Environment<K, V> {
 
     /// Tries to find the value of a key in the Environment.
     pub fn get(&self, key: &K) -> Option<&V> {
-        self.iter_layers().find_map(|hmap| hmap.get(key))
+        increment!("Environment::get");
+
+        let mut layer_count = 0;
+        let r = self.iter_layers().find_map(|hmap| {
+            sample!("Environment.hashmap_size_get", hmap.len() as f64);
+            layer_count += 1;
+            hmap.get(key)
+        });
+        sample!("Environment.get_layers_traversed", layer_count as f64);
+        r
     }
 
     /// Creates an iterator that visits all layers from the most recent one to the oldest.
diff --git a/core/src/eval/cache/lazy.rs b/core/src/eval/cache/lazy.rs
index 20722f41..8d805df8 100644
--- a/core/src/eval/cache/lazy.rs
+++ b/core/src/eval/cache/lazy.rs
@@ -2,6 +2,7 @@
 use super::{BlackholedError, Cache, CacheIndex, Closure, Environment};
 use crate::{
     identifier::{Ident, LocIdent},
+    metrics::increment,
     term::{record::FieldDeps, BindingType, RichTerm, Term},
 };
 use std::cell::{Ref, RefCell, RefMut};
@@ -364,6 +365,7 @@ pub struct Thunk {
 impl Thunk {
     /// Create a new standard thunk.
     pub fn new(closure: Closure) -> Self {
+        increment!("Thunk::new");
         Thunk {
             data: Rc::new(RefCell::new(ThunkData::new(closure))),
         }
@@ -374,9 +376,12 @@ impl Thunk {
     pub fn new_rev(closure: Closure, deps: FieldDeps) -> Self {
         match deps {
             FieldDeps::Known(deps) if deps.is_empty() => Self::new(closure),
-            deps => Thunk {
-                data: Rc::new(RefCell::new(ThunkData::new_rev(closure, deps))),
-            },
+            deps => {
+                increment!("Thunk::new_rev");
+                Thunk {
+                    data: Rc::new(RefCell::new(ThunkData::new_rev(closure, deps))),
+                }
+            }
         }
     }
 
diff --git a/core/src/eval/merge.rs b/core/src/eval/merge.rs
index fdbe960e..72f8568b 100644
--- a/core/src/eval/merge.rs
+++ b/core/src/eval/merge.rs
@@ -32,7 +32,7 @@ use crate::label::{Label, MergeLabel};
 use crate::position::TermPos;
 use crate::term::{
     record::{self, Field, FieldDeps, FieldMetadata, RecordAttrs, RecordData},
-    BinaryOp, IndexMap, RichTerm, Term,
+    BinaryOp, IndexMap, RichTerm, Term, TypeAnnotation,
 };
 use crate::transform::Closurizable;
 
@@ -67,6 +67,7 @@ impl From<MergeMode> for MergeLabel {
 #[allow(clippy::too_many_arguments)] // TODO: Is it worth to pack the inputs in an ad-hoc struct?
 pub fn merge<C: Cache>(
     cache: &mut C,
+    initial_env: &Environment,
     t1: RichTerm,
     env1: Environment,
     t2: RichTerm,
@@ -307,6 +308,7 @@ pub fn merge<C: Cache>(
                     id,
                     merge_fields(
                         cache,
+                        initial_env,
                         merge_label,
                         field1,
                         env1.clone(),
@@ -357,6 +359,7 @@ pub fn merge<C: Cache>(
 #[allow(clippy::too_many_arguments)]
 fn merge_fields<'a, C: Cache, I: DoubleEndedIterator<Item = &'a LocIdent> + Clone>(
     cache: &mut C,
+    initial_env: &Environment,
     merge_label: MergeLabel,
     field1: Field,
     env1: Environment,
@@ -410,12 +413,21 @@ fn merge_fields<'a, C: Cache, I: DoubleEndedIterator<Item = &'a LocIdent> + Clon
     };
 
     let mut pending_contracts = pending_contracts1.revert_closurize(cache, env_final, env1.clone());
-    pending_contracts.extend(pending_contracts2.revert_closurize(cache, env_final, env2.clone()));
+
+    for ctr2 in pending_contracts2.revert_closurize(cache, env_final, env2.clone()) {
+        RuntimeContract::push_dedup(
+            initial_env,
+            &mut pending_contracts,
+            env_final,
+            ctr2,
+            env_final,
+        );
+    }
 
     Ok(Field {
         metadata: FieldMetadata {
             doc: merge_doc(metadata1.doc, metadata2.doc),
-            annotation: Combine::combine(metadata1.annotation, metadata2.annotation),
+            annotation: TypeAnnotation::combine_dedup(metadata1.annotation, metadata2.annotation),
             // If one of the record requires this field, then it musn't be optional. The
             // resulting field is optional iff both are.
             opt: metadata1.opt && metadata2.opt,
diff --git a/core/src/eval/mod.rs b/core/src/eval/mod.rs
index 62ed6af2..20ef42b3 100644
--- a/core/src/eval/mod.rs
+++ b/core/src/eval/mod.rs
@@ -122,6 +122,8 @@ pub struct VirtualMachine<R: ImportResolver, C: Cache> {
     import_resolver: R,
     // The evaluation cache.
     pub cache: C,
+    // The initial environment containing stdlib and builtin functions accessible from anywhere
+    initial_env: Environment,
     // The stream for writing trace output.
     trace: Box<dyn Write>,
 }
@@ -133,6 +135,7 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
             call_stack: Default::default(),
             stack: Stack::new(),
             cache: Cache::new(),
+            initial_env: Environment::new(),
             trace: Box::new(trace),
         }
     }
@@ -144,6 +147,7 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
             stack: Stack::new(),
             cache,
             trace: Box::new(trace),
+            initial_env: Environment::new(),
         }
     }
 
@@ -164,55 +168,58 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
 
     /// Evaluate a Nickel term. Wrapper around [VirtualMachine::eval_closure] that starts from an
     /// empty local environment and drops the final environment.
-    pub fn eval(&mut self, t0: RichTerm, initial_env: &Environment) -> Result<RichTerm, EvalError> {
-        self.eval_closure(Closure::atomic_closure(t0), initial_env)
+    pub fn eval(&mut self, t: RichTerm) -> Result<RichTerm, EvalError> {
+        self.eval_closure(Closure::atomic_closure(t))
             .map(|(term, _)| term)
     }
 
     /// Fully evaluate a Nickel term: the result is not a WHNF but to a value with all variables
     /// substituted.
-    pub fn eval_full(
-        &mut self,
-        t0: RichTerm,
-        initial_env: &Environment,
-    ) -> Result<RichTerm, EvalError> {
-        self.eval_deep_closure(t0, initial_env, false)
-            .map(|(term, env)| subst(&self.cache, term, initial_env, &env))
+    pub fn eval_full(&mut self, t0: RichTerm) -> Result<RichTerm, EvalError> {
+        self.eval_full_closure(Closure::atomic_closure(t0))
+            .map(|(term, _)| term)
+    }
+
+    pub fn eval_full_closure(&mut self, t0: Closure) -> Result<(RichTerm, Environment), EvalError> {
+        self.eval_deep_closure(t0, false)
+            .map(|(term, env)| (subst(&self.cache, term, &self.initial_env, &env), env))
     }
 
     /// Like `eval_full`, but skips evaluating record fields marked `not_exported`.
-    pub fn eval_full_for_export(
-        &mut self,
-        t0: RichTerm,
-        initial_env: &Environment,
-    ) -> Result<RichTerm, EvalError> {
-        self.eval_deep_closure(t0, initial_env, true)
-            .map(|(term, env)| subst(&self.cache, term, initial_env, &env))
+    pub fn eval_full_for_export(&mut self, t0: RichTerm) -> Result<RichTerm, EvalError> {
+        self.eval_deep_closure(Closure::atomic_closure(t0), true)
+            .map(|(term, env)| subst(&self.cache, term, &self.initial_env, &env))
     }
 
     /// Fully evaluates a Nickel term like `eval_full`, but does not substitute all variables.
-    pub fn eval_deep(
-        &mut self,
-        t0: RichTerm,
-        initial_env: &Environment,
-    ) -> Result<RichTerm, EvalError> {
-        self.eval_deep_closure(t0, initial_env, false)
+    pub fn eval_deep(&mut self, t0: RichTerm) -> Result<RichTerm, EvalError> {
+        self.eval_deep_closure(Closure::atomic_closure(t0), false)
             .map(|(term, _)| term)
     }
 
+    /// Use a specific initial environment for evaluation. Usually, [VirtualMachine::prepare_eval]
+    /// is populating the initial environment. But in some cases, such as testing or benchmarks, we
+    /// might want to use a different one.
+    ///
+    /// Return the new virtual machine with the updated initial environment.
+    pub fn with_initial_env(mut self, env: Environment) -> Self {
+        self.initial_env = env;
+        self
+    }
+
     fn eval_deep_closure(
         &mut self,
-        rt: RichTerm,
-        initial_env: &Environment,
+        mut closure: Closure,
         for_export: bool,
     ) -> Result<(RichTerm, Environment), EvalError> {
-        let wrapper = mk_term::op1(
+        closure.body = mk_term::op1(
             UnaryOp::Force {
                 ignore_not_exported: for_export,
             },
-            rt,
+            closure.body,
         );
-        self.eval_closure(Closure::atomic_closure(wrapper), initial_env)
+
+        self.eval_closure(closure)
     }
 
     /// Query the value and the metadata of a record field in an expression.
@@ -221,14 +228,15 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
     /// `baz`. The content of `baz` is evaluated as well, and variables are substituted, in order
     /// to obtain a value that can be printed. The metadata and the evaluated value are returned as
     /// a new field.
-    pub fn query(
-        &mut self,
-        t: RichTerm,
-        path: QueryPath,
-        initial_env: &Environment,
-    ) -> Result<Field, EvalError> {
-        let mut prev_pos = t.pos;
-        let (rt, mut env) = self.eval_closure(Closure::atomic_closure(t), initial_env)?;
+    pub fn query(&mut self, t: RichTerm, path: QueryPath) -> Result<Field, EvalError> {
+        self.query_closure(Closure::atomic_closure(t), path)
+    }
+
+    /// Same as [VirtualMachine::query], but starts from a closure instead of a term in an empty
+    /// environment.
+    pub fn query_closure(&mut self, closure: Closure, path: QueryPath) -> Result<Field, EvalError> {
+        let mut prev_pos = closure.body.pos;
+        let (rt, mut env) = self.eval_closure(closure)?;
 
         let mut field: Field = rt.into();
 
@@ -268,13 +276,10 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                                     pending_contracts.into_iter(),
                                     prev_pos,
                                 );
-                                let (new_value, new_env) = self.eval_closure(
-                                    Closure {
-                                        body: value_with_ctr,
-                                        env: env.clone(),
-                                    },
-                                    initial_env,
-                                )?;
+                                let (new_value, new_env) = self.eval_closure(Closure {
+                                    body: value_with_ctr,
+                                    env: env.clone(),
+                                })?;
                                 env = new_env;
 
                                 Ok(new_value)
@@ -329,7 +334,6 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
     pub fn eval_closure(
         &mut self,
         mut clos: Closure,
-        initial_env: &Environment,
     ) -> Result<(RichTerm, Environment), EvalError> {
         loop {
             let Closure {
@@ -383,7 +387,7 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                 Term::Var(x) => {
                     let mut idx = env
                         .get(&x.ident())
-                        .or_else(|| initial_env.get(&x.ident()))
+                        .or_else(|| self.initial_env.get(&x.ident()))
                         .cloned()
                         .ok_or(EvalError::UnboundIdentifier(*x, pos))?;
                     std::mem::drop(env); // idx may be a 1RC pointer
@@ -820,17 +824,29 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
 }
 
 impl<C: Cache> VirtualMachine<ImportCache, C> {
-    pub fn prepare_eval(&mut self, main_id: FileId) -> Result<(RichTerm, Environment), Error> {
+    /// Prepare the underlying program for evaluation (load the stdlib, typecheck, transform,
+    /// etc.). Sets the initial environment of the virtual machine.
+    pub fn prepare_eval(&mut self, main_id: FileId) -> Result<RichTerm, Error> {
         let Envs {
             eval_env,
             type_ctxt,
-        }: Envs = self.import_resolver.prepare_stdlib(&mut self.cache)?;
+        } = self.import_resolver.prepare_stdlib(&mut self.cache)?;
         self.import_resolver.prepare(main_id, &type_ctxt)?;
-        Ok((self.import_resolver().get(main_id).unwrap(), eval_env))
+        self.initial_env = eval_env;
+        Ok(self.import_resolver().get(main_id).unwrap())
     }
 
+    /// Prepare the stdlib for evaluation. Sets the initial environment of the virtual machine. As
+    /// opposed to [VirtualMachine::prepare_eval], [VirtualMachine::prepare_stdlib] doesn't prepare
+    /// the main program yet (typechecking, transformations, etc.).
+    ///
+    /// # Returns
+    ///
+    /// The initial evaluation and typing environments, containing the stdlib items.
     pub fn prepare_stdlib(&mut self) -> Result<Envs, Error> {
-        self.import_resolver.prepare_stdlib(&mut self.cache)
+        let envs = self.import_resolver.prepare_stdlib(&mut self.cache)?;
+        self.initial_env = envs.eval_env.clone();
+        Ok(envs)
     }
 }
 
diff --git a/core/src/eval/operation.rs b/core/src/eval/operation.rs
index 9c385760..47736e26 100644
--- a/core/src/eval/operation.rs
+++ b/core/src/eval/operation.rs
@@ -1881,6 +1881,7 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
             },
             BinaryOp::Merge(merge_label) => merge::merge(
                 &mut self.cache,
+                &self.initial_env,
                 RichTerm {
                     term: t1,
                     pos: pos1,
@@ -2113,6 +2114,7 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                             // due to a limitation of `match_sharedterm`: see the macro's
                             // documentation
                             let mut record_data = record_data;
+                            let term_original_env = env2.clone();
 
                             let mut contract_at_field = |id: LocIdent| {
                                 let pos = contract_term.pos;
@@ -2125,10 +2127,18 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                             };
 
                             for (id, field) in record_data.fields.iter_mut() {
-                                field.pending_contracts.push(RuntimeContract {
-                                    contract: contract_at_field(*id),
-                                    label: label.clone(),
-                                });
+                                let runtime_ctr = RuntimeContract {
+                                        contract: contract_at_field(*id),
+                                        label: label.clone(),
+                                    };
+
+                                crate::term::RuntimeContract::push_dedup(
+                                    &self.initial_env,
+                                    &mut field.pending_contracts,
+                                    &term_original_env,
+                                    runtime_ctr,
+                                    &contract_env,
+                                );
                             }
 
                             // IMPORTANT: here, we revert the record back to a `RecRecord`. The
@@ -2463,6 +2473,7 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                         Term::Lbl(lbl) => {
                             merge::merge(
                                 &mut self.cache,
+                                &self.initial_env,
                                 RichTerm {
                                     term: t2,
                                     pos: pos2,
diff --git a/core/src/metrics.rs b/core/src/metrics.rs
index f608ee0d..92238ade 100644
--- a/core/src/metrics.rs
+++ b/core/src/metrics.rs
@@ -16,4 +16,17 @@ macro_rules! increment {
     ( $( $args:expr ),+ ) => {};
 }
 
+#[cfg(feature = "metrics")]
+macro_rules! sample {
+    ( $counter:expr, $value:expr ) => {
+        ::metrics::histogram!($counter, $value)
+    };
+}
+
+#[cfg(not(feature = "metrics"))]
+macro_rules! sample {
+    ( $( $args:expr ),+ ) => {};
+}
+
 pub(crate) use increment;
+pub(crate) use sample;
diff --git a/core/src/program.rs b/core/src/program.rs
index 5db36aff..d930129f 100644
--- a/core/src/program.rs
+++ b/core/src/program.rs
@@ -23,7 +23,6 @@
 use crate::{
     cache::*,
     error::{report, ColorOpt, Error, EvalError, IOError, IntoDiagnostics, ParseError},
-    eval,
     eval::{cache::Cache as EvalCache, VirtualMachine},
     identifier::LocIdent,
     label::Label,
@@ -156,6 +155,7 @@ impl<EC: EvalCache> Program<EC> {
         I: IntoIterator<Item = P>,
         P: Into<OsString>,
     {
+        increment!("Program::new");
         let mut cache = Cache::new(ErrorTolerance::Strict);
 
         let merge_term = paths
@@ -240,9 +240,8 @@ impl<EC: EvalCache> Program<EC> {
             .clone())
     }
 
-    /// Retrieve the parsed term and typecheck it, and generate a fresh initial environment. Return
-    /// both.
-    fn prepare_eval(&mut self) -> Result<(RichTerm, eval::Environment), Error> {
+    /// Retrieve the parsed term, typecheck it, and generate a fresh initial environment.
+    fn prepare_eval(&mut self) -> Result<RichTerm, Error> {
         // If there are no overrides, we avoid the boilerplate of creating an empty record and
         // merging it with the current program
         if self.overrides.is_empty() {
@@ -263,7 +262,7 @@ impl<EC: EvalCache> Program<EC> {
                 .value(Term::ResolvedImport(value_file_id));
         }
 
-        let (t, initial_env) = self.vm.prepare_eval(self.main_id)?;
+        let t = self.vm.prepare_eval(self.main_id)?;
         let built_record = record.build();
         // For now, we can't do much better than using `Label::default`, but this is
         // hazardous. `Label::default` was originally written for tests, and although it
@@ -272,22 +271,25 @@ impl<EC: EvalCache> Program<EC> {
         // generate a dummy file id).
         // We'll have to adapt `Label` and `MergeLabel` to be generated programmatically,
         // without referring to any source position.
-        let wrapper = mk_term::op2(BinaryOp::Merge(Label::default().into()), t, built_record);
-        Ok((wrapper, initial_env))
+        Ok(mk_term::op2(
+            BinaryOp::Merge(Label::default().into()),
+            t,
+            built_record,
+        ))
     }
 
     /// Parse if necessary, typecheck and then evaluate the program.
     pub fn eval(&mut self) -> Result<RichTerm, Error> {
-        let (t, initial_env) = self.prepare_eval()?;
+        let t = self.prepare_eval()?;
         self.vm.reset();
-        self.vm.eval(t, &initial_env).map_err(|e| e.into())
+        self.vm.eval(t).map_err(|e| e.into())
     }
 
     /// Same as `eval`, but proceeds to a full evaluation.
     pub fn eval_full(&mut self) -> Result<RichTerm, Error> {
-        let (t, initial_env) = self.prepare_eval()?;
+        let t = self.prepare_eval()?;
         self.vm.reset();
-        self.vm.eval_full(t, &initial_env).map_err(|e| e.into())
+        self.vm.eval_full(t).map_err(|e| e.into())
     }
 
     /// Same as `eval`, but proceeds to a full evaluation. Optionally take a set of overrides that
@@ -304,25 +306,24 @@ impl<EC: EvalCache> Program<EC> {
     ///   an import referring to the corresponding isolated value. This stub is finally merged with
     ///   the current program before being evaluated for import.
     pub fn eval_full_for_export(&mut self) -> Result<RichTerm, Error> {
-        let (t, initial_env) = self.prepare_eval()?;
+        let t = self.prepare_eval()?;
         self.vm.reset();
-        self.vm
-            .eval_full_for_export(t, &initial_env)
-            .map_err(|e| e.into())
+        self.vm.eval_full_for_export(t).map_err(|e| e.into())
     }
 
     /// Same as `eval_full`, but does not substitute all variables.
     pub fn eval_deep(&mut self) -> Result<RichTerm, Error> {
-        let (t, initial_env) = self.prepare_eval()?;
+        let t = self.prepare_eval()?;
         self.vm.reset();
-        self.vm.eval_deep(t, &initial_env).map_err(|e| e.into())
+        self.vm.eval_deep(t).map_err(|e| e.into())
     }
 
-    /// Wrapper for [`query`].
+    /// Prepare for evaluation, then query the program for a field.
     pub fn query(&mut self, path: Option<String>) -> Result<Field, Error> {
-        let initial_env = self.vm.prepare_stdlib()?;
+        let rt = self.prepare_eval()?;
         let query_path = QueryPath::parse_opt(self.vm.import_resolver_mut(), path)?;
-        query(&mut self.vm, self.main_id, &initial_env, query_path)
+
+        Ok(self.vm.query(rt, query_path)?)
     }
 
     /// Load, parse, and typecheck the program and the standard library, if not already done.
@@ -429,7 +430,7 @@ impl<EC: EvalCache> Program<EC> {
         use crate::match_sharedterm;
         use crate::term::{record::RecordData, RuntimeContract};
 
-        let (t, initial_env) = self.prepare_eval()?;
+        let t = self.prepare_eval()?;
 
         // Eval pending contracts as well, in order to extract more information from potential
         // record contract fields.
@@ -437,13 +438,12 @@ impl<EC: EvalCache> Program<EC> {
             vm: &mut VirtualMachine<Cache, EC>,
             mut pending_contracts: Vec<RuntimeContract>,
             current_env: Environment,
-            initial_env: &Environment,
         ) -> Result<Vec<RuntimeContract>, Error> {
             vm.reset();
 
             for ctr in pending_contracts.iter_mut() {
                 let rt = ctr.contract.clone();
-                ctr.contract = do_eval(vm, rt, current_env.clone(), initial_env)?;
+                ctr.contract = do_eval(vm, rt, current_env.clone())?;
             }
 
             Ok(pending_contracts)
@@ -453,16 +453,12 @@ impl<EC: EvalCache> Program<EC> {
             vm: &mut VirtualMachine<Cache, EC>,
             t: RichTerm,
             current_env: Environment,
-            initial_env: &Environment,
         ) -> Result<RichTerm, Error> {
             vm.reset();
-            let result = vm.eval_closure(
-                Closure {
-                    body: t.clone(),
-                    env: current_env,
-                },
-                initial_env,
-            );
+            let result = vm.eval_closure(Closure {
+                body: t.clone(),
+                env: current_env,
+            });
 
             // We expect to hit `MissingFieldDef` errors. When a configuration
             // contains undefined record fields they most likely will be used
@@ -489,13 +485,12 @@ impl<EC: EvalCache> Program<EC> {
                                     Field {
                                         value: field
                                             .value
-                                            .map(|rt| do_eval(vm, rt, env.clone(), initial_env))
+                                            .map(|rt| do_eval(vm, rt, env.clone()))
                                             .transpose()?,
                                         pending_contracts: eval_contracts(
                                             vm,
                                             field.pending_contracts,
                                             env.clone(),
-                                            initial_env
                                         )?,
                                         ..field
                                     },
@@ -511,7 +506,7 @@ impl<EC: EvalCache> Program<EC> {
             }
         }
 
-        do_eval(&mut self.vm, t, Environment::new(), &initial_env)
+        do_eval(&mut self.vm, t, Environment::new())
     }
 
     /// Extract documentation from the program
@@ -535,7 +530,7 @@ impl<EC: EvalCache> Program<EC> {
         out: &mut impl std::io::Write,
         apply_transforms: bool,
     ) -> Result<(), Error> {
-        use crate::pretty::*;
+        use crate::{pretty::*, transform::transform};
         use pretty::BoxAllocator;
 
         let Program {
@@ -545,7 +540,7 @@ impl<EC: EvalCache> Program<EC> {
 
         let rt = vm.import_resolver().parse_nocache(*main_id)?.0;
         let rt = if apply_transforms {
-            crate::transform::transform(rt, None).map_err(EvalError::from)?
+            transform(rt, None).map_err(EvalError::from)?
         } else {
             rt
         };
@@ -556,29 +551,6 @@ impl<EC: EvalCache> Program<EC> {
     }
 }
 
-/// Query the metadata of a path of a term in the cache.
-///
-/// The path is a list of dot separated identifiers. For example, querying `{a = {b  = ..}}` (call
-/// it `exp`) with path `a.b` will evaluate `exp.a` and retrieve the `b` field. `b` is forced as
-/// well, in order to print its value (note that forced just means evaluated to a WHNF, it isn't
-/// deeply - or recursively - evaluated).
-//TODO: also gather type information, such that `query a.b.c <<< '{ ... } : {a: {b: {c: Num}}}`
-//would additionally report `type: Num` for example. Maybe use the LSP infrastructure?
-//TODO: not sure where this should go. It seems to embed too much logic to be in `Cache`, but is
-//common to both `Program` and `Repl`. Leaving it here as a stand-alone function for now
-pub fn query<EC: EvalCache>(
-    vm: &mut VirtualMachine<Cache, EC>,
-    file_id: FileId,
-    initial_env: &Envs,
-    path: QueryPath,
-) -> Result<Field, Error> {
-    vm.import_resolver_mut()
-        .prepare(file_id, &initial_env.type_ctxt)?;
-
-    let rt = vm.import_resolver().get_owned(file_id).unwrap();
-    Ok(vm.query(rt, path, &initial_env.eval_env)?)
-}
-
 #[cfg(feature = "doc")]
 mod doc {
     use crate::error::{Error, ExportError, IOError};
diff --git a/core/src/repl/mod.rs b/core/src/repl/mod.rs
index 2ec59661..51d46160 100644
--- a/core/src/repl/mod.rs
+++ b/core/src/repl/mod.rs
@@ -167,10 +167,11 @@ impl<EC: EvalCache> ReplImpl<EC> {
 
     fn eval_(&mut self, exp: &str, eval_full: bool) -> Result<EvalResult, Error> {
         self.vm.reset();
+
         let eval_function = if eval_full {
-            eval::VirtualMachine::eval_full
+            eval::VirtualMachine::eval_full_closure
         } else {
-            eval::VirtualMachine::eval
+            eval::VirtualMachine::eval_closure
         };
 
         let file_id = self.vm.import_resolver_mut().add_string(
@@ -189,7 +190,15 @@ impl<EC: EvalCache> ReplImpl<EC> {
         match term {
             ExtendedTerm::RichTerm(t) => {
                 let t = self.prepare(None, t)?;
-                Ok(eval_function(&mut self.vm, t, &self.env.eval_env)?.into())
+                Ok(eval_function(
+                    &mut self.vm,
+                    Closure {
+                        body: t,
+                        env: self.env.eval_env.clone(),
+                    },
+                )?
+                .0
+                .into())
             }
             ExtendedTerm::ToplevelLet(id, t) => {
                 let t = self.prepare(Some(id), t)?;
@@ -223,9 +232,10 @@ impl<EC: EvalCache> Repl for ReplImpl<EC> {
 
         let term = self.prepare(None, term)?;
 
-        let (term, new_env) = self
-            .vm
-            .eval_closure(Closure::atomic_closure(term), &self.env.eval_env)?;
+        let (term, new_env) = self.vm.eval_closure(Closure {
+            body: term,
+            env: self.env.eval_env.clone(),
+        })?;
 
         if !matches!(term.as_ref(), Term::Record(..) | Term::RecRecord(..)) {
             return Err(Error::EvalError(EvalError::Other(
@@ -295,7 +305,7 @@ impl<EC: EvalCache> Repl for ReplImpl<EC> {
     }
 
     fn query(&mut self, path: String) -> Result<Field, Error> {
-        use crate::program;
+        self.vm.reset();
 
         let mut query_path = QueryPath::parse(self.vm.import_resolver_mut(), path)?;
 
@@ -309,7 +319,17 @@ impl<EC: EvalCache> Repl for ReplImpl<EC> {
             .import_resolver_mut()
             .replace_string(SourcePath::ReplQuery, target.label().into());
 
-        program::query(&mut self.vm, file_id, &self.env, query_path)
+        self.vm
+            .import_resolver_mut()
+            .prepare(file_id, &self.env.type_ctxt)?;
+
+        Ok(self.vm.query_closure(
+            Closure {
+                body: self.vm.import_resolver().get_owned(file_id).unwrap(),
+                env: self.env.eval_env.clone(),
+            },
+            query_path,
+        )?)
     }
 
     fn cache_mut(&mut self) -> &mut Cache {
diff --git a/core/src/serialize.rs b/core/src/serialize.rs
index b9a4c30c..9b86e85d 100644
--- a/core/src/serialize.rs
+++ b/core/src/serialize.rs
@@ -281,7 +281,7 @@ mod tests {
     use super::*;
     use crate::cache::resolvers::DummyResolver;
     use crate::eval::cache::CacheImpl;
-    use crate::eval::{Environment, VirtualMachine};
+    use crate::eval::VirtualMachine;
     use crate::program::Program;
     use crate::term::{make as mk_term, BinaryOp};
     use serde_json::json;
@@ -306,10 +306,7 @@ mod tests {
     fn assert_nickel_eq(term: RichTerm, expected: RichTerm) {
         assert_eq!(
             VirtualMachine::<_, CacheImpl>::new(DummyResolver {}, std::io::stderr())
-                .eval(
-                    mk_term::op2(BinaryOp::Eq(), term, expected),
-                    &Environment::new(),
-                )
+                .eval(mk_term::op2(BinaryOp::Eq(), term, expected))
                 .map(Term::from),
             Ok(Term::Bool(true))
         )
diff --git a/core/src/term/mod.rs b/core/src/term/mod.rs
index 0cf5d2c5..bec643d2 100644
--- a/core/src/term/mod.rs
+++ b/core/src/term/mod.rs
@@ -20,11 +20,13 @@ use string::NickelString;
 use crate::{
     destructuring::RecordPattern,
     error::{EvalError, ParseError},
+    eval::Environment,
     identifier::LocIdent,
     label::{Label, MergeLabel},
     match_sharedterm,
     position::TermPos,
     typ::{Type, UnboundTypeVariableError},
+    typecheck::eq::{contract_eq, type_eq_noenv, EvalEnvsRef},
 };
 
 use codespan::FileId;
@@ -319,6 +321,35 @@ impl RuntimeContract {
     {
         contracts.fold(rt, |acc, ctr| ctr.apply(acc, pos))
     }
+
+    /// Push a pending contract to a vector of contracts if the contract to add isn't already
+    /// present in the vector, according to the notion of contract equality defined in
+    /// [crate::typecheck::eq].
+    pub fn push_dedup(
+        initial_env: &Environment,
+        contracts: &mut Vec<RuntimeContract>,
+        env1: &Environment,
+        ctr: Self,
+        env2: &Environment,
+    ) {
+        let envs1 = EvalEnvsRef {
+            eval_env: env1,
+            initial_env,
+        };
+
+        for c in contracts.iter() {
+            let envs = EvalEnvsRef {
+                eval_env: env2,
+                initial_env,
+            };
+
+            if contract_eq::<EvalEnvsRef>(0, &c.contract, envs1, &ctr.contract, envs) {
+                return;
+            }
+        }
+
+        contracts.push(ctr);
+    }
 }
 
 impl Traverse<RichTerm> for RuntimeContract {
@@ -576,6 +607,34 @@ impl TypeAnnotation {
     pub fn is_empty(&self) -> bool {
         self.typ.is_none() && self.contracts.is_empty()
     }
+
+    /// **Warning**: the contract equality check used in this function behaves like syntactic
+    /// equality, and doesn't take the environment into account. It's unsound for execution (it
+    /// could equate contracts that are actually totally distinct), but we use it only to trim
+    /// accumulated contracts before pretty-printing. Do not use prior to any form of evaluation.
+    ///
+    /// Same as [`crate::combine::Combine`], but eliminate duplicate contracts. As there's no
+    /// notion of environment when considering mere annotations, we use an unsound contract
+    /// equality checking which correspond to compares contracts syntactically.
+    pub fn combine_dedup(left: Self, right: Self) -> Self {
+        let mut contracts = left.contracts;
+
+        let typ = match (left.typ, right.typ) {
+            (left_ty @ Some(_), Some(right_ty)) => {
+                contracts.push(right_ty);
+                left_ty
+            }
+            (left_ty, right_ty) => left_ty.or(right_ty),
+        };
+
+        for ctr in right.contracts.into_iter() {
+            if !contracts.iter().any(|c| type_eq_noenv(0, &c.typ, &ctr.typ)) {
+                contracts.push(ctr);
+            }
+        }
+
+        TypeAnnotation { typ, contracts }
+    }
 }
 
 impl From<TypeAnnotation> for LetMetadata {
diff --git a/core/src/typecheck/eq.rs b/core/src/typecheck/eq.rs
index 4d3d0479..1f20f846 100644
--- a/core/src/typecheck/eq.rs
+++ b/core/src/typecheck/eq.rs
@@ -49,23 +49,41 @@ use crate::{
     term::{self, record::Field, IndexMap, UnaryOp},
 };
 
+use std::fmt::Debug;
+
 /// The maximal number of variable links we want to unfold before abandoning the check. It should
 /// stay low, but has been fixed arbitrarily: feel fee to increase reasonably if it turns out
 /// legitimate type equalities between simple contracts are unduly rejected in practice.
-pub const MAX_GAS: u8 = 8;
+pub const MAX_GAS: u8 = 10;
+
+/// Like `std::borrow::ToOwned`, but defined as a local trait for convenience and to be implemented
+/// on the reference type directly. Beside the slightly different signature, the blanket
+/// implementations of `ToOwned` would interfere with our own.
+pub trait ToOwnedEnv<Owned> {
+    fn to_owned_env(self) -> Owned;
+}
 
 /// Abstract over the term environment, which is represented differently in the typechecker and
 /// during evaluation.
 ///
-/// The evaluation environment holds [crate::eval::cache::CacheIndex]es,
-/// while the term environment used during typechecking is just maps identifiers to
-/// a pair `(RichTerm, Environment)`. To have an interface that works with both,
-/// `TermEnvironment::get_then` has to take a closure representing the continuation of the task to
-/// do with the result instead of merely returning it.
+/// The evaluation environment holds [crate::eval::cache::CacheIndex]es, while the term environment
+/// used during typechecking just maps identifiers to a pair `(RichTerm, Environment)`. To have an
+/// interface that works with both, [TermEnvironment] provides [TermEnvironment::get_then], which
+/// has to take a closure representing the continuation of the task to do with the result instead
+/// of merely returning it.
 pub trait TermEnvironment: Clone {
-    fn get_then<F, T>(&self, id: Ident, f: F) -> T
+    /// The representation of the environment is different during typechecking and evaluation. In
+    /// particular, the evaluation's environment is actually a pair of environments (the initial
+    /// environment and the local environment), and thus the notion of owned environment and
+    /// reference to the environment don't always map to the obvious `Self` and `&Self`
+    /// respectively.
+    /// The trait abstract over thos notions by defining a reference type and an owned type.
+    type Owned: Clone + PartialEq + Debug;
+    type Ref<'a>: Copy + ToOwnedEnv<Self::Owned>;
+
+    fn get_then<F, T>(env: Self::Ref<'_>, id: Ident, f: F) -> T
     where
-        F: FnOnce(Option<(&RichTerm, &Self)>) -> T;
+        F: FnOnce(Option<(&RichTerm, Self::Ref<'_>)>) -> T;
 }
 
 /// A simple term environment, as a mapping from identifiers to a tuple of a term and an
@@ -85,12 +103,27 @@ impl Default for SimpleTermEnvironment {
     }
 }
 
+impl<'a, E: Clone> ToOwnedEnv<E> for &'a E {
+    fn to_owned_env(self) -> E {
+        self.clone()
+    }
+}
+
 impl TermEnvironment for SimpleTermEnvironment {
-    fn get_then<F, T>(&self, id: Ident, f: F) -> T
+    type Owned = Self;
+    type Ref<'a> = &'a Self;
+
+    fn get_then<F, T>(env: &Self, id: Ident, f: F) -> T
     where
         F: FnOnce(Option<(&RichTerm, &SimpleTermEnvironment)>) -> T,
     {
-        f(self.0.get(&id).map(|(rt, env)| (rt, env)))
+        f(env.0.get(&id).map(|(rt, env)| (rt, env)))
+    }
+}
+
+impl<'a> AsRef<SimpleTermEnvironment> for &'a SimpleTermEnvironment {
+    fn as_ref(&self) -> Self {
+        self
     }
 }
 
@@ -105,19 +138,58 @@ impl std::iter::FromIterator<(Ident, (RichTerm, SimpleTermEnvironment))> for Sim
     }
 }
 
-/*
-impl<C: Cache> TermEnvironment for eval::Environment {
-    fn get_then<F, T>(&self, id: &Ident, f: F) -> T
+#[derive(Clone, Copy)]
+pub struct EvalEnvsRef<'a> {
+    pub eval_env: &'a eval::Environment,
+    pub initial_env: &'a eval::Environment,
+}
+
+#[derive(Clone, PartialEq, Debug)]
+pub struct EvalEnvsOwned {
+    pub eval_env: eval::Environment,
+    pub initial_env: eval::Environment,
+}
+
+impl<'a> ToOwnedEnv<EvalEnvsOwned> for EvalEnvsRef<'a> {
+    fn to_owned_env(self) -> EvalEnvsOwned {
+        EvalEnvsOwned {
+            eval_env: self.eval_env.clone(),
+            initial_env: self.initial_env.clone(),
+        }
+    }
+}
+
+impl<'a> TermEnvironment for EvalEnvsRef<'a> {
+    type Ref<'b> = EvalEnvsRef<'b>;
+    type Owned = EvalEnvsOwned;
+
+    fn get_then<F, T>(env: EvalEnvsRef<'_>, id: Ident, f: F) -> T
     where
-        F: FnOnce(Option<(&RichTerm, &eval::Environment)>) -> T,
+        F: FnOnce(Option<(&RichTerm, EvalEnvsRef<'_>)>) -> T,
     {
-        match self.get(id).map(eval::lazy::Thunk::borrow) {
-            Some(closure_ref) => f(Some((&closure_ref.body, &closure_ref.env))),
+        debug_assert!(
+            env.eval_env.get(&id).or(env.initial_env.get(&id)).is_some(),
+            "unbound variable `{}`",
+            id
+        );
+
+        match env
+            .eval_env
+            .get(&id)
+            .or(env.initial_env.get(&id))
+            .map(eval::cache::lazy::Thunk::borrow)
+        {
+            Some(closure_ref) => f(Some((
+                &closure_ref.body,
+                EvalEnvsRef {
+                    eval_env: &closure_ref.env,
+                    initial_env: env.initial_env,
+                },
+            ))),
             None => f(None),
         }
     }
 }
-*/
 
 pub trait FromEnv<C: Cache> {
     fn from_env(eval_env: eval::Environment, cache: &C) -> Self;
@@ -138,6 +210,26 @@ impl<C: Cache> FromEnv<C> for SimpleTermEnvironment {
     }
 }
 
+/// Dummy environment used in conjunction with [VarEq::Direct] to compare variables by name.
+///
+/// **Warning**: calling `get_then` on this environment will panic, as it has no way to generate a
+/// term from a variable. It must only be used in conjunction with [VarEq::Direct], which doesn't
+/// access the environment.
+#[derive(Copy, Clone, Debug, PartialEq)]
+pub struct NoEnvironment;
+
+impl TermEnvironment for NoEnvironment {
+    type Owned = Self;
+    type Ref<'a> = &'a Self;
+
+    fn get_then<F, T>(_env: &Self, id: Ident, _f: F) -> T
+    where
+        F: FnOnce(Option<(&RichTerm, &NoEnvironment)>) -> T,
+    {
+        panic!("contract equality checking: cannot get variable `{id}` from no environment")
+    }
+}
+
 /// State threaded through the type equality computation.
 #[derive(Copy, Clone, Default)]
 struct State {
@@ -181,7 +273,19 @@ impl State {
     }
 }
 
-/// Compute the equality between two flat types (contracts).
+/// Different possible behavior for variables comparison.
+#[derive(Copy, Clone, PartialEq, Eq, Default)]
+pub enum VarEq {
+    #[default]
+    /// Use the environment to fetch the content of variables and compare them.
+    Environment,
+    /// Don't use an environment and simply compare variables by name. This is unsound, and should
+    /// only be used to deduplicate annotations for pretty-printing. In this case, use
+    /// [NoEnvironment] as an environment.
+    Direct,
+}
+
+/// Compute equality between two contracts.
 ///
 /// # Parameters
 ///
@@ -190,21 +294,47 @@ impl State {
 pub fn contract_eq<E: TermEnvironment>(
     var_uid: usize,
     t1: &RichTerm,
-    env1: &E,
+    env1: E::Ref<'_>,
     t2: &RichTerm,
-    env2: &E,
+    env2: E::Ref<'_>,
 ) -> bool {
-    contract_eq_bounded(&mut State::new(var_uid), t1, env1, t2, env2)
+    contract_eq_bounded::<E>(
+        &mut State::new(var_uid),
+        VarEq::Environment,
+        t1,
+        env1,
+        t2,
+        env2,
+    )
+}
+
+/// **Warning**: this function isn't computing a sound contract equality (it could equate contracts
+/// that aren't actually the same). It is used to deduplicate type and contract annotations for
+/// pretty-printing, where there is no notion of environment and the only thing that matters is
+/// that they are printed the same or not.
+///
+/// Compute equality between two contracts, considering that two variables with the same name are
+/// equal.
+pub fn type_eq_noenv(var_uid: usize, t1: &Type, t2: &Type) -> bool {
+    type_eq_bounded::<NoEnvironment>(
+        &mut State::new(var_uid),
+        VarEq::Direct,
+        &GenericUnifType::<NoEnvironment>::from_type(t1.clone(), &NoEnvironment),
+        &NoEnvironment,
+        &GenericUnifType::<NoEnvironment>::from_type(t2.clone(), &NoEnvironment),
+        &NoEnvironment,
+    )
 }
 
 /// Decide type equality on contracts in their respective environment and given the remaining gas
-/// `gas`.
+/// in `state`.
 fn contract_eq_bounded<E: TermEnvironment>(
     state: &mut State,
+    var_eq: VarEq,
     t1: &RichTerm,
-    env1: &E,
+    env1: E::Ref<'_>,
     t2: &RichTerm,
-    env2: &E,
+    env2: E::Ref<'_>,
 ) -> bool {
     use Term::*;
 
@@ -223,7 +353,7 @@ fn contract_eq_bounded<E: TermEnvironment>(
         (Enum(id1), Enum(id2)) => id1 == id2,
         (SealingKey(s1), SealingKey(s2)) => s1 == s2,
         (Sealed(key1, inner1, _), Sealed(key2, inner2, _)) => {
-            key1 == key2 && contract_eq_bounded(state, inner1, env1, inner2, env2)
+            key1 == key2 && contract_eq_bounded::<E>(state, var_eq, inner1, env1, inner2, env2)
         }
         // We only compare string chunks when they represent a plain string (they don't contain any
         // interpolated expression), as static string may be currently parsed as such. We return
@@ -239,17 +369,18 @@ fn contract_eq_bounded<E: TermEnvironment>(
                     })
         }
         (App(head1, arg1), App(head2, arg2)) => {
-            contract_eq_bounded(state, head1, env1, head2, env2)
-                && contract_eq_bounded(state, arg1, env1, arg2, env2)
+            contract_eq_bounded::<E>(state, var_eq, head1, env1, head2, env2)
+                && contract_eq_bounded::<E>(state, var_eq, arg1, env1, arg2, env2)
         }
+        (Var(id1), Var(id2)) if var_eq == VarEq::Direct => id1 == id2,
         // All variables must be bound at this stage. This is checked by the typechecker when
         // walking annotations. However, we may assume that `env` is a local environment (that it
         // doesn't include the stdlib). In that case, free variables (unbound) may be deemed equal
         // if they have the same identifier: whatever global environment the term will be put in,
         // free variables are not redefined locally and will be bound to the same value in any case.
         (Var(id1), Var(id2)) => {
-            env1.get_then(id1.ident(), |binding1| {
-                env2.get_then(id2.ident(), |binding2| {
+            <E as TermEnvironment>::get_then(env1, id1.ident(), |binding1| {
+                <E as TermEnvironment>::get_then(env2, id2.ident(), |binding2| {
                     match (binding1, binding2) {
                         (None, None) => id1 == id2,
                         (Some((t1, env1)), Some((t2, env2))) => {
@@ -258,33 +389,41 @@ fn contract_eq_bounded<E: TermEnvironment>(
                             // still return false if gas was already at zero.
                             let had_gas = state.use_gas();
                             state.use_gas();
-                            had_gas && contract_eq_bounded(state, t1, env1, t2, env2)
+                            had_gas && contract_eq_bounded::<E>(state, var_eq, t1, env1, t2, env2)
                         }
                         _ => false,
                     }
                 })
             })
         }
+        // If one term is a variable and not the other, and we don't have access to an environment, the
+        // term are considered unequal
+        (Var(_), _) | (_, Var(_)) if var_eq == VarEq::Direct => false,
         (Var(id), _) => {
             state.use_gas()
-                && env1.get_then(id.ident(), |binding| {
+                && <E as TermEnvironment>::get_then(env1, id.ident(), |binding| {
                     binding
-                        .map(|(t1, env1)| contract_eq_bounded(state, t1, env1, t2, env2))
+                        .map(|(t1, env1)| {
+                            contract_eq_bounded::<E>(state, var_eq, t1, env1, t2, env2)
+                        })
                         .unwrap_or(false)
                 })
         }
         (_, Var(id)) => {
             state.use_gas()
-                && env2.get_then(id.ident(), |binding| {
+                && <E as TermEnvironment>::get_then(env2, id.ident(), |binding| {
                     binding
-                        .map(|(t2, env2)| contract_eq_bounded(state, t1, env1, t2, env2))
+                        .map(|(t2, env2)| {
+                            contract_eq_bounded::<E>(state, var_eq, t1, env1, t2, env2)
+                        })
                         .unwrap_or(false)
                 })
         }
         (Record(r1), Record(r2)) => {
-            map_eq(
-                contract_eq_fields,
+            map_eq::<_, _, E>(
+                contract_eq_fields::<E>,
                 state,
+                var_eq,
                 &r1.fields,
                 env1,
                 &r2.fields,
@@ -297,9 +436,10 @@ fn contract_eq_bounded<E: TermEnvironment>(
         {
             dyn_fields1.is_empty()
                 && dyn_fields2.is_empty()
-                && map_eq(
-                    contract_eq_fields,
+                && map_eq::<_, _, E>(
+                    contract_eq_fields::<E>,
                     state,
+                    var_eq,
                     &r1.fields,
                     env1,
                     &r2.fields,
@@ -312,13 +452,13 @@ fn contract_eq_bounded<E: TermEnvironment>(
                 && ts1
                     .iter()
                     .zip(ts2.iter())
-                    .all(|(t1, t2)| contract_eq_bounded(state, t1, env1, t2, env2))
+                    .all(|(t1, t2)| contract_eq_bounded::<E>(state, var_eq, t1, env1, t2, env2))
                 && attrs1 == attrs2
         }
         // We must compare the inner values as well as the corresponding contracts or type
         // annotations.
         (Annotated(annot1, t1), Annotated(annot2, t2)) => {
-            let value_eq = contract_eq_bounded(state, t1, env1, t2, env2);
+            let value_eq = contract_eq_bounded::<E>(state, var_eq, t1, env1, t2, env2);
 
             // TODO:
             // - does it really make sense to compare the annotations?
@@ -337,9 +477,10 @@ fn contract_eq_bounded<E: TermEnvironment>(
                 (None, None) => true,
                 (Some(ctr1), Some(ctr2)) => type_eq_bounded(
                     state,
-                    &GenericUnifType::from_type(ctr1.typ.clone(), env1),
+                    var_eq,
+                    &GenericUnifType::<E>::from_type(ctr1.typ.clone(), env1),
                     env1,
-                    &GenericUnifType::from_type(ctr2.typ.clone(), env2),
+                    &GenericUnifType::<E>::from_type(ctr2.typ.clone(), env2),
                     env2,
                 ),
                 _ => false,
@@ -348,29 +489,38 @@ fn contract_eq_bounded<E: TermEnvironment>(
             value_eq && ty_eq
         }
         (Op1(UnaryOp::StaticAccess(id1), t1), Op1(UnaryOp::StaticAccess(id2), t2)) => {
-            id1 == id2 && contract_eq_bounded(state, t1, env1, t2, env2)
+            id1 == id2 && contract_eq_bounded::<E>(state, var_eq, t1, env1, t2, env2)
         }
+        (Type(ty1), Type(ty2)) => type_eq_bounded(
+            state,
+            var_eq,
+            &GenericUnifType::<E>::from_type(ty1.clone(), env1),
+            env1,
+            &GenericUnifType::<E>::from_type(ty2.clone(), env2),
+            env2,
+        ),
         // We don't treat imports, parse errors, nor pairs of terms that don't have the same shape
         _ => false,
     }
 }
 
 /// Compute the equality between two hashmaps holding either types or terms.
-fn map_eq<V, F, E>(
+fn map_eq<V, F, E: TermEnvironment>(
     mut f: F,
     state: &mut State,
+    var_eq: VarEq,
     map1: &IndexMap<LocIdent, V>,
-    env1: &E,
+    env1: E::Ref<'_>,
     map2: &IndexMap<LocIdent, V>,
-    env2: &E,
+    env2: E::Ref<'_>,
 ) -> bool
 where
-    F: FnMut(&mut State, &V, &E, &V, &E) -> bool,
+    F: FnMut(&mut State, VarEq, &V, E::Ref<'_>, &V, E::Ref<'_>) -> bool,
 {
     map1.len() == map2.len()
         && map1.iter().all(|(id, v1)| {
             map2.get(id)
-                .map(|v2| f(state, v1, env1, v2, env2))
+                .map(|v2| f(state, var_eq, v1, env1, v2, env2))
                 .unwrap_or(false)
         })
 }
@@ -416,14 +566,15 @@ fn rows_as_set(erows: &UnifEnumRows) -> Option<HashSet<LocIdent>> {
 /// definition, or are both defined and their values are equal.
 fn contract_eq_fields<E: TermEnvironment>(
     state: &mut State,
+    var_eq: VarEq,
     field1: &Field,
-    env1: &E,
+    env1: E::Ref<'_>,
     field2: &Field,
-    env2: &E,
+    env2: E::Ref<'_>,
 ) -> bool {
     match (&field1.value, &field2.value) {
         (Some(ref value1), Some(ref value2)) => {
-            contract_eq_bounded(state, value1, env1, value2, env2)
+            contract_eq_bounded::<E>(state, var_eq, value1, env1, value2, env2)
         }
         (None, None) => true,
         _ => false,
@@ -442,10 +593,11 @@ fn contract_eq_fields<E: TermEnvironment>(
 /// and we can just start from `0`.
 fn type_eq_bounded<E: TermEnvironment>(
     state: &mut State,
+    var_eq: VarEq,
     ty1: &GenericUnifType<E>,
-    env1: &E,
+    env1: E::Ref<'_>,
     ty2: &GenericUnifType<E>,
-    env2: &E,
+    env2: E::Ref<'_>,
 ) -> bool {
     match (ty1, ty2) {
         (GenericUnifType::Concrete { typ: s1, .. }, GenericUnifType::Concrete { typ: s2, .. }) => {
@@ -465,13 +617,13 @@ fn type_eq_bounded<E: TermEnvironment>(
                         type_fields: uty2,
                         flavour: attrs2,
                     },
-                ) if attrs1 == attrs2 => type_eq_bounded(state, uty1, env1, uty2, env2),
+                ) if attrs1 == attrs2 => type_eq_bounded(state, var_eq, uty1, env1, uty2, env2),
                 (TypeF::Array(uty1), TypeF::Array(uty2)) => {
-                    type_eq_bounded(state, uty1, env1, uty2, env2)
+                    type_eq_bounded(state, var_eq, uty1, env1, uty2, env2)
                 }
                 (TypeF::Arrow(s1, t1), TypeF::Arrow(s2, t2)) => {
-                    type_eq_bounded(state, s1, env1, s2, env2)
-                        && type_eq_bounded(state, t1, env1, t2, env2)
+                    type_eq_bounded(state, var_eq, s1, env1, s2, env2)
+                        && type_eq_bounded(state, var_eq, t1, env1, t2, env2)
                 }
                 (TypeF::Enum(uty1), TypeF::Enum(uty2)) => {
                     let rows1 = rows_as_set(uty1);
@@ -481,12 +633,13 @@ fn type_eq_bounded<E: TermEnvironment>(
                 (TypeF::Record(uty1), TypeF::Record(uty2)) => {
                     fn type_eq_bounded_wrapper<E: TermEnvironment>(
                         state: &mut State,
+                        var_eq: VarEq,
                         uty1: &&GenericUnifType<E>,
-                        env1: &E,
+                        env1: E::Ref<'_>,
                         uty2: &&GenericUnifType<E>,
-                        env2: &E,
+                        env2: E::Ref<'_>,
                     ) -> bool {
-                        type_eq_bounded(state, *uty1, env1, *uty2, env2)
+                        type_eq_bounded(state, var_eq, *uty1, env1, *uty2, env2)
                     }
 
                     let map1 = rows_as_map(uty1);
@@ -494,12 +647,20 @@ fn type_eq_bounded<E: TermEnvironment>(
 
                     map1.zip(map2)
                         .map(|(m1, m2)| {
-                            map_eq(type_eq_bounded_wrapper, state, &m1, env1, &m2, env2)
+                            map_eq::<_, _, E>(
+                                type_eq_bounded_wrapper,
+                                state,
+                                var_eq,
+                                &m1,
+                                env1,
+                                &m2,
+                                env2,
+                            )
                         })
                         .unwrap_or(false)
                 }
                 (TypeF::Flat(t1), TypeF::Flat(t2)) => {
-                    contract_eq_bounded(state, t1, env1, t2, env2)
+                    contract_eq_bounded::<E>(state, var_eq, t1, env1, t2, env2)
                 }
                 (
                     TypeF::Forall {
@@ -537,7 +698,7 @@ fn type_eq_bounded<E: TermEnvironment>(
                         ),
                     };
 
-                    type_eq_bounded(state, &uty1_subst, env1, &uty2_subst, env2)
+                    type_eq_bounded(state, var_eq, &uty1_subst, env1, &uty2_subst, env2)
                 }
                 // We can't compare type variables without knowing what they are instantiated to,
                 // and all type variables should have been substituted at this point, so we bail
diff --git a/core/src/typecheck/mod.rs b/core/src/typecheck/mod.rs
index 1e43aa55..8ef7d6c5 100644
--- a/core/src/typecheck/mod.rs
+++ b/core/src/typecheck/mod.rs
@@ -89,7 +89,7 @@ pub mod mk_uniftype;
 pub mod eq;
 pub mod unif;
 
-use eq::{SimpleTermEnvironment, TermEnvironment};
+use eq::{SimpleTermEnvironment, TermEnvironment, ToOwnedEnv};
 use error::*;
 use indexmap::IndexMap;
 use operation::{get_bop_type, get_nop_type, get_uop_type};
@@ -329,7 +329,7 @@ pub enum GenericUnifType<E: TermEnvironment> {
     /// A contract, seen as an opaque type. In order to compute type equality between contracts or
     /// between a contract and a type, we need to carry an additional environment. This is why we
     /// don't reuse the variant from [`crate::typ::TypeF`].
-    Contract(RichTerm, E),
+    Contract(RichTerm, E::Owned),
     /// A rigid type constant which cannot be unified with anything but itself.
     Constant(VarId),
     /// A unification variable.
@@ -478,7 +478,7 @@ impl<E: TermEnvironment + Clone> GenericUnifRecordRows<E> {
     /// Create `GenericUnifRecordRows` from `RecordRows`. Contracts are represented as the separate
     /// variant [`GenericUnifType::Contract`] which also stores a term environment, required for
     /// checking type equality involving contracts.
-    pub fn from_record_rows(rrows: RecordRows, env: &E) -> Self {
+    pub fn from_record_rows(rrows: RecordRows, env: E::Ref<'_>) -> Self {
         let f_rrow = |ty: Box<Type>| Box::new(GenericUnifType::from_type(*ty, env));
         let f_rrows =
             |rrows: Box<RecordRows>| Box::new(GenericUnifRecordRows::from_record_rows(*rrows, env));
@@ -820,9 +820,9 @@ impl<E: TermEnvironment + Clone> GenericUnifType<E> {
     /// Create a [`GenericUnifType`] from a [`Type`]. Contracts are represented as the separate
     /// variant [`GenericUnifType::Contract`] which also stores a term environment, required for
     /// checking type equality involving contracts.
-    pub fn from_type(ty: Type, env: &E) -> Self {
+    pub fn from_type(ty: Type, env: E::Ref<'_>) -> Self {
         match ty.typ {
-            TypeF::Flat(t) => GenericUnifType::Contract(t, env.clone()),
+            TypeF::Flat(t) => GenericUnifType::Contract(t, env.to_owned_env()),
             ty => GenericUnifType::concrete(ty.map(
                 |ty_| Box::new(GenericUnifType::from_type(*ty_, env)),
                 |rrows| GenericUnifRecordRows::from_record_rows(rrows, env),
diff --git a/core/src/typecheck/unif.rs b/core/src/typecheck/unif.rs
index 8d575a8a..cdbef5fa 100644
--- a/core/src/typecheck/unif.rs
+++ b/core/src/typecheck/unif.rs
@@ -1124,7 +1124,13 @@ impl Unify for UnifType {
                 Err(UnifError::WithConst(VarKindDiscriminant::Type, i, ty))
             }
             (UnifType::Contract(t1, env1), UnifType::Contract(t2, env2))
-                if eq::contract_eq(state.table.max_uvars_count(), &t1, &env1, &t2, &env2) =>
+                if eq::contract_eq::<SimpleTermEnvironment>(
+                    state.table.max_uvars_count(),
+                    &t1,
+                    &env1,
+                    &t2,
+                    &env2,
+                ) =>
             {
                 Ok(())
             }
diff --git a/doc/manual/contracts.md b/doc/manual/contracts.md
index 6a901670..a43aa8c1 100644
--- a/doc/manual/contracts.md
+++ b/doc/manual/contracts.md
@@ -58,6 +58,29 @@ are available. `Dyn` is a contract that never fails.
 
 ## User-defined contracts
 
+### Preamble: idempotency
+
+As you will see in this section, Nickel contracts are more than mere boolean
+validators. They can sometimes modify the checked value for valid reasons
+exposed below. However, it's hard (if not impossible) to rightfully limit the
+expressive power of contracts to be *reasonable* with respect to modifications.
+
+For Nickel, the notion of reasonable modification is practically defined by
+idempotency. An idempotent contract is a contract which gives the same result if
+applied once or many times to the same value. That is, for any value,
+`MyContract` is idempotent if `value | MyContract` and `value | MyContract |
+MyContract` gives the same result. Idempotency sounds like a reasonable property
+for a contract, even if it can perform some normalization.
+
+**Nickel makes the assumption that all contracts - including user-defined
+contracts - are idempotent**, because the converse would be surprising for
+consumers of the contract and because this assumption enables important
+optimizations such as contract deduplication. **While non-idempotent contracts
+shouldn't wreak havoc on your program, they might lead to surprising results**,
+such as a change in behavior e.g. when refactoring a program to a new version
+that should be identical, typically because the deduplication optimization
+doesn't fire anymore.
+
 ### By hand
 
 The ability to check arbitrary properties is where run-time contracts really
diff --git a/flake.nix b/flake.nix
index 8a09bdf3..aa875f74 100644
--- a/flake.nix
+++ b/flake.nix
@@ -234,9 +234,12 @@
         pname = pkg.pname + "-rev-fixup";
         inherit (pkg) version meta;
         src = pkg;
-        buildInputs = [ pkgs.bbe ];
+        buildInputs = [ pkgs.bbe ]
+          ++ pkgs.lib.optionals pkgs.stdenv.isDarwin [ pkgs.darwin.autoSignDarwinBinariesHook ];
         phases = [ "fixupPhase" ];
         fixupPhase = ''
+          runHook preFixup
+
           mkdir -p $out/bin
           for srcBin in $src/bin/*; do
             outBin="$out/bin/$(basename $srcBin)"
@@ -247,6 +250,8 @@
               $srcBin > $outBin
             chmod +x $outBin
           done
+
+          runHook postFixup
         '';
       };
 
diff --git a/utils/src/bench.rs b/utils/src/bench.rs
index 4e107f81..b3fb3e0f 100644
--- a/utils/src/bench.rs
+++ b/utils/src/bench.rs
@@ -121,12 +121,14 @@ pub fn bench_terms<'r>(rts: Vec<Bench<'r>>) -> Box<dyn Fn(&mut Criterion) + 'r>
                             c_local.typecheck(id, &type_ctxt).unwrap();
                         } else {
                             c_local.prepare(id, &type_ctxt).unwrap();
+
                             VirtualMachine::new_with_cache(
                                 c_local,
                                 eval_cache.clone(),
                                 std::io::sink(),
                             )
-                            .eval(t, &eval_env)
+                            .with_initial_env(eval_env.clone())
+                            .eval(t)
                             .unwrap();
                         }
                     },
@@ -201,12 +203,14 @@ macro_rules! ncl_bench_group {
                                 c_local.typecheck(id, &type_ctxt).unwrap();
                             } else {
                                 c_local.prepare(id, &type_ctxt).unwrap();
+
                                 VirtualMachine::new_with_cache(
                                     c_local,
                                     eval_cache.clone(),
                                     std::io::sink()
                                 )
-                                .eval(t, &eval_env)
+                                .with_initial_env(eval_env.clone())
+                                .eval(t)
                                 .unwrap();
                             }
                         },


Test patch:
diff --git a/core/src/eval/tests.rs b/core/src/eval/tests.rs
index a2f97182..8877f0bb 100644
--- a/core/src/eval/tests.rs
+++ b/core/src/eval/tests.rs
@@ -14,7 +14,7 @@ use codespan::Files;
 /// Evaluate a term without import support.
 fn eval_no_import(t: RichTerm) -> Result<Term, EvalError> {
     VirtualMachine::<_, CacheImpl>::new(DummyResolver {}, std::io::sink())
-        .eval(t, &Environment::new())
+        .eval(t)
         .map(Term::from)
 }
 
@@ -169,9 +169,7 @@ fn imports() {
     let mk_import_two = mk_import("x", "two", mk_term::var("x"), &mut vm).unwrap();
     vm.reset();
     assert_eq!(
-        vm.eval(mk_import_two, &Environment::new(),)
-            .map(RichTerm::without_pos)
-            .unwrap(),
+        vm.eval(mk_import_two).map(RichTerm::without_pos).unwrap(),
         mk_term::integer(2)
     );
 
@@ -187,9 +185,7 @@ fn imports() {
     );
     vm.reset();
     assert_eq!(
-        vm.eval(mk_import_lib.unwrap(), &Environment::new(),)
-            .map(Term::from)
-            .unwrap(),
+        vm.eval(mk_import_lib.unwrap()).map(Term::from).unwrap(),
         Term::Bool(true)
     );
 }
@@ -269,7 +265,8 @@ fn initial_env() {
     let t = mk_term::let_in("x", mk_term::integer(2), mk_term::var("x"));
     assert_eq!(
         VirtualMachine::new_with_cache(DummyResolver {}, eval_cache.clone(), std::io::sink())
-            .eval(t, &initial_env)
+            .with_initial_env(initial_env.clone())
+            .eval(t)
             .map(RichTerm::without_pos),
         Ok(mk_term::integer(2))
     );
@@ -277,7 +274,8 @@ fn initial_env() {
     let t = mk_term::let_in("x", mk_term::integer(2), mk_term::var("g"));
     assert_eq!(
         VirtualMachine::new_with_cache(DummyResolver {}, eval_cache.clone(), std::io::sink())
-            .eval(t, &initial_env)
+            .with_initial_env(initial_env.clone())
+            .eval(t)
             .map(RichTerm::without_pos),
         Ok(mk_term::integer(1))
     );
@@ -286,7 +284,8 @@ fn initial_env() {
     let t = mk_term::let_in("g", mk_term::integer(2), mk_term::var("g"));
     assert_eq!(
         VirtualMachine::new_with_cache(DummyResolver {}, eval_cache.clone(), std::io::sink())
-            .eval(t, &initial_env)
+            .with_initial_env(initial_env.clone())
+            .eval(t)
             .map(RichTerm::without_pos),
         Ok(mk_term::integer(2))
     );