@@ -6,6 +6,9 @@ use crate::ast::*;
66use crate :: scope:: * ;
77use crate :: token:: Span ;
88use crate :: types:: * ;
9+ use std:: collections:: hash_map:: DefaultHasher ;
10+ use std:: collections:: HashMap ;
11+ use std:: hash:: { Hash , Hasher } ;
912use thiserror:: Error ;
1013
1114#[ derive( Error , Debug , Clone ) ]
@@ -132,6 +135,20 @@ pub struct Checker {
132135 /// avoid spamming a duplicate error for every parent expression on the
133136 /// way out of the recursion.
134137 too_deep_reported : bool ,
138+ /// Content-addressed result cache for *environment-independent*
139+ /// subexpressions (hyperpolymath/my-lang#16).
140+ ///
141+ /// Keyed by a span-stripped structural hash. Only populated for the
142+ /// provably-safe subset built by [`Checker::memo_key`] (string/number/
143+ /// bool literals and calls that resolve to the genuine stdlib binding,
144+ /// recursively). For that subset the resulting [`Ty`] is a pure function
145+ /// of the structural form — it does not depend on the symbol environment,
146+ /// introduces no scope bindings, and (because we only insert when the
147+ /// check produced no new diagnostics) re-encountering the expression is
148+ /// observably identical to re-checking it. This turns the repeated
149+ /// `str_concat`/`format` templating sites from #1 from
150+ /// O(occurrences × subtree) into O(distinct × subtree).
151+ expr_cache : HashMap < u64 , Ty > ,
135152}
136153
137154impl Default for Checker {
@@ -149,6 +166,7 @@ impl Checker {
149166 current_return_type : None ,
150167 expr_depth : 0 ,
151168 too_deep_reported : false ,
169+ expr_cache : HashMap :: new ( ) ,
152170 } ;
153171 checker. register_stdlib ( ) ;
154172 checker
@@ -714,7 +732,114 @@ impl Checker {
714732 }
715733 }
716734
735+ /// Type-check an expression, consulting the content-addressed result
736+ /// cache for environment-independent subexpressions first
737+ /// (hyperpolymath/my-lang#16).
738+ ///
739+ /// A cache hit returns immediately *without recursing*, which is the
740+ /// whole point: structurally-identical `str_concat`/`format` templating
741+ /// trees that recur across functions are checked once. A cache hit also
742+ /// cannot trip the depth guard or allocate, so a repeated deep-but-legal
743+ /// pure chain no longer pays its full recursive cost on every occurrence.
744+ /// We only *insert* a result when the underlying check produced no new
745+ /// diagnostics and a concrete type, so memoisation never suppresses or
746+ /// reorders an error relative to the un-cached behaviour.
717747 fn check_expr ( & mut self , expr : & Expr ) -> Ty {
748+ let Some ( key) = self . memo_key ( expr) else {
749+ return self . check_expr_guarded ( expr) ;
750+ } ;
751+ if let Some ( ty) = self . expr_cache . get ( & key) {
752+ return ty. clone ( ) ;
753+ }
754+ let errors_before = self . errors . len ( ) ;
755+ let ty = self . check_expr_guarded ( expr) ;
756+ if self . errors . len ( ) == errors_before && !ty. is_error_or_unknown ( ) {
757+ self . expr_cache . insert ( key, ty. clone ( ) ) ;
758+ }
759+ ty
760+ }
761+
762+ /// Span-stripped structural hash for the subset of expressions whose type
763+ /// is a pure function of their form, independent of the symbol
764+ /// environment and free of scope side effects. Returns `None` for
765+ /// anything outside that subset (in particular: every expression that
766+ /// resolves an identifier against local scope, and every scope-
767+ /// introducing form — lambdas, blocks, matches, records), so those are
768+ /// always re-checked normally and the cache stays sound.
769+ ///
770+ /// The subset:
771+ /// - literals (type fixed by the literal kind), and
772+ /// - calls whose callee resolves to the *genuine* stdlib binding (so the
773+ /// result type is the fixed stdlib signature regardless of scope, and a
774+ /// local/user shadow is correctly excluded) and whose arguments are
775+ /// themselves in the subset.
776+ ///
777+ /// Spans are never fed to the hasher, so structurally-equal expressions
778+ /// at different source locations share a key. The key is a 64-bit hash;
779+ /// a collision (≈2⁻⁶⁴) could return a wrong cached type, which is the
780+ /// standard, accepted trade-off for content-addressed memoisation and is
781+ /// acceptable here because this is an optional optimisation layer over an
782+ /// already-linear checker (see #14).
783+ fn memo_key ( & self , expr : & Expr ) -> Option < u64 > {
784+ let mut hasher = DefaultHasher :: new ( ) ;
785+ self . hash_pure ( expr, & mut hasher) ?;
786+ Some ( hasher. finish ( ) )
787+ }
788+
789+ fn hash_pure ( & self , expr : & Expr , hasher : & mut DefaultHasher ) -> Option < ( ) > {
790+ match expr {
791+ Expr :: Literal ( lit) => {
792+ match lit {
793+ Literal :: Int ( v, _) => {
794+ 0u8 . hash ( hasher) ;
795+ v. hash ( hasher) ;
796+ }
797+ Literal :: Float ( v, _) => {
798+ 1u8 . hash ( hasher) ;
799+ v. to_bits ( ) . hash ( hasher) ;
800+ }
801+ Literal :: String ( s, _) => {
802+ 2u8 . hash ( hasher) ;
803+ s. hash ( hasher) ;
804+ }
805+ Literal :: Bool ( b, _) => {
806+ 3u8 . hash ( hasher) ;
807+ b. hash ( hasher) ;
808+ }
809+ }
810+ Some ( ( ) )
811+ }
812+ Expr :: Call { callee, args, .. } => {
813+ let Expr :: Ident ( id) = callee. as_ref ( ) else {
814+ return None ;
815+ } ;
816+ // Only cacheable if the callee resolves to the real stdlib
817+ // function symbol. Comparing against the canonical stdlib
818+ // signature rejects a local `let str_concat = ...` or a
819+ // user-defined function that shadows the name, so the name
820+ // alone is a sound proxy for the resolved (scope-invariant)
821+ // result type.
822+ let sig = Self :: stdlib_function_type ( & id. name ) ;
823+ if !matches ! ( sig, Ty :: Function { .. } ) {
824+ return None ;
825+ }
826+ match self . symbols . lookup ( & id. name ) {
827+ Some ( sym) if sym. ty == sig => { }
828+ _ => return None ,
829+ }
830+ 4u8 . hash ( hasher) ;
831+ id. name . hash ( hasher) ;
832+ ( args. len ( ) as u64 ) . hash ( hasher) ;
833+ for arg in args {
834+ self . hash_pure ( arg, hasher) ?;
835+ }
836+ Some ( ( ) )
837+ }
838+ _ => None ,
839+ }
840+ }
841+
842+ fn check_expr_guarded ( & mut self , expr : & Expr ) -> Ty {
718843 // Depth guard: stops pathological inputs (e.g. deeply chained
719844 // str_concat/format) from driving the checker into runaway memory
720845 // allocation or stack overflow. Reports a single ExpressionTooDeep
@@ -1530,6 +1655,82 @@ mod tests {
15301655 assert ! ( check_source( & src) . is_ok( ) ) ;
15311656 }
15321657
1658+ #[ test]
1659+ fn test_memoised_pure_chain_is_cached ( ) {
1660+ // #16: a structurally-identical pure str_concat chain repeated across
1661+ // many functions must be checked once and reused. We can observe the
1662+ // cache directly via the Checker API.
1663+ let src = r#"
1664+ fn a() { let x = str_concat("p", str_concat("q", "r")); }
1665+ fn b() { let y = str_concat("p", str_concat("q", "r")); }
1666+ fn c() { let z = str_concat("p", str_concat("q", "r")); }
1667+ "# ;
1668+ let program = parse ( src) . expect ( "parse" ) ;
1669+ let mut checker = Checker :: new ( ) ;
1670+ checker. check_program ( & program) . expect ( "should type-check" ) ;
1671+ // The repeated `str_concat(...)` tree (and its inner subtree) collapse
1672+ // to a small number of distinct keys, not one per occurrence.
1673+ assert ! (
1674+ !checker. expr_cache. is_empty( ) ,
1675+ "expected pure str_concat chain to be memoised"
1676+ ) ;
1677+ // Three identical chains => the distinct-key count stays at the small
1678+ // fixed set of subexpressions ("p", "q", "r", inner + outer call),
1679+ // not 3x that. The exact figure is an implementation detail; the
1680+ // invariant is that it does not grow with the number of occurrences.
1681+ assert ! (
1682+ checker. expr_cache. len( ) <= 6 ,
1683+ "identical chains should share keys regardless of occurrence count, got {} entries" ,
1684+ checker. expr_cache. len( )
1685+ ) ;
1686+ }
1687+
1688+ #[ test]
1689+ fn test_memoisation_does_not_change_results ( ) {
1690+ // Correctness: every existing positive/negative case must behave
1691+ // identically with the cache in place (the cache is consulted on
1692+ // every expression via check_expr).
1693+ assert ! ( check_source( r#"fn main() { let s = str_concat("a", "b"); }"# ) . is_ok( ) ) ;
1694+ assert ! ( check_source(
1695+ r#"fn main() { let s = format(str_concat("<", str_concat("x", ">"))); }"#
1696+ )
1697+ . is_ok( ) ) ;
1698+ // A pure stdlib call with a genuine type error must still report it
1699+ // (never cached, since the result is an error type).
1700+ let err = check_source ( r#"fn main() { let s = str_concat("a"); }"# ) . unwrap_err ( ) ;
1701+ assert ! ( err. iter( ) . any( |e| matches!( e, CheckError :: WrongArgCount { .. } ) ) ) ;
1702+ }
1703+
1704+ #[ test]
1705+ fn test_memoisation_is_scope_sound ( ) {
1706+ // The soundness hazard #16 calls out: two structurally-identical
1707+ // expressions can have different types because identifiers resolve
1708+ // against scope. `add(p, 1)` is `Int` in one function and `Float` in
1709+ // the other. Because identifier arguments are outside the cacheable
1710+ // subset, each call site is checked against its own scope and the
1711+ // return-type checks must both pass.
1712+ let src = r#"
1713+ fn add(a: Int, b: Int) -> Int { return a + b; }
1714+ fn addf(a: Float, b: Float) -> Float { return a + b; }
1715+ fn use_int(p: Int) -> Int { return add(p, p); }
1716+ fn use_float(p: Float) -> Float { return addf(p, p); }
1717+ "# ;
1718+ assert ! (
1719+ check_source( src) . is_ok( ) ,
1720+ "scope-sensitive look-alikes must each be checked in their own scope"
1721+ ) ;
1722+
1723+ // And the negative: a genuine mismatch is still caught, not masked by
1724+ // a cache entry from the structurally-similar good call.
1725+ let bad = r#"
1726+ fn add(a: Int, b: Int) -> Int { return a + b; }
1727+ fn ok() -> Int { return add(1, 2); }
1728+ fn bad() -> Int { return add(1, "two"); }
1729+ "# ;
1730+ let err = check_source ( bad) . unwrap_err ( ) ;
1731+ assert ! ( err. iter( ) . any( |e| matches!( e, CheckError :: TypeMismatch { .. } ) ) ) ;
1732+ }
1733+
15331734 #[ test]
15341735 fn test_non_bool_condition ( ) {
15351736 let result = check_source ( r#"
0 commit comments