From 1cdeadde9c86a43d6f585f83254bcab73d201bc5 Mon Sep 17 00:00:00 2001 From: Oron Port Date: Sun, 9 Aug 2026 21:08:02 +0300 Subject: [PATCH 01/40] simplify the basic lib playground --- lib/src/test/scala/Playground.scala | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/lib/src/test/scala/Playground.scala b/lib/src/test/scala/Playground.scala index 757d28345..a47a85a0a 100644 --- a/lib/src/test/scala/Playground.scala +++ b/lib/src/test/scala/Playground.scala @@ -1,5 +1,3 @@ import dfhdl.* -import dfhdl.hw.constraints.timing.clock -@clock(edge = _.rising) -@top(false) class Foo extends RTDesign +class Foo extends RTDesign From 54e549c72f323e7529a2260bfeb99837f8fe43b5 Mon Sep 17 00:00:00 2001 From: Oron Port Date: Mon, 10 Aug 2026 01:13:51 +0300 Subject: [PATCH 02/40] core+docs: a wildcard `Int`'s minimum width counts as an actual width in `+`/`-`/`*` (#469) A wildcard `Int` has no exact width, only a minimum one, and adapts to the bit-accurate operand. That adaptation also demanded the value FIT the operand, so `vr0(SInt(16)) :== 180 - 5 * rbow_id(UInt(3))` was rejected for `180` not fitting 3 bits, even though the assignment context is 16 bits wide. (Not the reported `*` vs `+`/`-` asymmetry: `5` fits `u3` and `180` does not, whatever the operator.) The minimum width now counts as an actual width when the result width of `+`, `-` or `*` is computed, so the result is simply the wider of the two operands. That subsumes both behaviors: a minimum within the other operand's width leaves it adapting exactly as before, and one beyond it widens the operation instead of failing. The reporter's line elaborates, and target-context widening then re-evaluates the whole cone at 16 bits, reproducing the Verilog it translates. This needs the other operand's width at compile time as well, so a parametric width, or an `Int` whose value is not a literal, keeps adapting and must fit: the widths of a design must not depend on an applied parameter value, and a result width is a type, so a minimum the type system cannot see could only degrade it. A literal that already fits keeps taking the adapting path, leaving the IR of every legal design untouched. `-`, `/` and `%` stay LHS-dominant. A literal too wide to adapt is then the wider operand and satisfies that by definition, but a negative one meeting an unsigned operand does not, and is rejected at compile time with the message its elaboration-time counterpart already reports. Subtraction over parametric widths is now decided by proof (`IntParamRef.widthFitGE`) rather than skipped when the widths do not resolve. Six shapes, the reporter's among them, are SAT-proven equivalent to hand-written Verilog via a yosys miter. Co-Authored-By: Claude Fable 5 --- .claude/commands/bugfix.md | 18 ++ .../src/main/scala/dfhdl/core/DFDecimal.scala | 223 +++++++++++++----- .../test/scala/CoreSpec/DFDecimalSpec.scala | 96 ++++---- docs/transitioning/from-verilog/index.md | 17 +- docs/user-guide/compilation/index.md | 4 +- docs/user-guide/type-system/index.md | 30 ++- 6 files changed, 270 insertions(+), 118 deletions(-) diff --git a/.claude/commands/bugfix.md b/.claude/commands/bugfix.md index 62aa28b9a..52981734b 100644 --- a/.claude/commands/bugfix.md +++ b/.claude/commands/bugfix.md @@ -311,6 +311,24 @@ failures to expect are therefore specs that assert an error and find none: `asse compile error for its snippet and then an elaboration error for its block, so "which half failed" is a real question and the failure position does not tell you. +### Pick the error assertion by which halves the rule actually has + +The three are not interchangeable, and passing `""` to opt out of a half is a smell: + +| assertion | use when | +|---|---| +| `assertCompileError(msg)(snippet)` | the rule is decided at compile time only | +| `assertRuntimeErrorLog(msg, col1, col2)(block)` | the rule is decided at elaboration only | +| `assertDSLErrorLog(msg)(snippet)(block)` | the SAME rule has both halves | + +`assertDSLErrorLog(msg)("")(block)` is an elaboration-only assertion wearing the two-sided +helper: write `assertRuntimeErrorLog` there instead. Because `assertDSLErrorLog` takes ONE +message for both halves, using it is also a design statement: the compile-time check and the +elaboration check must report the SAME text. When a rule is enforced statically for operands +whose widths are known and dynamically for those that are not (the `Int`-literal vs. parameter +split of the wildcard rules), give both checks that one message, and let a single +`assertDSLErrorLog` pin the pair. + ### Sibling op givens drift like twin helpers do The "twin helpers drift" rule from §2 applies to `ExactOp*` given families too. Issue #445: the diff --git a/core/src/main/scala/dfhdl/core/DFDecimal.scala b/core/src/main/scala/dfhdl/core/DFDecimal.scala index 6301d1600..21b7b90b5 100644 --- a/core/src/main/scala/dfhdl/core/DFDecimal.scala +++ b/core/src/main/scala/dfhdl/core/DFDecimal.scala @@ -134,6 +134,18 @@ object DFDecimal: ITE[BaS, "a signed", "an unsigned"] + " bit-accurate value.\nUse an explicit conversion or `sd\"\"` interpolation." ] + // A negative `Int` on the LHS of an LHS-dominant operation (`-`, `/`, `%`) with an + // unsigned operand: the result would have to be signed AND wider, which those + // operations cannot express. Carries the message the elaboration-time check throws for + // the same mistake made through a value whose width is not statically known. + object `WcNonNegForBa` + extends Check2[ + Boolean, + Boolean, + [BaS <: Boolean, WcS <: Boolean] =>> BaS || ![WcS], + [BaS <: Boolean, + WcS <: Boolean] =>> "Wildcard `Int` value is negative and cannot adapt to an unsigned bit-accurate value." + ] object `BaW >= WcW` extends Check2[ Int, @@ -1437,6 +1449,31 @@ object DFXInt: // Check that a wildcard `Int` value fits in the bit-accurate value's type. // Produces an elaboration error if it doesn't. + // Whether a wildcard `Int` value adapts to the bit-accurate type without truncation, + // i.e. the condition behind `checkWildcardFit`. A LITERAL that fits adapts exactly as + // it always has, keeping the IR of every previously-legal design unchanged; only one + // that does not fit widens the result instead of being rejected (the arithmetic + // givens). An unresolvable width answers `true`, leaving the decision to the adapting + // path's own check. + private def wildcardFits( + wildcard: DFValOf[DFInt32], + bitAccurateType: DFTypeAny + )(using dfc: DFC): Boolean = + val baType = bitAccurateType.asIR.asInstanceOf[ir.DFDecimal] + import dfc.getSet + import DFXInt.Val.getActualSignedWidthOpt + wildcard.getActualSignedWidthOpt match + case Some(wcSigned, wcWidthIntOpt) => + if (!baType.signed && wcSigned) false + else + (baType.widthIntOpt, wcWidthIntOpt) match + case (Some(baWidth), Some(wcWidth)) => + val effectiveWidth = if (baType.signed && !wcSigned) wcWidth + 1 else wcWidth + effectiveWidth <= baWidth + case _ => true + case _ => true + end wildcardFits + private def checkWildcardFit( wildcard: DFValOf[DFInt32], bitAccurateType: DFTypeAny @@ -1526,48 +1563,39 @@ object DFXInt: RS <: Boolean, RW <: IntP, RN <: NativeType, - RP, - LWUB <: Int, - RWUB <: Int + RP ](using icL: Candidate.Aux[L, LS, LW, LN, LP], icR: Candidate.Aux[R, RS, RW, RN, RP], op: ValueOf[Op], isWildcardL: ValueOf[LN], isWildcardR: ValueOf[RN], - // Type-level wildcard detection: when exactly one operand is a wildcard - // (Int32 NativeType), adapt to the bit-accurate value's sign and width. - // When both are wildcards, use LS || RS and Max (both-wildcard = DFInt32-like). - resultSign: Id[ITE[LN && ![RN], RS, ITE[RN && ![LN], LS, ITE[LN && RN, LS, LS || RS]]]], - resultWidth: Id[ITE[LN && ![RN], RW, ITE[ - RN && ![LN], + // An ADAPTING wildcard: a wildcard `Int` operand (Int32 NativeType) that takes the + // bit-accurate operand's sign and width, its own value fit-checked at elaboration. + // A wildcard adapts unless BOTH widths are statically known, which happens exactly + // when it is a Scala `Int` literal (whose candidate builds a bit-accurate constant + // at the value's minimal width) meeting a literal-width operand. There it is not + // adapted but participates in the width calculation like any bit-accurate operand, + // so a literal wider than the other operand widens the result instead of being + // rejected. A parametric width keeps adapting: the widths of a design must not + // depend on an applied parameter value, and the relation is undecidable anyway. + adaptL: Id[LN && ![RN] && ![IntP.IsConstInt2[LW, RW]]], + adaptR: Id[RN && ![LN] && ![IntP.IsConstInt2[LW, RW]]], + // the same predicate as a value, so the elaboration below branches exactly as the + // types above do (a RUNTIME `Int` is statically width-unknown, and its candidate + // builds a bit-accurate constant, so it must adapt like a parameter) + knownWidths: ValueOf[IntP.IsConstInt2[LW, RW]] + )(using + // Both wildcards keep the LHS type (DFInt32-like `Int` arithmetic). + resultSign: Id[ + ITE[adaptL.Out, RS, ITE[adaptR.Out, LS, ITE[LN && RN, LS, LS || RS]]] + ], + resultWidth: Id[ITE[adaptL.Out, RW, ITE[ + adaptR.Out, LW, ITE[LN && RN, LW, IntP.ArithMaxWidth[LS, LW, RS, RW]] ]]], - resultNative: Id[ITE[LN && ![RN], RN, LN]], - // Compile-time wildcard fit: when one operand is a literal wildcard, - // verify its sign and width fit in the bit-accurate value's type. - // the UBound outputs are bound to plain type parameters rather than read off the - // instances: `IsConst` answers `false` for a path-dependent type just as it does for an - // unreduced match type, which would collapse these widths (see `IntP.IsConstInt2`) - ubLW: UBound.Aux[Int, LW, LWUB], - ubRW: UBound.Aux[Int, RW, RWUB], - checkWS: `BaS >= WcS`.Check[ - ITE[RN && ![LN], LS, ITE[LN && ![RN], RS, LS]], - ITE[RN && ![LN], RS, ITE[LN && ![RN], LS, LS]] - ], - checkWW: `BaW >= WcW`.Check[ - ITE[RN && ![LN], LWUB, ITE[LN && ![RN], RWUB, LWUB]], - ITE[ - RN && ![LN], - ITE[LS && ![RS], IntP.Inc[RWUB], RWUB], - ITE[ - LN && ![RN], - ITE[RS && ![LS], IntP.Inc[LWUB], LWUB], - LWUB - ] - ] - ] + resultNative: Id[ITE[LN && ![RN], RN, LN]] ): ExactOp2Aux[Op, DFC, DFValAny, L, R, DFValTP[ DFXInt[resultSign.Out, resultWidth.Out, resultNative.Out], LP | RP @@ -1579,8 +1607,21 @@ object DFXInt: val lhsVal = icL(lhs)(using dfcAnon) val rhsVal = icR(rhs)(using dfcAnon) import IntParam.{+, max} - val lhsIsWildcard = isWildcardL.value - val rhsIsWildcard = isWildcardR.value + // A Scala `Int` LITERAL facing a BIT-ACCURATE operand is not treated as a + // wildcard: the candidate has already built it as a bit-accurate constant at its + // value's minimal width, so it takes the ordinary path below (sign alignment and + // the wider of the two widths), exactly like a written `d"W'V"` constant. Facing + // another wildcard it stays an `Int`, so `Int` arithmetic (a parameter and a + // literal, say) never collapses to the literal's own width. This mirrors the + // `adaptL`/`adaptR` type-level conditions above. + val lhsIsWildcard = + isWildcardL.value && + (isWildcardR.value || !knownWidths.value || + wildcardFits(lhsVal.asValOf[DFInt32], rhsVal.dfType)) + val rhsIsWildcard = + isWildcardR.value && + (isWildcardL.value || !knownWidths.value || + wildcardFits(rhsVal.asValOf[DFInt32], lhsVal.dfType)) val retVal = if (lhsIsWildcard && !rhsIsWildcard) // LHS is wildcard: adapt to RHS type, keeping the written operand order @@ -1632,9 +1673,7 @@ object DFXInt: RS <: Boolean, RW <: IntP, RN <: NativeType, - RP, - LWUB <: Int, - RWUB <: Int + RP ](using icL: Candidate.Aux[L, LS, LW, LN, LP], icR: Candidate.Aux[R, RS, RW, RN, RP], @@ -1643,29 +1682,31 @@ object DFXInt: isWildcardR: ValueOf[RN] )(using check: ArithCheck[LS, LW, LN, RS, RW, RN], - // Wildcard LHS adapts to RHS type; otherwise LHS-dominant - resultSign: Id[ITE[LN && ![RN], RS, LS]], - resultWidth: Id[ITE[LN && ![RN], RW, LW]], - resultNative: Id[ITE[LN && ![RN], RN, LN]], - // Compile-time wildcard fit: when LHS is a literal wildcard, - // verify its sign and width fit in the RHS (bit-accurate value) type. - // the UBound outputs are bound to plain type parameters rather than read off the - // instances: `IsConst` answers `false` for a path-dependent type just as it does for an - // unreduced match type, which would collapse these widths (see `IntP.IsConstInt2`) - ubLW: UBound.Aux[Int, LW, LWUB], - ubRW: UBound.Aux[Int, RW, RWUB], - checkWS: `BaS >= WcS`.Check[ - ITE[LN && ![RN], RS, LS], - ITE[LN && ![RN], LS, LS] + // A wildcard LHS takes the RHS's type (see the commutative given for the adapting + // vs. literal wildcard distinction); a LITERAL LHS instead adopts the common type + // (the RHS's sign, the wider of the two widths), so the LHS-dominance rule these + // operations impose is satisfied by construction: an `Int` literal never narrows + // the result, and never has to fit within the RHS's own width. Anything else is + // LHS-dominant, and `check` enforces the rule. + adaptL: Id[LN && ![RN] && ![IntP.IsConstInt2[LW, RW]]], + litL: Id[LN && ![RN] && IntP.IsConstInt2[LW, RW]], + // see the commutative given: the type-level predicate as a value + knownWidths: ValueOf[IntP.IsConstInt2[LW, RW]] + )(using + // These operations stay LHS-dominant, so a LITERAL LHS must still hold the RHS. A + // literal too WIDE to adapt satisfies that by definition (it is then the wider + // operand), but a NEGATIVE one meeting an unsigned operand does not: the result + // would have to be signed AND wider, so widening here would silently rewrite the + // operation's width. + checkLitS: `WcNonNegForBa`.Check[ + ITE[litL.Out, RS, true], + ITE[litL.Out, LS, false] ], - checkWW: `BaW >= WcW`.Check[ - ITE[LN && ![RN], RWUB, LWUB], - ITE[ - LN && ![RN], - ITE[RS && ![LS], IntP.Inc[LWUB], LWUB], - LWUB - ] - ] + resultSign: Id[ITE[adaptL.Out, RS, ITE[litL.Out, LS || RS, LS]]], + resultWidth: Id[ + ITE[adaptL.Out, RW, ITE[litL.Out, IntP.ArithMaxWidth[LS, LW, RS, RW], LW]] + ], + resultNative: Id[ITE[LN && ![RN], RN, LN]] ): ExactOp2Aux[Op, DFC, DFValAny, L, R, DFValTP[ DFXInt[resultSign.Out, resultWidth.Out, resultNative.Out], LP | RP @@ -1676,17 +1717,73 @@ object DFXInt: val dfcAnon = dfc.anonymize val lhsVal = icL(lhs)(using dfcAnon) val rhsVal = icR(rhs)(using dfcAnon) - val lhsIsWildcard = isWildcardL.value - val rhsIsWildcard = isWildcardR.value + import IntParam.max + // see the commutative given: a literal wildcard facing a bit-accurate operand is + // just a bit-accurate constant, and facing another wildcard it stays an `Int` + val lhsIsWildcard = + isWildcardL.value && + (isWildcardR.value || !knownWidths.value || + wildcardFits(lhsVal.asValOf[DFInt32], rhsVal.dfType)) + val rhsIsWildcard = + isWildcardR.value && + (isWildcardL.value || !knownWidths.value || + wildcardFits(rhsVal.asValOf[DFInt32], lhsVal.dfType)) if (lhsIsWildcard && !rhsIsWildcard) - // LHS is wildcard, RHS is concrete: adapt LHS to RHS type, keep operand order + // LHS is an adapting wildcard, RHS is concrete: adapt LHS to RHS type, keep + // operand order checkWildcardFit(lhsVal.asValOf[DFInt32], rhsVal.dfType) val lhsAdj = lhsVal.toDFXIntOf(rhsVal.dfType)(using dfcAnon) DFVal.Func(rhsVal.dfType, op.value, List(lhsAdj, rhsVal)).asInstanceOf[Out] + else if (isWildcardL.value && !rhsIsWildcard) + // LHS is a literal wildcard: both operands align at the common type, so the + // literal keeps its own width when it is the wider one and adopts the RHS's + // otherwise. The LHS-dominance rule holds by construction, so `check` (which + // would demand the RHS fit the literal's own width) does not apply. + // the sign fix comes first, so an unsigned operand meeting a signed one + // contributes the sign bit it gains to the common width + val lhsSFix = + if (!lhsVal.dfType.signed && rhsVal.dfType.signed) + lhsVal.asValOf[DFUInt[Int]].signed(using dfcAnon).asValOf[DFSInt[Int]] + else lhsVal.asValOf[DFSInt[Int]] + val rhsSFix = + if (!rhsVal.dfType.signed && lhsVal.dfType.signed) + rhsVal.asValOf[DFUInt[Int]].signed(using dfcAnon).asValOf[DFSInt[Int]] + else rhsVal.asValOf[DFSInt[Int]] + val commonType = DFXInt( + lhsSFix.dfType.signed, + lhsSFix.widthIntParam.max(rhsSFix.widthIntParam), + BitAccurate + ) + val lhsFix = lhsSFix.toDFXIntOf(commonType)(using dfcAnon) + val rhsFix = rhsSFix.toDFXIntOf(commonType)(using dfcAnon) + arithOp(commonType, op.value, lhsFix, rhsFix).asInstanceOf[Out] else // Both concrete, both wildcards, or only RHS is wildcard: LHS-dominant check(lhsVal, rhsVal) + // Subtraction is LHS-dominant, so an RHS the LHS cannot hold silently drops + // the difference's high bits. `check` decides that on RESOLVED widths only; + // a parametric relation must hold for EVERY valid assignment, so it is + // decided by proof here and an undecidable one is rejected (the carry form + // `-^`, or a `.resize`, states the intent instead). + if (op.value == FuncOp.- && !lhsVal.dfType.asIR.isDFInt32) + import dfc.getSet + import IntParam.+ + val lhsIR = lhsVal.dfType.asIR + val rhsIR = rhsVal.dfType.asIR + if (lhsIR.widthIntOpt.isEmpty || rhsIR.widthIntOpt.isEmpty) + // an unsigned RHS gains the sign bit it needs under a signed LHS + val rhsEffWidthRef = + if (lhsIR.signed && !rhsIR.signed) (rhsVal.widthIntParam + 1).ref + else rhsVal.widthIntParam.ref + if (!lhsVal.widthIntParam.ref.widthFitGE(rhsEffWidthRef).getOrElse(false)) + throw new IllegalArgumentException( + s"""|The RHS value width (${rhsIR.magnitudeWidthParamRef.refErrorString}) is not provably within the LHS variable width (${lhsIR.magnitudeWidthParamRef.refErrorString}). + |Subtraction takes the LHS width, so the difference may not fit. + |Consider applying the carry subtraction `-^` or `.resize` to resolve this issue.""".stripMargin + ) + end if arithOp(lhsVal.dfType, op.value, lhsVal, rhsVal).asInstanceOf[Out] + end if }(using dfc, CTName(op.value.toString)) end evOpNonCommutativeArithDFXInt diff --git a/core/src/test/scala/CoreSpec/DFDecimalSpec.scala b/core/src/test/scala/CoreSpec/DFDecimalSpec.scala index e22b22a06..8381a53bd 100644 --- a/core/src/test/scala/CoreSpec/DFDecimalSpec.scala +++ b/core/src/test/scala/CoreSpec/DFDecimalSpec.scala @@ -753,33 +753,34 @@ class DFDecimalSpec extends DFSpec: val cmp11 = s8 < i42 val cmp12 = i42 < s8 - // Compile-time errors for literal value-fit checking - assertCompileError( - "The wildcard `Int` value width (10) is larger than the bit-accurate value width (8)." - )("""u8 + 1000""") - assertCompileError( - "Cannot apply a signed wildcard `Int` value to an unsigned bit-accurate value.\nUse an explicit conversion or `sd\"\"` interpolation." - )("""u8 + (-1)""") - assertCompileError( - "The wildcard `Int` value width (11) is larger than the bit-accurate value width (8)." - )("""s8 + 1000""") - // Non-commutative: literal wildcard LHS that doesn't fit - assertCompileError( - "The wildcard `Int` value width (10) is larger than the bit-accurate value width (8)." - )("""1000 - u8""") - assertCompileError( - "Cannot apply a signed wildcard `Int` value to an unsigned bit-accurate value.\nUse an explicit conversion or `sd\"\"` interpolation." - )("""(-1) - u8""") - // Unsigned wildcard adapting to signed bit-accurate value needs extra bit - assertCompileError( - "The wildcard `Int` value width (9) is larger than the bit-accurate value width (8)." - )("""255 + s8""") - assertCompileError( - "The wildcard `Int` value width (9) is larger than the bit-accurate value width (8)." - )("""s8 + 255""") - assertCompileError( - "The wildcard `Int` value width (9) is larger than the bit-accurate value width (8)." - )("""255 - s8""") + // === A Scala `Int` LITERAL never has to fit the other operand === + // Its candidate builds a bit-accurate constant at the value's minimal width, so it + // takes part in the width and sign calculation like any bit-accurate operand: the + // wider of the two wins, and an unsigned operand meeting a signed one gains its sign + // bit. A literal therefore widens the result instead of being rejected. + //format: off + val w1 = u8 + 1000; w1.verifyValOf[UInt[10]] + val w2 = u8 + (-1); w2.verifyValOf[SInt[9]] + val w3 = s8 + 1000; w3.verifyValOf[SInt[11]] + val w4 = 255 + s8; w4.verifyValOf[SInt[9]] + val w5 = s8 + 255; w5.verifyValOf[SInt[9]] + // Non-commutative with a literal LHS: both operands align at the common type, so the + // LHS-dominance rule these operations impose holds by construction + val w6 = 1000 - u8; w6.verifyValOf[UInt[10]] + val w7 = 255 - s8; w7.verifyValOf[SInt[9]] + val w8 = (-5) + u8; w8.verifyValOf[SInt[9]] + //format: on + // ... but LHS-dominance still holds: a literal too wide to adapt IS the wider operand, + // while a negative literal meeting an unsigned operand is NOT (the RHS gains a sign + // bit), and widening the operation would silently rewrite its width + assertDSLErrorLog( + "Wildcard `Int` value is negative and cannot adapt to an unsigned bit-accurate value." + )("""(-1) - u8""") { + // the same mistake through a value whose width is not statically known: it adapts, + // and the adaptation is checked at elaboration + val negOne = -1 + negOne - u8 + } // Elaboration-time errors for non-literal value-fit checking assertDSLErrorLog( @@ -1033,6 +1034,19 @@ class DFDecimalSpec extends DFSpec: u9 := q } } + test("Int literal widening feeds target-context widening") { + val u3 = UInt(3) <> VAR + val s16 = SInt(16) <> VAR + assertCodeString { + """|s16 := sd"16'180" - (sd"16'5" * u3.signed.eby(12)) + |""".stripMargin + } { + // an `Int` literal too wide for the other operand widens the operation instead of + // being rejected (issue #469), and the whole anonymous cone then re-evaluates at the + // wider target, exactly like the Verilog line it translates (issue #119) + s16 := 180 - 5 * u3 + } + } test("Arithmetic target-context widening through shifts and negation") { val u8 = UInt(8) <> VAR val s8 = SInt(8) <> VAR @@ -1297,30 +1311,24 @@ class DFDecimalSpec extends DFSpec: } test("Error positions") { + // an `Int` literal widens the chain instead of having to fit it (see "Wildcard Int + // operands"), so the width conflict these report is the one at the ASSIGNMENT, whose + // message names both widths and the conversion that resolves them val cnt = Bits[8] <> VAR val err1 = compiletime.testing.typeCheckErrors("cnt := cnt + 10000").last val err2 = compiletime.testing.typeCheckErrors("cnt := cnt + (cnt + 10000)").last val err3 = compiletime.testing.typeCheckErrors("cnt := cnt + 10000 + cnt").last val err4 = compiletime.testing.typeCheckErrors("val x: Bits[8] <> VAL = cnt + 10000").last - assertEquals( - err1.message, - "The wildcard `Int` value width (14) is larger than the bit-accurate value width (8)." - ) + val widthErr = + """|The argument width (14) is different than the receiver width (8). + |Consider applying `.resize` to resolve this issue.""".stripMargin + assertEquals(err1.message, widthErr) assertEquals(err1.column, 7) - assertEquals( - err2.message, - "The wildcard `Int` value width (14) is larger than the bit-accurate value width (8)." - ) - assertEquals(err2.column, 14) - assertEquals( - err3.message, - "The wildcard `Int` value width (14) is larger than the bit-accurate value width (8)." - ) + assertEquals(err2.message, widthErr) + assertEquals(err2.column, 7) + assertEquals(err3.message, widthErr) assertEquals(err3.column, 7) - assertEquals( - err4.message, - "The wildcard `Int` value width (14) is larger than the bit-accurate value width (8)." - ) + assertEquals(err4.message, widthErr) assertEquals(err4.column, 24) } test("Runtime error positions") { diff --git a/docs/transitioning/from-verilog/index.md b/docs/transitioning/from-verilog/index.md index cfd8805b6..17d2aff83 100644 --- a/docs/transitioning/from-verilog/index.md +++ b/docs/transitioning/from-verilog/index.md @@ -915,12 +915,22 @@ val sign = prod(15) // single-bit access (Bit) To recover signed semantics on a slice, chain `.bits.sint` (re-interpret the bits as signed, same width). Do **not** use `.signed` for this: `.signed` is a numeric conversion that widens by one zero-extension bit, which is not what slice migration wants. /// +/// admonition | Rule of thumb: transcribe, then follow the diagnostics + type: tip +You do not have to apply the width and sign rules by hand while translating. Write each arithmetic or logic expression exactly as the Verilog has it, in DFHDL syntax, and let the compiler review it: + +- Where DFHDL can reproduce Verilog's context-dependent width propagation, it does so silently. An anonymous `+`, `-`, `*`, unary `-`, `.sel`, `if`/`match` expression or shift feeding a wider target re-evaluates at that target's width, which is what the Verilog line does, so the transcription stands as written. +- Where the two would disagree, DFHDL declines to guess. It reports a **compile-time or elaboration error** for a width or sign relation it cannot express (a narrower LHS under `-`, `/`, `%`, or a comparison between operands of different widths), and an **elaboration warning** where an implicit `Int` would have evaluated 32-bit in Verilog but is bit-accurate here. + +So the loop is: transcribe, compile, apply whatever the diagnostic names (a carry operation, an explicit `d"W'V"` literal, or a `.resize`), and repeat until it is quiet. An expression that compiles and elaborates without warnings carries the original's semantics. +/// + /// admonition | Arithmetic with Signed Values and Constants type: verilog **Arithmetic operand compatibility:** DFHDL enforces sign and width constraints at compile time. **Commutative operations** (`+`, `*`, `max`, `min`) produce the widest, most signed result, and operand order does not matter. **Non-commutative operations** (`-`, `/`, `%`) require the LHS to be at least as wide and signed as the RHS. When mixing signed and unsigned, the unsigned operand is implicitly sign-extended by 1 bit. -Both Scala `Int` values and DFHDL `Int` parameters act as [wildcards][wildcard-ops]: the wildcard `Int` value adapts to the bit-accurate value's sign and width. If the wildcard `Int` value does not fit, an error is generated. +Both Scala `Int` values and DFHDL `Int` parameters act as [wildcards][wildcard-ops]: the wildcard `Int` value adapts to the bit-accurate value's sign and width, and an error is generated if it does not fit. In `+`, `-` and `*` a Scala `Int` **literal** is instead taken at its own minimum width whenever the other operand's width is known at compile time as well, so the result is simply the wider of the two. ```scala // Commutative: result is widest, most signed @@ -936,14 +946,15 @@ sd"8'5" + (-3) // SInt[8] (-3 adapts to SInt[8]) val param: Int <> CONST = 10 d"8'5" + param // UInt[8] (param adapts to UInt[8]) sd"8'5" + param // SInt[8] (param adapts to SInt[8]) -d"8'5" + 1000 // ERROR: 1000 exceeds UInt[8] range -d"8'5" + (-1) // ERROR: -1 is negative for UInt bit-accurate value +d"8'5" + 1000 // UInt[10] (1000's minimum width of 10 is the wider one) // Non-commutative: LHS-dominant, LHS must be >= RHS d"8'5" - d"4'3" // UInt[8] sd"8'5" - d"4'3" // SInt[8] (RHS widened to 5 bits, 8 >= 5) // d"4'5" - d"8'3" // ERROR: RHS width > LHS width // d"8'5" - sd"4'3" // ERROR: unsigned LHS, signed RHS +// (-1) - d"8'5" // ERROR: a negative literal cannot adapt to an unsigned operand, +// // and LHS-dominance leaves no room to widen it ``` **Comparison operand compatibility:** diff --git a/docs/user-guide/compilation/index.md b/docs/user-guide/compilation/index.md index ae28a06a8..e1fb7e119 100755 --- a/docs/user-guide/compilation/index.md +++ b/docs/user-guide/compilation/index.md @@ -9,9 +9,9 @@ When [arithmetic operations][arithmetic-ops] involve wildcard `Int` values (Scala `Int` or DFHDL `Int` parameters), the wildcard `Int` value adapts to the bit-accurate value's sign and width. The value is then checked to ensure it fits. This check occurs at three levels, depending on when the value becomes known: -1. **Scala compile-time**: literal Scala integers (e.g., `u8 + 1000`) have known values at compile time. The Scala compiler reports an error immediately if the wildcard `Int` value exceeds the bit-accurate value's range or has incompatible sign. +1. **Scala compile-time**: literal Scala integers have known values, and therefore known minimum widths, at compile time. In `+`, `-` and `*` that minimum counts as an actual width when the other operand's width is also known at compile time, so the result is the wider of the two and a literal that does not fit widens the operation rather than failing (`u8 + 1000` is `UInt[10]`). The Scala compiler still reports an error where widening cannot express the result, namely a negative literal on the left of `-`, `/` or `%` with an unsigned operand, which those LHS-dominant operations cannot represent. Elsewhere the literal adapts, and the compiler reports a value that does not fit. -2. **DFHDL elaboration-time**: non-literal Scala integers (e.g., `val x: Int = computeValue(); u8 + x`) and DFHDL `Int` constants whose values are resolved during elaboration. A DFHDL elaboration error (Scala runtime error) is generated if the value does not fit. +2. **DFHDL elaboration-time**: non-literal Scala integers (e.g., `val x: Int = computeValue(); u8 + x`) and DFHDL `Int` constants whose values are resolved during elaboration. These have no statically known width, so they always adapt, as does any literal meeting a parametric width. A DFHDL elaboration error (Scala runtime error) is generated if the value does not fit. 3. **Synthesis/simulation-time**: DFHDL `Int` parameters that are set externally or computed in complex generation loops may not be known until synthesis or simulation. Assertions must be added to verify these values at the target platform level. This is a planned future feature (TODO). diff --git a/docs/user-guide/type-system/index.md b/docs/user-guide/type-system/index.md index 499e91abf..ea1732c17 100755 --- a/docs/user-guide/type-system/index.md +++ b/docs/user-guide/type-system/index.md @@ -2387,7 +2387,11 @@ s8 := -u8 ### Wildcard `Int` Values {#wildcard-ops} -Both Scala `Int` values and DFHDL `Int` parameters (`Int <> CONST`) act as **wildcards** when used in operations with bit-accurate `UInt` or `SInt` values. The wildcard `Int` value adapts to the bit-accurate value's sign and width. If the wildcard `Int` value does not fit in the bit-accurate value's range or has incompatible sign, an error is generated. One exception: in [carry operations][carry-ops] a Scala `Int` operand contributes its value's minimal width instead of adapting, while a DFHDL `Int` parameter adapts as usual. +Both Scala `Int` values and DFHDL `Int` parameters (`Int <> CONST`) act as **wildcards** when used in operations with bit-accurate `UInt` or `SInt` values. A wildcard has no exact width of its own, only a **minimum** one, and a natural (non-negative) wildcard has no sign of its own either: it can be taken as unsigned, or as signed at the cost of one more bit. Only a negative wildcard is inherently signed. It therefore adapts to the bit-accurate value's sign and width, and that absence of a fixed width and sign is the whole difference from a constant of the same value: `u8 == 0` compares fine while `u8 == d"1'0"` is a width mismatch, and `5 - s8` is `SInt[8]` while `d"5" - s8` is a sign mismatch. + +The two kinds of wildcard differ in how much is known about that minimum. A Scala `Int` always has one, from the literal at compile time or from the value at elaboration. A DFHDL `Int` parameter may have none at elaboration, and an overridable one has none for any manifestation, so it can only ever adapt. + +For `+`, `-` and `*`, a Scala `Int` **literal**'s minimum width counts as an actual width when the result width is computed, so the result is simply the wider of the two operands. A literal that fits is unchanged by this (the bit-accurate operand is the wider one), and a literal that does not fit widens the operation instead of being an error: `u8 + 1000` is `UInt[10]`. It takes the other operand's width at compile time too, so against a parametric width, or for an `Int` whose value is not a literal, the wildcard adapts and must fit as before. In [carry operations][carry-ops] a Scala `Int` operand always contributes its minimum width. ```scala val u8 = UInt(8) <> VAR @@ -2408,10 +2412,24 @@ u8 / param // UInt[8] (param adapts to UInt[8]) u8 == 200 // OK (200 fits in UInt[8]) s8 < (-5) // OK (-5 fits in SInt[8]) -// ERROR: wildcard `Int` value does not fit bit-accurate value -u8 + 1000 // ERROR: 1000 exceeds UInt[8] range (0..255) -u8 + (-1) // ERROR: -1 is negative for unsigned bit-accurate value -s8 + 1000 // ERROR: 1000 exceeds SInt[8] range (-128..127) +// A runtime Scala `Int` has a minimum width too, but only at elaboration, so it +// always adapts and must fit, exactly like a DFHDL `Int` parameter +val rt = List(1, 2, 3).sum // a Scala `Int`, but not a literal +u8 + rt // UInt[8] (adapts; a value over 255 is an elaboration error) + +// In +, - and * a literal's MINIMUM width counts as an actual width, so the +// result is the wider of the two operands +u8 + 1000 // UInt[10] (1000's minimum is 10 bits, the wider of the two) +u8 + (-1) // SInt[9] (-1 is inherently signed, so u8 gains a sign bit) +s8 + 1000 // SInt[11] (1000 taken as signed needs one bit more, 11) +1000 - u8 // UInt[10] (the literal is the wider operand, so LHS-dominance holds) + +// ... but only when the other width is known at compile time as well +val wp = UInt(param) <> VAR +wp + 1000 // ERROR: nothing to compare against, so 1000 must adapt to UInt[param] + +// ERROR: `-`, `/` and `%` take the LHS width, which a negative literal cannot supply +(-1) - u8 // ERROR: -1 is negative and cannot adapt to an unsigned value ``` See [Wildcard Arithmetic Value Checking][wildcard-check] for details on when these checks occur (compile-time, elaboration-time, or synthesis-time). @@ -2447,7 +2465,7 @@ val r3 = u4 + u8 // UInt[8] (commutative, same as above) val r4 = s8 + u4 // SInt[8] (max(8, 4+1) = 8, signed) val r5 = u8 + s8 // SInt[9] (max(8+1, 8) = 9, signed) val r6 = u8 + 200 // UInt[8] (literal adapts) -val r7 = (-5) + u8 // SInt[8] (negative literal, signed result) +val r7 = (-5) + u8 // SInt[9] (-5 is signed, so u8 gains a sign bit) // Non-commutative: LHS-dominant val r8 = 200 - u8 // UInt[8] From 3548169a7fdcf282a76a4f212d4c3f7dbb2f372e Mon Sep 17 00:00:00 2001 From: Oron Port Date: Mon, 10 Aug 2026 02:23:04 +0300 Subject: [PATCH 03/40] core: revise a sub-design instance's port through its by-name selection (#470) A port of a sub-design instance is not a member of the instantiating design: `inst.port` hands back the child's `Dcl`, while the parent only ever holds a `PortByNameSelect` representative, minted lazily by `refTW` at reference time. `MutableDB.setMember` looks its original member up in the current design context, so every front-end op that revises a member in place crashed with a raw `NoSuchElementException` on such a port. The argument-less `.resize` is one of those: it marks its operand with `ir.ResizeTag`. `refTW`'s foreign-port handling is split into a pure `isForeignPort` predicate and a member-planting `foreignPortSelectOpt`, and the two revising ops now answer separately, by kind: - a tag describes the *use* of the port, so it lands on the local representative (`DFVal.revisableHere`), making `c.o.resize` produce exactly what `c.o.resize(16)` does; - a name is a property of the *declaration*, so there is nothing local to put it on and `setName` reports an elaboration error naming the design that does get to set it. Its `@metaContextForward(0)` is dropped, since the plugin never stamps applies of forwarding symbols and the error would otherwise carry no position. Both redirects are skipped during meta-programming, where `setMember` revises without touching a design context. Co-Authored-By: Claude Opus 5 (1M context) --- .claude/commands/bugfix.md | 44 +++++++++++++ .../StagesSpec/PrintCodeStringSpec.scala | 42 +++++++++++++ core/src/main/scala/dfhdl/core/DFRef.scala | 63 +++++++++++++------ core/src/main/scala/dfhdl/core/DFVal.scala | 29 ++++++++- .../test/scala/ElaborationChecksSpec.scala | 26 ++++++++ 5 files changed, 183 insertions(+), 21 deletions(-) diff --git a/.claude/commands/bugfix.md b/.claude/commands/bugfix.md index 52981734b..5b1fcf069 100644 --- a/.claude/commands/bugfix.md +++ b/.claude/commands/bugfix.md @@ -256,6 +256,50 @@ the `sel` body). Both cost a full suite cycle to find, and neither is visible in the file being edited. +### An in-place member revision only works in the design context that holds the member + +`MutableDB.setMember` looks its original member up in `DesignContext.current`, so every +front-end op that revises a member in place (`tag`, `setName`, anything reaching `setMeta` / +`setTags`) silently assumes the member lives in the design being elaborated. A +`java.util.NoSuchElementException: key not found: Dcl(...)` out of `DesignContext.setMember` is +that assumption failing, and the `Meta` inside the printed key names the *other* design's +source line while the frames below it are the current one — that mismatch is the whole +diagnosis (issue #470). + +The archetype is a **sub-design instance's port**. `inst.port` hands back the child's `Dcl` +itself; the parent's own representative of that port is a `PortByNameSelect`, and `refTW` mints +one lazily, at **reference** time. So every op that *references* the port works and every op +that *revises* it before referencing crashes. `.resize(N)` versus argument-less `.resize` is +exactly this split: the first builds an alias (a reference), the second marks its operand with +`ir.ResizeTag` (a revision). + +Three things generalize: + +- **Route revisions through the same representative the reference path materializes**, rather + than teaching `setMember` about foreign members. Factoring that out of `refTW` also removes a + duplicated predicate; split it in two, though: a **pure** `isForeignPort` and a + member-**planting** `foreignPortSelectOpt`. Testing the condition with the planting one adds + an IR member as a side effect of asking a question. +- **Not every revision can be redirected, and the kind decides.** A tag describes the *use* of + the port and belongs on the local representative, which is also why redirecting it is a fix + and not a workaround. A name is a property of the *declaration*, so there is nothing local to + put it on and the honest answer is an elaboration error naming the design that does get to + set it. Deciding this per revision kind is the design step; a uniform answer is wrong in one + direction or the other. +- **Guard the redirect with `!dfc.inMetaProgramming`.** There `MutableDB.setMember` revises + without touching any design context, so foreign members are already handled, and planting a + representative would hand a stage a member it never asked for. + +One trap when adding the error: **`@metaContextForward(n)` costs you the position.** +`MetaContextGenPhase.transformApply` skips applies of symbols carrying it (`!fixedApply.fun +.symbol.forwardMetaContext`), so no meta context is stamped and a `DFError.Basic` raised inside +reports `Position: :0:0 - 0:0`. Its purpose is naming (`nameValOrDef` descends into the +forwarded argument instead of stopping at the call), so an op that both forwards naming and +reports errors cannot have both. Dropping the annotation from `setName` restored exact spans in +all three call shapes (nested operand, standalone statement, `val` RHS) and changed no name: +the forwarded argument's name is overwritten by `setName`'s own argument anyway. Check the +naming-sensitive suites before assuming that holds for another op. + ### Changing a type-level algebra: pick the mechanism by when it costs `IntP` decides widths at the type level, and there are three mechanisms for such a rule. They diff --git a/compiler/stages/src/test/scala/StagesSpec/PrintCodeStringSpec.scala b/compiler/stages/src/test/scala/StagesSpec/PrintCodeStringSpec.scala index 48c246289..629695948 100644 --- a/compiler/stages/src/test/scala/StagesSpec/PrintCodeStringSpec.scala +++ b/compiler/stages/src/test/scala/StagesSpec/PrintCodeStringSpec.scala @@ -3151,4 +3151,46 @@ class PrintCodeStringSpec extends StageSpec(stageCreatesUnrefAnons = true): |""".stripMargin ) } + // A port of a sub-design instance is not a member of the instantiating design. That design + // represents the port by a `PortByNameSelect`, which is what a reference to it materializes, + // so an in-place member revision applied to the port from here has nowhere to land on the + // foreign declaration and must target the representative instead. + // The argument-less `.resize` is such a revision: it marks its operand with a tag that the + // connection's width then resolves. This pins that the mark lands locally, giving the same + // result as the explicit-width `.resize(16)` form. + // See https://github.com/DFiantHDL/DFHDL/issues/470 + test("Argument-less resize of a sub-design instance's output port") { + class SubDsn extends EDDesign: + val WIDTH: Int <> CONST = 24 + val ob = Bits(WIDTH) <> OUT + val ou = UInt(WIDTH) <> OUT + ob <> all(0) + ou <> 0 + class Top extends EDDesign: + val pb = Bits(16) <> OUT + val pu = UInt(16) <> OUT + val c = SubDsn() + pb <> c.ob.resize + pu <> c.ou.resize + assertCodeString( + Top(), + """|class SubDsn extends EDDesign: + | val WIDTH: Int <> CONST = 24 + | val ob = Bits(WIDTH) <> OUT + | val ou = UInt(WIDTH) <> OUT + | ob <> b"0".repeat(WIDTH) + | ou <> d"1'0".resize(WIDTH) + |end SubDsn + | + |class Top extends EDDesign: + | val pb = Bits(16) <> OUT + | val pu = UInt(16) <> OUT + | val c = SubDsn() + | val c_WIDTH: Int <> CONST = 24 + | pb <> c.ob.resize(16) + | pu <> c.ou.resize(16) + |end Top + |""".stripMargin + ) + } end PrintCodeStringSpec diff --git a/core/src/main/scala/dfhdl/core/DFRef.scala b/core/src/main/scala/dfhdl/core/DFRef.scala index 3b624c3df..d670e1876 100644 --- a/core/src/main/scala/dfhdl/core/DFRef.scala +++ b/core/src/main/scala/dfhdl/core/DFRef.scala @@ -33,6 +33,46 @@ extension [M <: ir.DFMember](member: M) end match end getReachableMember + // True if the member is a port declaration of a design other than the one currently being + // elaborated, e.g. a sub-design instance's port accessed as `inst.port` from the parent. + // Such a port has no member of its own in the current design context: this design + // represents it by a `PortByNameSelect` (see `foreignPortSelectOpt`), so it can be + // referenced from here but never revised in place, since `MutableDB.setMember` looks the + // original member up in the current design context and would not find it. + private[core] def isForeignPort(using dfc: DFC): Boolean = + import dfc.getSet + member match + // in meta-programming we can end up with a modified copy of the design that should + // not be treated as a different design (for example, the stage `ToED`). + // for this reason we only compare the owner references which are guaranteed to be + // different for different design, but not for a copy made during meta-programming step. + case port @ DclPort() => + port.getOwnerDesign.ownerRef != dfc.owner.asIR.getThisOrOwnerDesign.ownerRef + case _ => false + + // The current design's `PortByNameSelect` representative of a foreign port (see + // `isForeignPort`), planted as a member here. That representative is what a reference to + // the port materializes (see `refTW`) and what a tag applied to the port from here lands + // on (see `DFVal.revisableHere`). + // Returns `None` for any member the current design context holds directly. + private[core] def foreignPortSelectOpt(using dfc: DFC): Option[ir.DFVal.PortByNameSelect] = + import dfc.getSet + member match + case port: ir.DFVal.Dcl if port.isForeignPort => + // name path accounts for domains within the design that can contain the port + val namePath = port.getRelativeName(port.getOwnerDesign) + Some( + DFVal.PortByNameSelect( + port.dfType, + port.modifier.dir, + port.getOwnerDesign.getCachedDesignInst, + namePath + ) + ) + case _ => None + end match + end foreignPortSelectOpt + def ref(using DFC): ir.DFRef.OneWay[M] = val newRef = dfc.refGen.genOneWay[M] dfc.mutableDB.newRefFor(newRef, member) @@ -42,27 +82,14 @@ extension [M <: ir.DFMember](member: M) import dfc.getSet injectGlobalCtx() val reachableMember = if (knownReachable) member else member.getReachableMember - reachableMember match - // referencing a port from another design causes by-name referencing. - // in meta-programming we can end up with a modified copy of the design that should - // not be treated as a different design (for example, the stage `ToED`). - // for this reason we only compare the owner references which are guaranteed to be - // different for different design, but not for a copy made during meta-programming step. - case port @ DclPort() - if port.getOwnerDesign.ownerRef != dfc.owner.asIR.getThisOrOwnerDesign.ownerRef => - // name path accounts for domains within the design that can contain the port - val namePath = port.getRelativeName(port.getOwnerDesign) - val portSelect = DFVal.PortByNameSelect( - port.dfType, - port.modifier.dir, - port.getOwnerDesign.getCachedDesignInst, - namePath - ) + reachableMember.foreignPortSelectOpt match + // referencing a port from another design causes by-name referencing + case Some(portSelect) => portSelect.refTW[O].asInstanceOf[ir.DFRef.TwoWay[M, O]] // any other kind of reference - case member => + case None => val newRef = dfc.refGen.genTwoWay[M, O] - dfc.mutableDB.newRefFor(newRef, member) + dfc.mutableDB.newRefFor(newRef, reachableMember) end match end refTW end extension diff --git a/core/src/main/scala/dfhdl/core/DFVal.scala b/core/src/main/scala/dfhdl/core/DFVal.scala index 603023cc3..295c425bb 100644 --- a/core/src/main/scala/dfhdl/core/DFVal.scala +++ b/core/src/main/scala/dfhdl/core/DFVal.scala @@ -433,12 +433,28 @@ object DFVal extends DFValLP: ] extension [T <: DFTypeAny, M <: ModifierAny](dfVal: DFVal[T, M]) + // The member a tag may be applied to from here. A port of another design (a sub-design + // instance's port, accessed as `inst.port`) is not a member of the current design + // context, and `MutableDB.setMember` looks the original member up there, so tagging the + // foreign declaration is not possible from this design. The current design's + // representative of that port is a `PortByNameSelect`, which is also what a reference to + // the port materializes, so the tag lands there instead. It then stays local, as it must: + // a marker like `ir.ResizeTag` describes this use of the port, not the port itself. + // Not applied during meta-programming: there `MutableDB.setMember` revises the member + // without touching any design context, so a foreign port is already handled and + // materializing a selection for it would plant a member a stage never asked for. + private def revisableHere(using dfc: DFC): DFVal[T, M] = + if (dfc.inMetaProgramming) dfVal + else + dfVal.asIR.foreignPortSelectOpt match + case Some(portSelect) => portSelect.asVal[T, M] + case None => dfVal @metaContextForward(0) infix def tag[CT <: ir.DFTag: ClassTag](customTag: CT)(using dfc: DFC ): DFVal[T, M] = import dfc.getSet - dfVal.asIR + dfVal.revisableHere.asIR .setTags(_.tag(customTag)) .setMeta(m => if (m.isAnonymous && !dfc.getMeta.isAnonymous) dfc.getMeta else m) .asVal[T, M] @@ -449,15 +465,22 @@ object DFVal extends DFValLP: def hasTag[CT <: ir.DFTag: ClassTag](using dfc: DFC): Boolean = import dfc.getSet dfVal.asIR.tags.hasTagOf[CT] - @metaContextForward(0) - infix def setName(name: String)(using dfc: DFC): DFVal[T, M] = + infix def setName(name: String)(using dfc: DFC): DFVal[T, M] = trydf { import dfc.getSet + // Unlike a tag, a name is a property of the declaration itself, so there is nothing + // local to redirect it to: renaming a port of another design would have to revise that + // design's declaration, which this design context cannot reach (see `isForeignPort`). + if (!dfc.inMetaProgramming && dfVal.asIR.isForeignPort) + throw new IllegalArgumentException( + "Cannot set a name for a port of an internal design.\nThe name of a port is set by the design that declares it." + ) dfVal.asIR .setMeta(m => if (m.isAnonymous && !dfc.getMeta.isAnonymous) dfc.getMeta.setName(name) else m.setName(name) ) .asVal[T, M] + }(using dfc, CTName("setName")) def anonymize(using dfc: DFC): DFVal[T, M] = import dfc.getSet dfVal.asIR match diff --git a/lib/src/test/scala/ElaborationChecksSpec.scala b/lib/src/test/scala/ElaborationChecksSpec.scala index aa2d4fa6b..ca60f3a2d 100644 --- a/lib/src/test/scala/ElaborationChecksSpec.scala +++ b/lib/src/test/scala/ElaborationChecksSpec.scala @@ -1585,4 +1585,30 @@ class ElaborationChecksSpec extends DesignSpec: |Message: The applied RHS value width (2 * W) is larger than the LHS variable width (W).""".stripMargin ) + // A port of a sub-design instance is not a member of the instantiating design, which only + // represents it by a port-by-name selection. A tag applied to the port from here can land on + // that representative, but a name cannot: a name belongs to the declaration, and the + // declaring design is the one that gets to set it. + test("naming a sub-design instance's port"): + object Test: + class Child extends EDDesign: + val o = Bits(8) <> OUT + o <> all(0) + end Child + @top(false) class Top extends EDDesign: + val p = Bits(8) <> OUT + val c = Child() + p <> c.o.setName("kernel") + end Top + import Test.* + assertElaborationErrors(Top())( + s"""|Elaboration errors found! + |DFiant HDL elaboration error! + |Position: ${currentFilePos}ElaborationChecksSpec.scala:1601:14 - 1601:35 + |Hierarchy: Top + |Operation: `setName` + |Message: Cannot set a name for a port of an internal design. + |The name of a port is set by the design that declares it.""".stripMargin + ) + end ElaborationChecksSpec From 3b844e72c4d0553f3f4fc628db686c8627f01280 Mon Sep 17 00:00:00 2001 From: Oron Port Date: Mon, 10 Aug 2026 03:49:36 +0300 Subject: [PATCH 04/40] ir+lib+compiler_stages: connection flow never defers to parameters (#467, #471) Fixes #471 and #467: a bit-select with a parameter-dependent index (`v(N - 1)`) made unrelated connections fail as "Unsupported read-to-read connection", on lines far from the select that caused it. `departial` only folded a LITERAL index, so a parametric one took the runtime-index fallback and recorded a write covering the WHOLE value. That falsely-proven overlap made the next connection on the value classify as a read, which flipped its direction and recorded the wrong sink, and the corruption then cascaded into nets that never touched a parameter. The analysis answers two kinds of question that have opposite tolerances for an unresolved parameter, and it was serving both from one `contains` query. They are now separated: - Directionality is structural and must hold for every parameter assignment. `departial` composes any elaboration-fixed index into the slice (literal -> concrete, design-parameter expression -> symbolic); an iterator, a static function's formal and a runtime value keep the whole-value fallback. The `VAR` rule reads a value as already-driven only on a PROVEN overlap (`hasProvenNet`), so opacity leaves the flow undecided instead of guessing. - Legality may be deferred to wherever the parameters resolve. A collision is decided by symbolic proof; failing that, by folding both bit ranges at the elaborated parameter values (`DataCalc.foldConst`, root defaults included); failing that, the check is skipped. This retires the "cannot be proven disjoint" error, whose rejection was the guess this replaces. - `getConnToMap`'s pending passes are progress-guarded, since proven-only classification makes repeat-undecidable nets possible; a pass that settles nothing is followed by one pass under the old conservative reading as the tiebreak, which keeps every previously-accepted shape accepted. A second driver on a variable also arrives as a read-to-read pairing (the first write makes the variable read as a source), so a genuine double-drive reported the unactionable "read-to-read" even with literal indices. It now names the variable and the earlier write. Verified with verilator and ghdl on the emitted output: the index survives as `v[N - 1]` / `v(N - 1)`, so the design stays parametric. Co-Authored-By: Claude Fable 5 --- .claude/commands/bugfix.md | 57 ++++- .../dfhdl/compiler/ir/ConnectToMap.scala | 52 ++++- .../src/main/scala/dfhdl/compiler/ir/DB.scala | 113 +++++++--- .../scala/dfhdl/compiler/ir/DFMember.scala | 28 ++- .../scala/dfhdl/compiler/ir/IntExprCalc.scala | 18 ++ .../StagesSpec/PrintCodeStringSpec.scala | 55 +++++ .../test/scala/ElaborationChecksSpec.scala | 209 ++++++++++++------ 7 files changed, 414 insertions(+), 118 deletions(-) diff --git a/.claude/commands/bugfix.md b/.claude/commands/bugfix.md index 5b1fcf069..224e6fe05 100644 --- a/.claude/commands/bugfix.md +++ b/.claude/commands/bugfix.md @@ -816,16 +816,37 @@ generalizes: `DesignParam.instAppliedConstDataOpt` is the correct primitive: cached instance during elaboration, `designBlockInstMap` on flat DBs, `parentSubDBOpt` walk-up on hierarchical sub-DBs, and `None` exactly for the elaboration root. -- **The check re-runs where you don't expect.** `connectionTable` is forced again by the backend - printer on the *flat* DB, so a connectivity-analysis fix must resolve under every DB model; a - test that only elaborates is blind to the print-time re-run. Pin it with - `getCompiledCodeString` (`ElaborationChecksSpec`'s sub-design slice test is the model). +- **The check re-runs where you don't expect.** `connectionTable` is forced again after the + stages have run, so a connectivity-analysis fix must resolve under every DB model; a test that + only elaborates is blind to that re-run. Pin it in `PrintCodeStringSpec`, NOT with + `getCompiledCodeString` in `ElaborationChecksSpec` — see the rule in §6. - **`clearDFHDL` between probe re-runs after compiler edits.** The sub-design elaboration cache serves API-driven probes (`getCompiledCodeString`) too, not just DFApp runs; a cached child elaboration skips the very code you just changed and the probe "reproduces" stale behavior. - One departial-coordinate trap fixed alongside: a vector `ApplyRange`'s indices are in **cell** units and must be scaled by the cell width into bit coordinates; the old `shift(idxLow)` mixed units and falsely errored even fully-literal `o(0, 1)` / `o(2, 3)` vector range connections. +- **Separate what may be deferred from what may not, before choosing how conservative to be.** + One analysis usually answers two different kinds of question, and they have opposite tolerances + for an unresolved parameter. A **legality** verdict ("do these two writes collide?") may be + deferred: skip it and it re-runs wherever the parameters do resolve, e.g. when the design is + instantiated by a parent. A **structural** verdict ("which end of this connection is the sink?") + may not: it must hold for every parameter assignment, so an unproven relation must leave the + flow undecided rather than guess. Issues #467/#471 were one analysis serving both through a + single `contains` query, so parameter opacity silently flipped a connection's direction and the + corruption cascaded into unrelated nets. The fix is to give each consumer its own verdict from + the shared machinery (`hasProvenNet` for direction, `foldedOverlap` for legality), never to make + the shared query smarter. When a report shows errors on lines that are innocent, suspect this + shape: a *classification* was poisoned upstream, and the reported line is just where the poison + surfaced. +- **A conservative fallback needs a progress guard, or it never terminates.** Weakening a rule to + "only decide on proof" turns cases that used to resolve immediately into pending ones, and a + re-examination loop keyed on "is anything relevant in the map" then spins forever, because the + unproven relation keeps the endpoint in the map without ever settling it. Track whether a full + pass settled anything; when a pass settles nothing, run one pass under the OLD conservative rule + as the tiebreak, then fail. That fallback is what keeps every previously-accepted shape accepted + (a read of a bit whose only writer is parametric still resolves), so the change stays confined to + the shapes the bug affected. - **Symbolic elimination is a per-site semantic choice, not a smarter equivalence.** The width-fit checks accept `LHS >= RHS` after a mixed `max`/`min` drops its symbolic operands (`16 >= WIDTH max 16` decides as `16 >= 16`; `IntParamRef.compare(..., elimSymbolicMaxMin = @@ -857,6 +878,16 @@ scalafmt reflows the test design (a braces-on-one-line block becomes multi-line) shifts those positions. Write the design in the already-normalized indented form so reformatting does not move it, and re-check the positions after running scalafmt. +Any edit that changes the file's LINE COUNT shifts every expectation below it, so adding a test in +the middle breaks unrelated tests that were passing. Append new tests at the end of the file. When +a mid-file edit is unavoidable (rewriting an existing test), do not hand-patch the fallout: munit +prints each expected/obtained pair, so drive the rewrite off the run log — extract the +`-Position:`/`+Position:` pairs and apply them to the source in ONE simultaneous pass (a +sequential pass can rewrite a value that a later rule then matches). Two or three iterations +converge, since a test with several expected errors only reveals its next stale position after the +first is fixed. Do the substitution with a script that preserves the file's CRLF bytes, not +`sed -i`, which rewrites the whole file's line endings and produces phantom diffs. + --- ## 5. Fix the stage @@ -998,10 +1029,20 @@ rejects, a self-contained spec input is impossible by construction. Express the and let the stage's declared `dependencies` build the shape it consumes — that is what dependencies are for. Say so in a comment, since it deliberately departs from the self-contained-input rule. -**Code-string assertions beat lint.** `assertNoDiff(design.getCompiledCodeString, ...)` is -deterministic and needs no external tool. `.compile.lint` under `options.LinterOptions.WError` will -fail on warnings unrelated to your fix — an `abs`-style design trips `UNUSEDSIGNAL` on the high bit -of every intermediate that is only part-selected. +**Code-string assertions beat lint.** A printed-output assertion is deterministic and needs no +external tool. `.compile.lint` under `options.LinterOptions.WError` will fail on warnings unrelated +to your fix — an `abs`-style design trips `UNUSEDSIGNAL` on the high bit of every intermediate that +is only part-selected. + +**Reaching for `getCompiledCodeString` means the test is in the wrong file.** A code-string +regression belongs in the print specs, which own printed output and already select their backend +(`PrintCodeStringSpec` for the DFHDL code string, `PrintVerilogCodeSpec` / `PrintVHDLCodeSpec` for a +backend-specific rendering). `ElaborationChecksSpec` asserts what elaboration *accepts and +rejects*, so a test there ends at constructing the design; the moment it wants printed output, move +it. `StageSpec.assertCodeString` runs `sanityCheck`, hence `DB.subDBCheck`, hence `connectionTable`, +so a print-spec test re-derives the connectivity analysis for free — a compiled string is not needed +to cover the post-stage re-run. Two `ElaborationChecksSpec` tests were written the wrong way here +before the rule was clear; do not copy them as a model. **Do not copy the reporter's code into the repo.** Issue reports usually carry no license. Write a minimal design of your own that exercises the same path; if the shape is fully covered by stage diff --git a/compiler/ir/src/main/scala/dfhdl/compiler/ir/ConnectToMap.scala b/compiler/ir/src/main/scala/dfhdl/compiler/ir/ConnectToMap.scala index 4161459b6..4e5946d0d 100644 --- a/compiler/ir/src/main/scala/dfhdl/compiler/ir/ConnectToMap.scala +++ b/compiler/ir/src/main/scala/dfhdl/compiler/ir/ConnectToMap.scala @@ -16,19 +16,21 @@ object ConnectToMap: extension (ctm: ConnectToMap)(using MemberGetSet) def connectToVals: Set[ConnectToVal] = ctm.keySet - /** All nets whose slice overlaps `slice` on `connectToVal`, each with its overlap verdict: - * `Tri.Yes` for a proven overlap, `Tri.Unknown` when the relation could not be proven either - * way (conservatively included). Provably disjoint nets are excluded. + /** All nets whose slice overlaps `slice` on `connectToVal`, each with the slice it was stored + * under and its overlap verdict: `Tri.Yes` for a proven overlap, `Tri.Unknown` when the + * relation could not be proven either way (conservatively included). Provably disjoint nets + * are excluded. The stored slice is carried so a legality check can re-decide an `Unknown` + * verdict at the elaborated parameter values (see [[ConnectToMap.foldedOverlap]]). */ - def getNetsVerdicts(connectToVal: ConnectToVal, slice: Slice): Vector[(DFNet, Tri)] = + def getNetsVerdicts(connectToVal: ConnectToVal, slice: Slice): Vector[(DFNet, Slice, Tri)] = ctm.get(connectToVal) match case Some(entry) => val widthOpt = connectToVal.widthIntOpt entry.nets.view .map { (storedSlice, net) => - (net, ConnectToMap.overlapsSlices(storedSlice, slice, widthOpt)) + (net, storedSlice, ConnectToMap.overlapsSlices(storedSlice, slice, widthOpt)) } - .filter(_._2 != Tri.No) + .filter(_._3 != Tri.No) .toVector case None => Vector.empty @@ -37,6 +39,18 @@ object ConnectToMap: */ def getNets(connectToVal: ConnectToVal, slice: Slice): Set[DFNet] = getNetsVerdicts(connectToVal, slice).view.map(_._1).toSet + + /** Is some net's write to `slice` PROVEN to overlap? Unlike [[contains]], a merely unproven + * (parameter-dependent) relation answers `false`. Directionality decisions use this: which end + * of a connection is the sink is a structural property that must hold for every parameter + * assignment, so parameter opacity may never flip it. + */ + def hasProvenNet(connectToVal: ConnectToVal, slice: Slice): Boolean = + getNetsVerdicts(connectToVal, slice).exists(_._3 == Tri.Yes) + def hasProvenNet(dfVal: DFVal): Boolean = + dfVal.departialPBNS match + case Some(connectToVal, slice) => hasProvenNet(connectToVal, slice) + case _ => false def getNets(dfVal: DFVal): Set[DFNet] = dfVal.departialPBNS match case Some(connectToVal, slice) => getNets(connectToVal, slice) @@ -67,6 +81,32 @@ object ConnectToMap: ctm.get(connectToVal).map(_.coverage).getOrElse(Coverage.empty) end extension + /** Re-decides an overlap the symbolic proofs left [[Tri.Unknown]], by folding both slices at the + * elaborated parameter values (an elaboration root's own parameters resolve through their + * defaults). `Some(true)` for a collision at those values, `Some(false)` when disjoint there, + * and `None` when either endpoint does not fold, in which case the caller skips its check. + * + * LEGALITY ONLY, never directionality: unlike [[overlapsSlices]], a verdict here holds for the + * parameters actually elaborated rather than for every HDL parameter override. See + * [[IntExprCalc.DataCalc.foldConst]]. + */ + def foldedOverlap(a: Slice, b: Slice)(using MemberGetSet): Option[Boolean] = + for + ra <- foldedRange(a) + rb <- foldedRange(b) + yield ra.intersect(rb).nonEmpty + + private def foldedRange(slice: Slice)(using MemberGetSet): Option[Range] = slice match + case Slice.Concrete(r) => Some(r) + case Slice.Symbolic(lo, w) => + for + loInt <- IntExprCalc.DataCalc.foldConst(lo) + wInt <- IntExprCalc.DataCalc.foldConst(w) + yield Range(loInt, loInt + wInt) + // `Full` and `Unknown` never reach here: every pairing of `Full` is already decided by + // `overlapsSlices`, and `Unknown` carries nothing to fold. + case _ => None + /** Pairwise slice-overlap predicate used by `getNets`. Returns `Tri.Yes` only when provably * overlapping, `Tri.No` only when provably disjoint, `Tri.Unknown` otherwise. */ diff --git a/compiler/ir/src/main/scala/dfhdl/compiler/ir/DB.scala b/compiler/ir/src/main/scala/dfhdl/compiler/ir/DB.scala index ba154e8a8..b4cc519e1 100644 --- a/compiler/ir/src/main/scala/dfhdl/compiler/ir/DB.scala +++ b/compiler/ir/src/main/scala/dfhdl/compiler/ir/DB.scala @@ -450,7 +450,7 @@ final case class DB private ( import Access.* import DFVal.Modifier.* import DFNet.Op.* - private def getValAccess(dfVal: DFVal, slice: Slice, net: DFNet)( + private def getValAccess(dfVal: DFVal, slice: Slice, net: DFNet, conservative: Boolean)( connToMap: ConnectToMap ): Access = def isExternalConn = @@ -479,8 +479,17 @@ final case class DB private ( case INOUT if isExternalConn || isInternalConn => ReadWrite // internal connection to a var case VAR if isInternalConn => - // if already was connected as write, then it must be read - if (connToMap.contains(dfVal, slice)) Read + // if already was connected as write, then it must be read. + // only a PROVEN overlap decides this: which end of a connection is the sink is a + // structural property that must hold for every parameter assignment, so an + // unproven parameter-dependent relation may never flip the flow. Such a net stays + // undecided and is re-examined once other nets supply the direction, with the + // conservative reading (any not-disproven overlap counts) as the final tiebreak + // for a net nothing else can settle (see `getConnToMap`'s pending passes). + if ( + if (conservative) connToMap.contains(dfVal, slice) + else connToMap.hasProvenNet(dfVal, slice) + ) Read // otherwise it is unknown else Unknown // illegal connection @@ -490,11 +499,11 @@ final case class DB private ( case _ => Read end match end getValAccess - private def getValAccess(dfVal: DFVal, net: DFNet)( + private def getValAccess(dfVal: DFVal, net: DFNet, conservative: Boolean)( connToMap: ConnectToMap ): Access = val dpart = dfVal.departial - getValAccess(dpart._1, dpart._2, net)(connToMap) + getValAccess(dpart._1, dpart._2, net, conservative)(connToMap) private case class FlatNet(lhsVal: DFVal, rhsVal: DFVal, net: DFNet) derives CanEqual private object FlatNet: def apply(net: DFNet): List[FlatNet] = @@ -507,7 +516,9 @@ final case class DB private ( analyzeNets: List[FlatNet], pendingNets: List[FlatNet], connToMap: ConnectToMap, - errors: List[String] + errors: List[String], + progress: Boolean = false, + conservative: Boolean = false ): ConnectToMap = analyzeNets match case flatNet :: otherNets => @@ -576,12 +587,32 @@ final case class DB private ( case _ => if (lhsVal.isOpen) openCheck(rhsVal) else if (rhsVal.isOpen) openCheck(lhsVal) - (getValAccess(lhsVal, net)(connToMap), getValAccess(rhsVal, net)(connToMap)) + ( + getValAccess(lhsVal, net, conservative)(connToMap), + getValAccess(rhsVal, net, conservative)(connToMap) + ) + // A variable already driven on a proven-overlapping slice reads as a source (see the + // `VAR` case of `getValAccess`), so a second driver arrives here as a read-to-read + // rather than as a write collision. Recover the real diagnosis: name the variable and + // the earlier write, instead of a "read-to-read" the user cannot act on. + def priorWriteOf(dfVal: DFVal): Option[(ConnectToVal, DFNet)] = + dfVal.departialPBNS.collect { + case (dcl: DFVal.Dcl, slice) if dcl.modifier.dir == VAR => + connToMap.getNetsVerdicts(dcl, slice).collectFirst { + case (prevNet, _, Tri.Yes) if prevNet.isConnection => (dcl, prevNet) + } + }.flatten val toValOption = (lhsAccess, rhsAccess) match case (Write, Read | ReadWrite | Unknown) => Some(lhsVal) case (Read | ReadWrite | Unknown, Write) => Some(rhsVal) case (Read, Read) => - newError("Unsupported read-to-read connection.") + priorWriteOf(lhsVal).orElse(priorWriteOf(rhsVal)) match + case Some(connectToVal, prevNet) => + newError( + s"""Found multiple connections write to the same variable/port `${connectToVal.getFullName}`. + |The previous write occurred at ${prevNet.meta.position}""".stripMargin + ) + case None => newError("Unsupported read-to-read connection.") None // the LHS-favouring exception, see `isOwnOutPort` case (Write, Write) if isOwnOutPort(lhsVal) && isOwnOutPort(rhsVal) => Some(lhsVal) @@ -612,7 +643,6 @@ final case class DB private ( // found target variable or port declaration for the given connection/assignment case Some(connectToVal, slice) => val prevNetsVerdicts = connToMap.getNetsVerdicts(connectToVal, slice) - val prevNets = prevNetsVerdicts.view.map(_._1).toSet // checking multiple assignments from different domains, except for a condition // where the declaration is a shared variable. // this is used to define a shared variable which is against the RT model, @@ -621,60 +651,79 @@ final case class DB private ( case dcl: DFVal.Dcl if dcl.modifier.isShared => true case _ => false if (!isSharedVar) - prevNetsVerdicts.headOption.foreach: (prevNet, _) => + prevNetsVerdicts.headOption.foreach: (prevNet, _, _) => if (prevNet.getOwnerDomain != net.getOwnerDomain) newError( s"""|Found multiple domain assignments to the same variable/port `${connectToVal.getFullName}`. |Only variables declared as `VAR.SHARED` under ED domain allow this. |The previous write occurred at ${prevNet.meta.position}""".stripMargin ) + // Whether a previous write really covers bits this one writes. A symbolic proof + // decides it for every parameter assignment; when it cannot, the bit ranges are + // folded at the elaborated parameter values, which decides the design at hand (a + // parameter override changing the answer is the HDL tool's multiple-driver check to + // catch). An unfoldable relation leaves the check with no verdict, and an + // unprovable legality check is skipped rather than guessed: unlike directionality, + // it can be deferred to wherever the parameters do resolve, such as this design + // being instantiated by a parent. + def collides(prevSlice: Slice, verdict: Tri): Boolean = + verdict == Tri.Yes || + ConnectToMap.foldedOverlap(prevSlice, slice).getOrElse(false) // go through all previous nets and check for collisions - prevNetsVerdicts.foreach: (prevNet, verdict) => + prevNetsVerdicts.foreach: (prevNet, prevSlice, verdict) => // multiple assignments are allowed in the same range, but not multiple // connections or a combination of an assignment and a connection if (prevNet.isConnection || prevNet.isAssignment && !net.isAssignment) - if (verdict == Tri.Yes) + if (collides(prevSlice, verdict)) newError( s"""Found multiple connections write to the same variable/port `${connectToVal.getFullName}`. |The previous write occurred at ${prevNet.meta.position}""".stripMargin ) - // the slices could not be proven overlapping NOR disjoint (parameter-dependent - // indices the slice calculus cannot relate), so the write is conservatively - // rejected with an error that names the actual problem - else - newError( - s"""|Found a write to the same variable/port `${connectToVal.getFullName}` that cannot be proven to be - |disjoint from a previous write, because their parameter-dependent bit ranges could not be - |resolved. If the ranges never overlap, restructure their indexing so the compiler can relate - |them, or use assignments within a process instead of connections. - |The previous write occurred at ${prevNet.meta.position}""".stripMargin - ) - // if no previous connection in this range, we add it to the range map - if (prevNets.isEmpty) + // if no previous write can touch this range, we add it to the range map. a previous + // write proven disjoint only at the elaborated values still counts as disjoint here, + // so per-element parametric writes each get tracked. + if ( + prevNetsVerdicts.forall((_, prevSlice, verdict) => + verdict != Tri.Yes && ConnectToMap.foldedOverlap(prevSlice, slice).contains(false) + ) + ) getConnToMap( otherNets, pendingNets, connToMap.addNet(connectToVal, slice, net), - newErrors + newErrors, + progress = true, + conservative ) // if there are previous connections, it's either assignments or already reported as // errors, so no need to further modify the range map (the range map is not intended // to save all the previous assignment nets). else - getConnToMap(otherNets, pendingNets, connToMap, newErrors) + getConnToMap(otherNets, pendingNets, connToMap, newErrors, progress = true, + conservative) // unable to determine net directionality, so move net to pending case None => - getConnToMap(otherNets, flatNet :: pendingNets, connToMap, newErrors) + getConnToMap( + otherNets, + flatNet :: pendingNets, + connToMap, + newErrors, + progress, + conservative + ) end match case Nil if errors.nonEmpty => throw new IllegalArgumentException( errors.view.reverse.mkString("\n\n") ) case Nil if pendingNets.nonEmpty => - val reexamine = pendingNets.exists { n => - connToMap.contains(n.lhsVal) | connToMap.contains(n.rhsVal) - } - if (reexamine) getConnToMap(pendingNets, Nil, connToMap, errors) + // Re-examine as long as the last pass settled something, since a resolved net is what + // supplies a pending one its direction. A pass that settles nothing has exhausted the + // proven reasoning, so one conservative pass follows as the tiebreak (see the `VAR` case + // of `getValAccess`); only when that too settles nothing is the net undecidable. + if (progress) getConnToMap(pendingNets, Nil, connToMap, errors) + else if (!conservative) + getConnToMap(pendingNets, Nil, connToMap, errors, conservative = true) else throw new IllegalArgumentException( s"""DFiant HDL connectivity errors! diff --git a/compiler/ir/src/main/scala/dfhdl/compiler/ir/DFMember.scala b/compiler/ir/src/main/scala/dfhdl/compiler/ir/DFMember.scala index dc3ea53bd..3a7d63255 100644 --- a/compiler/ir/src/main/scala/dfhdl/compiler/ir/DFMember.scala +++ b/compiler/ir/src/main/scala/dfhdl/compiler/ir/DFMember.scala @@ -409,17 +409,29 @@ object DFVal: case None => Slice.Unknown relVal.departial(newSlice) case partial: DFVal.Alias.ApplyIdx => - partial.relIdx.get match - case DFVal.Alias.ApplyIdx.ConstIdx(idx) => - linearOfTypeWidth(partial.dfType) match - case Some(cellWidth) => - relVal.departial(Slice.compose(slice, scale(cellWidth, idx), cellWidth)) - case None => relVal.departial(Slice.Unknown) - // if not a constant index selection, then the entire value range is affected - case _ => + val idxLinear = linearOfVal(partial.relIdx.get) + // An index fixed at elaboration selects one cell, so it composes into the slice: a + // literal folds to a concrete range, and an index over design parameters stays a + // symbolic one (`v(N - 1)`). Any other index affects the entire value: a runtime + // value is not constant at all, and a loop iterator or a static-function formal is + // constant per evaluation yet varies across them. + val idxIsFixed = idxLinear.terms.forall((_, base) => base.isDesignParam) + val newSliceOpt = + if (idxIsFixed) + linearOfTypeWidth(partial.dfType).flatMap { cellWidth => + mulOpt(idxLinear, cellWidth).map(Slice.compose(slice, _, cellWidth)) + } + else None + (newSliceOpt, idxIsFixed) match + case (Some(newSlice), _) => relVal.departial(newSlice) + // a fixed index whose bit coordinates are not expressible (a cell width that does + // not linearize, or a parametric index times a parametric cell width) + case (None, true) => relVal.departial(Slice.Unknown) + case (None, false) => relVal.dealias match case Some(dcl: DFVal.Dcl) => (dcl, Slice.fromWidthOpt(dcl.dfType.widthIntOpt)) case _ => (relVal, Slice.fromWidthOpt(relVal.dfType.widthIntOpt)) + end match case partial: DFVal.Alias.SelectField => relVal.dfType match case structType: DFStruct => diff --git a/compiler/ir/src/main/scala/dfhdl/compiler/ir/IntExprCalc.scala b/compiler/ir/src/main/scala/dfhdl/compiler/ir/IntExprCalc.scala index 5bcb73147..8a7059069 100644 --- a/compiler/ir/src/main/scala/dfhdl/compiler/ir/IntExprCalc.scala +++ b/compiler/ir/src/main/scala/dfhdl/compiler/ir/IntExprCalc.scala @@ -132,6 +132,24 @@ object IntExprCalc: */ def proveNonNeg(e: Linear, facts: List[Linear])(using MemberGetSet): Boolean = calc.proveNonNeg(e, facts) + + /** Folds a linear form to a single integer by resolving every remaining opaque base through the + * design-parameter chain, which includes an elaboration ROOT's own parameters via their + * defaults (`AppliedData` deliberately keeps those symbolic). `None` when any base does not + * resolve to an integer. + * + * A decision made on folded values holds for the parameters actually elaborated, NOT for every + * HDL override, so this may only refine a legality verdict. It must never reach a + * directionality decision: the flow of a connection is a structural property that has to hold + * for every parameter assignment. + */ + def foldConst(l: Linear)(using MemberGetSet): Option[Int] = + l.terms.foldLeft(Option(l.offset)) { case (accOpt, (c, base)) => + accOpt.flatMap { acc => + base.getConstDataThroughParams[Option[BigInt]].flatten + .filter(_.isValidInt).map(v => acc + c * v.toInt) + } + } end DataCalc private object ConstInt: diff --git a/compiler/stages/src/test/scala/StagesSpec/PrintCodeStringSpec.scala b/compiler/stages/src/test/scala/StagesSpec/PrintCodeStringSpec.scala index 629695948..9a4ca57db 100644 --- a/compiler/stages/src/test/scala/StagesSpec/PrintCodeStringSpec.scala +++ b/compiler/stages/src/test/scala/StagesSpec/PrintCodeStringSpec.scala @@ -3193,4 +3193,59 @@ class PrintCodeStringSpec extends StageSpec(stageCreatesUnrefAnons = true): |""".stripMargin ) } + + // A bit-select whose index is parameter-dependent keeps its symbolic index in the printed + // code, and the connectivity re-derived here (`sanityCheck` forces it) must give the same + // directions the elaboration did, with the index resolved through the applied parameter. + test("Parametric bit-select index") { + class Fifo extends EDDesign: + val wReady = Bit <> OUT + wReady <> 1 + class ParamIdxChild(val N: Int <> CONST = 3) extends EDDesign: + val a = Bit <> IN + val out = Bits(N) <> OUT + val w = Bit <> VAR + val v = Bits(N) <> VAR + v(N - 1) <> a + v(0) <> w + v(1) <> a + out <> v + val f = Fifo() + w <> f.wReady + class ParamIdxParent extends EDDesign: + val a = Bit <> IN + val out = Bits(3) <> OUT + val c = ParamIdxChild(3) + c.a <> a + out <> c.out + assertCodeString( + ParamIdxParent(), + """|class Fifo extends EDDesign: + | val wReady = Bit <> OUT + | wReady <> 1 + |end Fifo + | + |class ParamIdxChild(val N: Int <> CONST = 3) extends EDDesign: + | val a = Bit <> IN + | val out = Bits(N) <> OUT + | val w = Bit <> VAR + | val v = Bits(N) <> VAR + | v(N - 1) <> a + | v(0) <> w + | v(1) <> a + | out <> v + | val f = Fifo() + | w <> f.wReady + |end ParamIdxChild + | + |class ParamIdxParent extends EDDesign: + | val a = Bit <> IN + | val out = Bits(3) <> OUT + | val c = ParamIdxChild(N = 3) + | c.a <> a + | out <> c.out + |end ParamIdxParent + |""".stripMargin + ) + } end PrintCodeStringSpec diff --git a/lib/src/test/scala/ElaborationChecksSpec.scala b/lib/src/test/scala/ElaborationChecksSpec.scala index ca60f3a2d..a32fdab3f 100644 --- a/lib/src/test/scala/ElaborationChecksSpec.scala +++ b/lib/src/test/scala/ElaborationChecksSpec.scala @@ -1206,44 +1206,9 @@ class ElaborationChecksSpec extends DesignSpec: o <> c.o end MixParent import Test.* - import dfhdl.compiler.stages.getCompiledCodeString - // the backend printing itself re-derives the connectivity on the flat DB, so the - // compiled code string (not just elaboration) is part of this regression - assertNoDiff( - MixParent().getCompiledCodeString, - """|`default_nettype none - |`timescale 1ns/1ps - | - |module MixChild#(parameter int W = 4)( - | input wire logic [(W * 2) - 1:0] i, - | output logic [(W * 2) - 1:0] o - |); - | `include "dfhdl_defs.svh" - | assign o[3:0] = i[3:0]; - | assign o[(2 * W) - 1:W] = i[(2 * W) - 1:W]; - |endmodule - | - |`default_nettype none - |`timescale 1ns/1ps - | - |module MixParent( - | input wire logic [7:0] i, - | output logic [7:0] o - |); - | `include "dfhdl_defs.svh" - | logic [(4 * 2) - 1:0] c_i; - | logic [(4 * 2) - 1:0] c_o; - | MixChild #( - | .W (4) - | ) c( - | .i /*<--*/ (c_i), - | .o /*-->*/ (c_o) - | ); - | assign c_i = i; - | assign o = c_o; - |endmodule - |""".stripMargin - ) + // the printed form of this shape is pinned by `PrintCodeStringSpec`, which re-derives the + // connectivity after the stages have run + MixParent() test("overlapping parameter-dependent slice connections error"): object Test: @@ -1257,15 +1222,19 @@ class ElaborationChecksSpec extends DesignSpec: assertElaborationErrors(SliceOverlap())( s"""|Elaboration errors found! |DFiant HDL connectivity error! - |Position: ${currentFilePos}ElaborationChecksSpec.scala:1254:9 - 1254:45 + |Position: ${currentFilePos}ElaborationChecksSpec.scala:1219:9 - 1219:45 |Hierarchy: SliceOverlap |LHS: o(W - 1, 0) |RHS: i((W + W) - 1, W) |Message: Found multiple connections write to the same variable/port `SliceOverlap.o`. - |The previous write occurred at ${currentFilePos}ElaborationChecksSpec.scala:1253:9 - 1253:45""".stripMargin + |The previous write occurred at ${currentFilePos}ElaborationChecksSpec.scala:1218:9 - 1218:45""".stripMargin ) - test("unprovable parameter-dependent slice connections error"): + // A relation no symbolic proof can settle is decided at the parameters actually elaborated + // (`W = 4` below makes the two ranges `[0, 4)` and `[4, 8)`), rather than rejected for being + // parametric. A legality verdict may be deferred that way; see the directionality tests below + // for the property that may NOT. + test("unprovable parameter-dependent slice connections resolve at the elaborated values"): object Test: @top(false) class SliceUnprovable(val W: Int <> CONST = 4) extends EDDesign: val i = Bits(W * 2) <> IN @@ -1274,18 +1243,26 @@ class ElaborationChecksSpec extends DesignSpec: o(2 * W - 1, W) <> i(2 * W - 1, W) end SliceUnprovable import Test.* - assertElaborationErrors(SliceUnprovable())( + SliceUnprovable() + + test("unprovable parameter-dependent slices colliding at the elaborated values error"): + object Test: + @top(false) class SliceUnprovableCollide(val W: Int <> CONST = 2) extends EDDesign: + val i = Bits(W * 2) <> IN + val o = Bits(W * 2) <> OUT + o(3, 0) <> i(3, 0) + o(2 * W - 1, W) <> i(2 * W - 1, W) + end SliceUnprovableCollide + import Test.* + assertElaborationErrors(SliceUnprovableCollide())( s"""|Elaboration errors found! |DFiant HDL connectivity error! - |Position: ${currentFilePos}ElaborationChecksSpec.scala:1274:9 - 1274:43 - |Hierarchy: SliceUnprovable + |Position: ${currentFilePos}ElaborationChecksSpec.scala:1254:9 - 1254:43 + |Hierarchy: SliceUnprovableCollide |LHS: o((2 * W) - 1, W) |RHS: i((2 * W) - 1, W) - |Message: Found a write to the same variable/port `SliceUnprovable.o` that cannot be proven to be - |disjoint from a previous write, because their parameter-dependent bit ranges could not be - |resolved. If the ranges never overlap, restructure their indexing so the compiler can relate - |them, or use assignments within a process instead of connections. - |The previous write occurred at ${currentFilePos}ElaborationChecksSpec.scala:1273:9 - 1273:27""".stripMargin + |Message: Found multiple connections write to the same variable/port `SliceUnprovableCollide.o`. + |The previous write occurred at ${currentFilePos}ElaborationChecksSpec.scala:1253:9 - 1253:27""".stripMargin ) test("consistent assignment kinds per process are accepted"): object Test: @@ -1344,24 +1321,24 @@ class ElaborationChecksSpec extends DesignSpec: assertElaborationErrors(MixedWhole())( s"""|Elaboration errors found! |DFiant HDL connectivity error! - |Position: ${currentFilePos}ElaborationChecksSpec.scala:1332:16 - 1332:23 + |Position: ${currentFilePos}ElaborationChecksSpec.scala:1309:16 - 1309:23 |Hierarchy: MixedWhole |LHS: q |RHS: d |Message: Found both blocking (`:=`) and non-blocking (`:==`) assignments to the same variable/port `MixedWhole.q` within the same process. |Use one assignment kind consistently for this variable inside the process. - |The previous write occurred at ${currentFilePos}ElaborationChecksSpec.scala:1331:20 - 1331:26""".stripMargin + |The previous write occurred at ${currentFilePos}ElaborationChecksSpec.scala:1308:20 - 1308:26""".stripMargin ) assertElaborationErrors(MixedParts())( s"""|Elaboration errors found! |DFiant HDL connectivity error! - |Position: ${currentFilePos}ElaborationChecksSpec.scala:1340:11 - 1340:30 + |Position: ${currentFilePos}ElaborationChecksSpec.scala:1317:11 - 1317:30 |Hierarchy: MixedParts |LHS: q(7, 4) |RHS: d(7, 4) |Message: Found both blocking (`:=`) and non-blocking (`:==`) assignments to the same variable/port `MixedParts.q` within the same process. |Use one assignment kind consistently for this variable inside the process. - |The previous write occurred at ${currentFilePos}ElaborationChecksSpec.scala:1339:11 - 1339:29""".stripMargin + |The previous write occurred at ${currentFilePos}ElaborationChecksSpec.scala:1316:11 - 1316:29""".stripMargin ) test("parametric max width-fit accepted via symbolic elimination"): object Test: @@ -1420,7 +1397,7 @@ class ElaborationChecksSpec extends DesignSpec: assertElaborationErrors(MaxTooNarrow())( s"""|Elaboration errors found! |DFiant HDL elaboration error! - |Position: ${currentFilePos}ElaborationChecksSpec.scala:1412:9 - 1412:20 + |Position: ${currentFilePos}ElaborationChecksSpec.scala:1389:9 - 1389:20 |Hierarchy: MaxTooNarrow |Operation: `:=` |Message: The applied RHS value width (WIDTH max 16) is larger than the LHS variable width (15).""".stripMargin @@ -1428,7 +1405,7 @@ class ElaborationChecksSpec extends DesignSpec: assertElaborationErrors(PlainSymWidth())( s"""|Elaboration errors found! |DFiant HDL elaboration error! - |Position: ${currentFilePos}ElaborationChecksSpec.scala:1417:9 - 1417:17 + |Position: ${currentFilePos}ElaborationChecksSpec.scala:1394:9 - 1394:17 |Hierarchy: PlainSymWidth |Operation: `:=` |Message: The applied RHS value width (WIDTH) is undefined compared to the LHS variable width (16).""".stripMargin @@ -1451,14 +1428,14 @@ class ElaborationChecksSpec extends DesignSpec: assertElaborationErrors(Parent())( s"""|Elaboration errors found! |DFiant HDL elaboration error! - |Position: ${currentFilePos}ElaborationChecksSpec.scala:1448:9 - 1448:17 + |Position: ${currentFilePos}ElaborationChecksSpec.scala:1425:9 - 1425:17 |Hierarchy: Parent |Operation: `apply` |Message: The argument width (c.OUTPUT_WIDTH) is different than the receiver width (OUTPUT_WIDTH). |Consider applying `.resize` to resolve this issue. | |DFiant HDL elaboration error! - |Position: ${currentFilePos}ElaborationChecksSpec.scala:1448:9 - 1448:17 + |Position: ${currentFilePos}ElaborationChecksSpec.scala:1425:9 - 1425:17 |Hierarchy: Parent |Operation: `apply` |Message: The argument width (OUTPUT_WIDTH) is different than the receiver width (c.OUTPUT_WIDTH). @@ -1482,13 +1459,13 @@ class ElaborationChecksSpec extends DesignSpec: assertElaborationErrors(Parent())( s"""|Elaboration errors found! |DFiant HDL elaboration error! - |Position: ${currentFilePos}ElaborationChecksSpec.scala:1479:9 - 1479:17 + |Position: ${currentFilePos}ElaborationChecksSpec.scala:1456:9 - 1456:17 |Hierarchy: Parent |Operation: `apply` |Message: The applied RHS value width (c.OUTPUT_WIDTH) is undefined compared to the LHS variable width (OUTPUT_WIDTH). | |DFiant HDL elaboration error! - |Position: ${currentFilePos}ElaborationChecksSpec.scala:1479:9 - 1479:17 + |Position: ${currentFilePos}ElaborationChecksSpec.scala:1456:9 - 1456:17 |Hierarchy: Parent |Operation: `apply` |Message: The applied RHS value width (OUTPUT_WIDTH) is undefined compared to the LHS variable width (c.OUTPUT_WIDTH).""".stripMargin @@ -1509,14 +1486,14 @@ class ElaborationChecksSpec extends DesignSpec: assertElaborationErrors(Parent())( s"""|Elaboration errors found! |DFiant HDL elaboration error! - |Position: ${currentFilePos}ElaborationChecksSpec.scala:1506:9 - 1506:17 + |Position: ${currentFilePos}ElaborationChecksSpec.scala:1483:9 - 1483:17 |Hierarchy: Parent |Operation: `apply` |Message: The argument width (c.W) is different than the receiver width (W). |Consider applying `.resize` to resolve this issue. | |DFiant HDL elaboration error! - |Position: ${currentFilePos}ElaborationChecksSpec.scala:1506:9 - 1506:17 + |Position: ${currentFilePos}ElaborationChecksSpec.scala:1483:9 - 1483:17 |Hierarchy: Parent |Operation: `apply` |Message: The argument width (W) is different than the receiver width (c.W). @@ -1569,7 +1546,7 @@ class ElaborationChecksSpec extends DesignSpec: assertElaborationErrors(MulTooNarrow())( s"""|Elaboration errors found! |DFiant HDL elaboration error! - |Position: ${currentFilePos}ElaborationChecksSpec.scala:1560:9 - 1560:24 + |Position: ${currentFilePos}ElaborationChecksSpec.scala:1537:9 - 1537:24 |Hierarchy: MulTooNarrow |Operation: `apply` |Message: The applied RHS value width (W) is undefined compared to the LHS variable width (16).""".stripMargin @@ -1579,7 +1556,7 @@ class ElaborationChecksSpec extends DesignSpec: assertElaborationErrors(ProvablyNarrow())( s"""|Elaboration errors found! |DFiant HDL elaboration error! - |Position: ${currentFilePos}ElaborationChecksSpec.scala:1565:9 - 1565:20 + |Position: ${currentFilePos}ElaborationChecksSpec.scala:1542:9 - 1542:20 |Hierarchy: ProvablyNarrow |Operation: `:=` |Message: The applied RHS value width (2 * W) is larger than the LHS variable width (W).""".stripMargin @@ -1604,11 +1581,115 @@ class ElaborationChecksSpec extends DesignSpec: assertElaborationErrors(Top())( s"""|Elaboration errors found! |DFiant HDL elaboration error! - |Position: ${currentFilePos}ElaborationChecksSpec.scala:1601:14 - 1601:35 + |Position: ${currentFilePos}ElaborationChecksSpec.scala:1578:14 - 1578:35 |Hierarchy: Top |Operation: `setName` |Message: Cannot set a name for a port of an internal design. |The name of a port is set by the design that declares it.""".stripMargin ) + // Which end of a connection is the sink must never depend on a parameter value: a bit-select + // whose index is parameter-dependent is a fixed selection, not a write to the whole value, and + // an unproven overlap may not make a variable read as already-driven. + test("parametric bit-select indices keep connection directionality"): + object Test: + class Fifo extends EDDesign: + val w_ready = Bit <> OUT + w_ready <> 1 + // `w` is read into `v(0)` before its own driver is written, so the parametric `v(N - 1)` + // must not make the later drive of `w` read as a second driver + @top(false) class ParamIdxFlow(val N: Int <> CONST = 3) extends EDDesign: + val a = Bit <> IN + val out = Bits(N) <> OUT + val w = Bit <> VAR + val v = Bits(N) <> VAR + v(N - 1) <> a + v(0) <> w + v(1) <> a + out <> v + val f = Fifo() + w <> f.w_ready + end ParamIdxFlow + // the same design with the drive of `w` written before the read of `w` + @top(false) class ParamIdxFlowSwapped(val N: Int <> CONST = 3) extends EDDesign: + val a = Bit <> IN + val out = Bits(N) <> OUT + val w = Bit <> VAR + val v = Bits(N) <> VAR + val f = Fifo() + w <> f.w_ready + v(N - 1) <> a + v(0) <> w + v(1) <> a + out <> v + end ParamIdxFlowSwapped + // a parametric top index alongside per-element child drives of the same bus + @top(false) class ParamIdxFanout(val H: Int <> CONST = 4) extends EDDesign: + val kReady = Bit <> IN + val extOut = Bits(H) <> OUT + val bus = Bits(H) <> VAR + val firstReady = Bit <> VAR + bus(H - 1) <> kReady + bus(0) <> firstReady + val firstLine = Fifo() + firstReady <> firstLine.w_ready + for (i <- 1 until H - 1) + val lineBuff = Fifo() + bus(i) <> lineBuff.w_ready + extOut <> bus + end ParamIdxFanout + end Test + import Test.* + ParamIdxFlow() + ParamIdxFlowSwapped() + ParamIdxFanout() + + test("a parametric bit-select colliding at the elaborated parameters errors"): + object Test: + @top(false) class ParamIdxCollide(val N: Int <> CONST = 2) extends EDDesign: + val a = Bit <> IN + val out = Bits(N) <> OUT + val v = Bits(N) <> VAR + v(N - 1) <> a + v(0) <> a + v(1) <> a + out <> v + end ParamIdxCollide + import Test.* + assertElaborationErrors(ParamIdxCollide())( + s"""|Elaboration errors found! + |DFiant HDL connectivity error! + |Position: ${currentFilePos}ElaborationChecksSpec.scala:1655:9 - 1655:18 + |Hierarchy: ParamIdxCollide + |LHS: v(1) + |RHS: a + |Message: Found multiple connections write to the same variable/port `ParamIdxCollide.v`. + |The previous write occurred at ${currentFilePos}ElaborationChecksSpec.scala:1653:9 - 1653:22""".stripMargin + ) + + // A variable already driven reads as a source, so a second driver reaches the analysis as a + // read-to-read pairing; the reported error must still name the write collision. + test("a second connection to the same variable bit reports a write collision"): + object Test: + @top(false) class VarRedrive extends EDDesign: + val a = Bit <> IN + val o = Bits(4) <> OUT + val v = Bits(4) <> VAR + v(0) <> a + v(0) <> a + v(3, 1) <> b"000" + o <> v + end VarRedrive + import Test.* + assertElaborationErrors(VarRedrive())( + s"""|Elaboration errors found! + |DFiant HDL connectivity error! + |Position: ${currentFilePos}ElaborationChecksSpec.scala:1679:9 - 1679:18 + |Hierarchy: VarRedrive + |LHS: v(0) + |RHS: a + |Message: Found multiple connections write to the same variable/port `VarRedrive.v`. + |The previous write occurred at ${currentFilePos}ElaborationChecksSpec.scala:1678:9 - 1678:18""".stripMargin + ) + end ElaborationChecksSpec From a86c4826c9275a302600be64ee4055634228b025 Mon Sep 17 00:00:00 2001 From: Oron Port Date: Mon, 10 Aug 2026 12:33:57 +0300 Subject: [PATCH 05/40] ir+compiler_stages: a shared-variable writer emits `always` instead of `always_ff` `always_ff` guarantees a single driver for everything the process writes, while a `VAR.SHARED` is multi-driven by design (one clocked process per RAM port), so the two are contradictory and conforming tools reject the pair. Every process writing a shared variable now renders as a plain `always`, which carries no such guarantee. Only the writers degrade: a process merely reading the shared variable is unconstrained and keeps its `always_ff`. Fixes #473 Co-Authored-By: Claude Opus 5 (1M context) --- .../compiler/analysis/ProcessBlockAnalysis.scala | 11 +++++++++++ .../compiler/stages/verilog/VerilogOwnerPrinter.scala | 11 +++++++++-- .../test/scala/StagesSpec/PrintVerilogCodeSpec.scala | 6 ++++-- .../verilog.sv2009/hdl/TrueDPR.sv | 4 ++-- 4 files changed, 26 insertions(+), 6 deletions(-) diff --git a/compiler/ir/src/main/scala/dfhdl/compiler/analysis/ProcessBlockAnalysis.scala b/compiler/ir/src/main/scala/dfhdl/compiler/analysis/ProcessBlockAnalysis.scala index e8ced2cea..6da3c35bc 100644 --- a/compiler/ir/src/main/scala/dfhdl/compiler/analysis/ProcessBlockAnalysis.scala +++ b/compiler/ir/src/main/scala/dfhdl/compiler/analysis/ProcessBlockAnalysis.scala @@ -40,6 +40,17 @@ extension (pb: ProcessBlock)(using MemberGetSet) // stages to decide between the `ToED` reset-branch path and declaration-init forms. def hasResolvedRstCfg: Boolean = pb.getOwnerDomain.resolvedRstAnnot.isDefined + // True when the process writes (any part of) a shared variable. A shared variable models a + // multi-ported memory, so it is written from as many processes as it has write ports, and a + // single-driver process construct (SystemVerilog `always_ff`) therefore cannot render such a + // process. Reads are unconstrained and do not count. The whole nesting is searched, since a + // write can sit inside a conditional or a loop within the process. + def writesSharedVar: Boolean = + pb.members(MemberView.Flattened).exists { + case DFNet.Assignment(toVal, _) => + toVal.departialDcl.exists((dcl, _) => dcl.modifier.isShared) + case _ => false + } end extension // The declarations assigned by the given block members, ordered by first assignment. diff --git a/compiler/stages/src/main/scala/dfhdl/compiler/stages/verilog/VerilogOwnerPrinter.scala b/compiler/stages/src/main/scala/dfhdl/compiler/stages/verilog/VerilogOwnerPrinter.scala index b1e9419b9..87a5db053 100644 --- a/compiler/stages/src/main/scala/dfhdl/compiler/stages/verilog/VerilogOwnerPrinter.scala +++ b/compiler/stages/src/main/scala/dfhdl/compiler/stages/verilog/VerilogOwnerPrinter.scala @@ -402,6 +402,11 @@ protected trait VerilogOwnerPrinter extends AbstractOwnerPrinter: if (dcls.isEmpty) "" else s"${csDFMembers(dcls)}\n" val named = pb.meta.nameOpt.map(n => s"$n : ").getOrElse("") + // `always_ff` guarantees a single driver for everything it writes, so a process writing a + // shared variable (multi-driven by design, e.g. one clocked process per RAM port) degrades + // to a plain `always`, which carries no such guarantee (issue #473). Only the writers + // degrade: a process merely reading the shared variable is unconstrained. + val sharedWriter = pb.writesSharedVar val alwaysKW = pb.sensitivity match case Sensitivity.Initial => "initial" case _ => @@ -412,10 +417,12 @@ protected trait VerilogOwnerPrinter extends AbstractOwnerPrinter: case Sensitivity.All => "always_comb" case Sensitivity.List(refs) => refs match - case DFRef(DFVal.Func(op = FuncOp.rising | FuncOp.falling)) :: Nil => + case DFRef(DFVal.Func(op = FuncOp.rising | FuncOp.falling)) :: Nil + if !sharedWriter => "always_ff" case DFRef(DFVal.Func(op = FuncOp.rising | FuncOp.falling)) :: - DFRef(DFVal.Func(op = FuncOp.rising | FuncOp.falling)) :: Nil => + DFRef(DFVal.Func(op = FuncOp.rising | FuncOp.falling)) :: Nil + if !sharedWriter => "always_ff" case _ => "always" val senList = pb.sensitivity match diff --git a/compiler/stages/src/test/scala/StagesSpec/PrintVerilogCodeSpec.scala b/compiler/stages/src/test/scala/StagesSpec/PrintVerilogCodeSpec.scala index 6c35d0013..4e4c12d8e 100644 --- a/compiler/stages/src/test/scala/StagesSpec/PrintVerilogCodeSpec.scala +++ b/compiler/stages/src/test/scala/StagesSpec/PrintVerilogCodeSpec.scala @@ -3183,7 +3183,9 @@ class PrintVerilogCodeSpec extends StageSpec: ) } // a `:==` write to a shared variable (the multi-port RAM idiom) renders as a plain non-blocking - // `<=` inside the clocked process, the portable RAM-inference template form (issue #437) + // `<=` inside the clocked process, the portable RAM-inference template form (issue #437). The + // process itself is a plain `always` and not an `always_ff`, since the latter guarantees a + // single driver for what it writes (issue #473) test("shared variable (RAM) writes are non-blocking") { class SimpleRAM extends EDDesign: val clk = Bit <> IN @@ -3213,7 +3215,7 @@ class PrintVerilogCodeSpec extends StageSpec: | /* verilator lint_off MULTIDRIVEN */ | logic [7:0] ram [0:15]; | /* verilator lint_on MULTIDRIVEN */ - | always_ff @(posedge clk) + | always @(posedge clk) | begin | if (we) ram[addr] <= din; | else dout <= ram[addr]; diff --git a/lib/src/test/resources/ref/docExamples.TrueDPRSpec/verilog.sv2009/hdl/TrueDPR.sv b/lib/src/test/resources/ref/docExamples.TrueDPRSpec/verilog.sv2009/hdl/TrueDPR.sv index 077f6b386..e87281268 100644 --- a/lib/src/test/resources/ref/docExamples.TrueDPRSpec/verilog.sv2009/hdl/TrueDPR.sv +++ b/lib/src/test/resources/ref/docExamples.TrueDPRSpec/verilog.sv2009/hdl/TrueDPR.sv @@ -20,12 +20,12 @@ module TrueDPR#( /* verilator lint_off MULTIDRIVEN */ logic [DATA_WIDTH - 1:0] ram [0:(2 ** ADDR_WIDTH) - 1]; /* verilator lint_on MULTIDRIVEN */ - always_ff @(posedge a_clk) + always @(posedge a_clk) begin a_q <= ram[a_addr]; if (a_we) ram[a_addr] <= a_data; end - always_ff @(posedge b_clk) + always @(posedge b_clk) begin b_q <= ram[b_addr]; if (b_we) ram[b_addr] <= b_data; From 3e0d457c60baed92bde81b78869ad257fa13ed85 Mon Sep 17 00:00:00 2001 From: Oron Port Date: Mon, 10 Aug 2026 13:03:09 +0300 Subject: [PATCH 06/40] compiler_stages+docs: a port-referenced constant is declared in the parameter port list A module body declaration is positionally after the header that names it, so a derived port width emitted as a body `localparam` and referenced from the ANSI port list is a use-before-declare, which a conforming frontend rejects. Those constants now join the parameter port list, spelled `localparam` where 1800-2009 allows it there and `parameter` in the earlier ANSI dialects. Under a non-ANSI header (v95) everything stays in the body, where they now lead the port declarations instead of trailing them. A width/length query follows the VHDL printer and keeps its native `$bits`/ `$size` spelling only over a CONSTANT argument: the query may now print into a parameter port list entry, where naming a port has the same defect. Over a non-constant argument the width parameter expression is inlined, as the pre- SystemVerilog dialects already did. VHDL solves the same restriction a stage earlier, by converting these into design parameters (`LocalToDesignParams`), which is why that stage stays VHDL-only. Fixes #472 Co-Authored-By: Claude Opus 5 (1M context) --- .../compiler/stages/LocalToDesignParams.scala | 5 +++ .../stages/verilog/VerilogOwnerPrinter.scala | 43 +++++++++++++++---- .../stages/verilog/VerilogValPrinter.scala | 32 +++++++++----- .../StagesSpec/PrintVerilogCodeSpec.scala | 27 +++++++----- docs/user-guide/design-hierarchy/index.md | 9 ++-- 5 files changed, 82 insertions(+), 34 deletions(-) diff --git a/compiler/stages/src/main/scala/dfhdl/compiler/stages/LocalToDesignParams.scala b/compiler/stages/src/main/scala/dfhdl/compiler/stages/LocalToDesignParams.scala index 2b5ac3e03..452c677fd 100644 --- a/compiler/stages/src/main/scala/dfhdl/compiler/stages/LocalToDesignParams.scala +++ b/compiler/stages/src/main/scala/dfhdl/compiler/stages/LocalToDesignParams.scala @@ -8,6 +8,11 @@ import dfhdl.options.CompilerOptions /** This stage converts local parameters that are used in IOs to be design parameters with default * values, since VHDL does not support local parameters for IO access. These kind of design * parameters remain at their default (relative) values and are never directly applied. + * + * Verilog has the same restriction and does NOT use this stage: a module body declaration is + * positionally after the header that names it, so an IO-referenced local parameter is a + * use-before-declare there too. It is fixed in the Verilog printer instead (a `localparam` in the + * parameter port list), which keeps the value non-overridable at the instantiation site. */ case object LocalToDesignParams extends HierarchyStage: override def runCondition(using co: CompilerOptions): Boolean = diff --git a/compiler/stages/src/main/scala/dfhdl/compiler/stages/verilog/VerilogOwnerPrinter.scala b/compiler/stages/src/main/scala/dfhdl/compiler/stages/verilog/VerilogOwnerPrinter.scala index 87a5db053..0fdc78bcf 100644 --- a/compiler/stages/src/main/scala/dfhdl/compiler/stages/verilog/VerilogOwnerPrinter.scala +++ b/compiler/stages/src/main/scala/dfhdl/compiler/stages/verilog/VerilogOwnerPrinter.scala @@ -64,17 +64,28 @@ protected trait VerilogOwnerPrinter extends AbstractOwnerPrinter: |${ports.hindent} |)""".stripMargin) val localTypeDcls = printer.csLocalTypeDcls(design) + // the local constants the PORTS reference. A module body declaration is positionally after + // the header that names it, so declaring these in the body is a use-before-declare (issue + // #472): with an ANSI header they are declared in the parameter port list instead, and with a + // non-ANSI header (where the ports are body declarations too) they only have to lead the port + // declarations, which the `constIntDcls` block below does. Either way they are dropped from + // the value/method ordering. VHDL has the same restriction and solves it a stage earlier, by + // converting them into design parameters (`LocalToDesignParams`). + val ioConsts: List[DFVal.CanBeExpr] = design.getIOLocalParams + val ioConstSet: Set[DFVal] = ioConsts.toSet // design parameters (non-ANSI dialects only) and the constants named by a local type // declaration (a vector/array width); both must precede the local type declarations val typeConsts = printer.typeReferencedConsts(design).toSet + val leadingConsts: Set[DFVal] = + if (parameterizedModuleSupport) typeConsts -- ioConstSet else typeConsts ++ ioConstSet val constIntDcls = designMembers.view .flatMap { case p: DesignParam => if (parameterizedModuleSupport) None else Some(p) - case c @ DclConst() if typeConsts.contains(c) => Some(c) - case _ => None + case c @ DclConst() if leadingConsts.contains(c) => Some(c) + case _ => None } .map(x => printer.csDFMember(x) + ";") .mkString("\n") @@ -123,10 +134,11 @@ protected trait VerilogOwnerPrinter extends AbstractOwnerPrinter: |${p.csMethodDcl(block)}""".stripTrailing val orderedDcls = printer.joinLocalDecls( printer.localDeclsOrdered(design, methodPrinters.map(_._1)).flatMap { - case LocalDecl.Const(c) => csDcl(c).map((false, _)) - case LocalDecl.Signal(s) => csDcl(s).map((false, _)) - case LocalDecl.StaticMethod(b) => List((true, csMethodLocal(b))) - case LocalDecl.EDMethod(b) => List((true, csMethodLocal(b))) + case LocalDecl.Const(c) if ioConstSet.contains(c) => Nil + case LocalDecl.Const(c) => csDcl(c).map((false, _)) + case LocalDecl.Signal(s) => csDcl(s).map((false, _)) + case LocalDecl.StaticMethod(b) => List((true, csMethodLocal(b))) + case LocalDecl.EDMethod(b) => List((true, csMethodLocal(b))) } ) val declarations = @@ -161,10 +173,23 @@ protected trait VerilogOwnerPrinter extends AbstractOwnerPrinter: val csTypeNoLogic = if (printer.supportLogicType) csType else csType.replace("logic ", "") s"parameter ${csTypeNoLogic}${param.getName}$defaultValue" } + // the port-referenced local constants, declared after the design parameters their values + // read (`getIOLocalParams` yields them in dependency order) + val ioConstList = + if (!parameterizedModuleSupport) Nil + else + ioConsts.map { c => + val keyword = if (printer.supportParamPortListLocalParam) "localparam" else "parameter" + val csType = printer.csDFType(c.dfType).emptyOr(_ + " ") + val csTypeNoLogic = if (printer.supportLogicType) csType else csType.replace("logic ", "") + val arrRange = printer.csDFVectorRanges(c.dfType) + s"$keyword ${csTypeNoLogic}${c.getName}$arrRange = ${printer.csDFValExpr(c)}" + } + val headerParamList = designParamList ++ ioConstList val designParamCS = - if (designParamList.length == 0 || !parameterizedModuleSupport) "" - else if (designParamList.length == 1) designParamList.mkString("#(", ", ", ")") - else "#(" + designParamList.mkString("\n", ",\n", "\n").hindent(2) + ")" + if (headerParamList.length == 0 || !parameterizedModuleSupport) "" + else if (headerParamList.length == 1) headerParamList.mkString("#(", ", ", ")") + else "#(" + headerParamList.mkString("\n", ",\n", "\n").hindent(2) + ")" val includeModuleDefs = if (printer.allowTypeDef || !printer.hasGlobalContent) "" else s"""`include "${printer.globalFileName}"""" diff --git a/compiler/stages/src/main/scala/dfhdl/compiler/stages/verilog/VerilogValPrinter.scala b/compiler/stages/src/main/scala/dfhdl/compiler/stages/verilog/VerilogValPrinter.scala index 7507e3274..316b7d5cf 100644 --- a/compiler/stages/src/main/scala/dfhdl/compiler/stages/verilog/VerilogValPrinter.scala +++ b/compiler/stages/src/main/scala/dfhdl/compiler/stages/verilog/VerilogValPrinter.scala @@ -39,6 +39,13 @@ protected trait VerilogValPrinter extends AbstractValPrinter: printer.dialect match case VerilogDialect.v95 | VerilogDialect.v2001 => false case _ => true + // a `localparam` inside the parameter port list arrived with 1800-2009; the earlier ANSI + // dialects declare a port-list parameter with `parameter` (DFHDL never overrides it at an + // instantiation site either way) + val supportParamPortListLocalParam: Boolean = + printer.dialect match + case VerilogDialect.v95 | VerilogDialect.v2001 | VerilogDialect.sv2005 => false + case _ => true def csConditionalExprRel(csExp: String, ch: DFConditional.Header): String = printer.unsupported def csDesignParamDefault(param: DesignParam): String = param.defaultValRef.get match @@ -236,16 +243,21 @@ protected trait VerilogValPrinter extends AbstractValPrinter: val supportsQuerySyntax = printer.dialect match case VerilogDialect.v95 | VerilogDialect.v2001 => false case _ => true - (dfVal.op, arg.get.dfType) match - // a vector's element count (`DropStructsVecs` folds these for the pre-SV - // dialects, so only a SystemVerilog `$size` spelling is ever needed) - case (Func.Op.length, _: DFVector) => - if (supportsQuerySyntax) s"$$size($argStrB)" else printer.unsupported - case (_, argType) => - if (supportsQuerySyntax) s"$$bits($argStrB)" - // pre-SystemVerilog dialects have no width query; inline the width - // parameter expression, which is what the type declaration itself prints - else csInlinedWidth(argType) + // Only a CONSTANT argument may be named from every context this query can print + // into -- in particular a design-level constant becomes a module PARAMETER whose + // default cannot reference a port. A non-constant argument (a port, a variable) + // inlines the width parameter expression instead, which is what the argument's own + // declaration prints. The pre-SystemVerilog dialects have no width query at all and + // always inline. + if (supportsQuerySyntax && arg.get.isConst) + (dfVal.op, arg.get.dfType) match + // a vector's element count + case (Func.Op.length, _: DFVector) => s"$$size($argStrB)" + case _ => s"$$bits($argStrB)" + else + (dfVal.op, arg.get.dfType) match + case (Func.Op.length, vec: DFVector) => vec.cellDimParamRefs.head.refCodeString + case (_, argType) => csInlinedWidth(argType) case _ => printer.unsupported end match // multiarg func diff --git a/compiler/stages/src/test/scala/StagesSpec/PrintVerilogCodeSpec.scala b/compiler/stages/src/test/scala/StagesSpec/PrintVerilogCodeSpec.scala index 4e4c12d8e..3fdc4e378 100644 --- a/compiler/stages/src/test/scala/StagesSpec/PrintVerilogCodeSpec.scala +++ b/compiler/stages/src/test/scala/StagesSpec/PrintVerilogCodeSpec.scala @@ -3376,9 +3376,12 @@ class PrintVerilogCodeSpec extends StageSpec: ) } - // width/length queries print natively in SystemVerilog: `$bits` for the total width, - // `$size` for a vector's element count; a named query binding becomes a localparam - // over the query, keeping the value-to-width relation in the generated code + // width/length queries over a CONSTANT argument print natively in SystemVerilog (`$bits` for + // the total width, `$size` for a vector's element count), so a named query binding keeps the + // value-to-width relation in the generated code. Over a NON-constant argument (a port), the + // width parameter expression is inlined instead: the query may print into a parameter port + // list entry, where naming a port is a use-before-declare. A port-referenced binding is + // declared there rather than in the module body, which the port list precedes (issue #472) test("width/length query emission") { class WidthQuery( val W: Int <> CONST = 4, @@ -3406,7 +3409,10 @@ class PrintVerilogCodeSpec extends StageSpec: |module WidthQuery#( | parameter int W = 4, | parameter int N = 3, - | parameter logic [7:0] INIT = 8'h00 + | parameter logic [7:0] INIT = 8'h00, + | parameter int LI = $bits(INIT), + | parameter int WID = N * W, + | parameter int LEN = N |)( | input wire logic [W - 1:0] vec [0:N - 1], | input wire logic [LI - 1:0] din, @@ -3415,9 +3421,6 @@ class PrintVerilogCodeSpec extends StageSpec: | output logic [LEN - 1:0] cnt |); | `include "dfhdl_defs.svh" - | localparam int LI = $bits(INIT); - | localparam int LEN = $size(vec); - | localparam int WID = $bits(vec); | assign dout = din; | assign flat = {vec}; | assign cnt = LEN'(1'd0); @@ -3428,7 +3431,9 @@ class PrintVerilogCodeSpec extends StageSpec: // the pre-SystemVerilog dialects have no width query syntax; the width parameter // expression is inlined instead, and a vector's length query is folded by - // `DropStructsVecs` into the element-count parameter before the vector is flattened + // `DropStructsVecs` into the element-count parameter before the vector is flattened. + // The parameter port list has no `localparam` before 1800-2009, so the port-referenced + // bindings are declared there as plain parameters (issue #472) test("width/length query emission under v2001") { given options.CompilerOptions.Backend = _.verilog.v2001 class WidthQueryOld( @@ -3451,15 +3456,15 @@ class PrintVerilogCodeSpec extends StageSpec: | |module WidthQueryOld#( | parameter integer W = 4, - | parameter integer N = 3 + | parameter integer N = 3, + | parameter integer WID = W * N, + | parameter integer LEN = N |)( | input wire [(W * N) - 1:0] vec, | output wire [WID - 1:0] flat, | output wire [LEN - 1:0] cnt |); | `include "dfhdl_defs.vh" - | parameter integer LEN = N; - | parameter integer WID = W * N; | assign flat = `EXTEND_U(vec, W * N, W * N); | assign cnt = `EXTEND_U(1'd0, 1, LEN); |endmodule diff --git a/docs/user-guide/design-hierarchy/index.md b/docs/user-guide/design-hierarchy/index.md index 72275ce5c..a785452e9 100644 --- a/docs/user-guide/design-hierarchy/index.md +++ b/docs/user-guide/design-hierarchy/index.md @@ -429,7 +429,10 @@ class InitReg( `default_nettype none `timescale 1ns/1ps -module InitReg#(parameter logic [7:0] INIT = 8'h00)( +module InitReg#( + parameter logic [7:0] INIT = 8'h00, + localparam int LEN = $bits(INIT) +)( input wire logic clk, input wire logic rst, /* data input */ @@ -438,8 +441,6 @@ module InitReg#(parameter logic [7:0] INIT = 8'h00)( output logic [LEN - 1:0] dout ); `include "dfhdl_defs.svh" - /* the register length, derived from the initialization parameter */ - localparam int LEN = $bits(INIT); always_ff @(posedge clk) begin if (rst == 1'b1) dout <= INIT; @@ -490,7 +491,7 @@ Points worth noting in this pattern: - Declaring `LEN` as a separate design parameter next to `INIT` is not possible in DFHDL, and it is also not needed: the width of a `Bits[Int] <> CONST` parameter travels with the applied argument itself, so `LEN` always agrees with `INIT` by construction. Two separate parameters would have to be kept consistent manually at every instantiation, a mismatch the derived form rules out entirely. - The width of `INIT` is set by the APPLIED argument (here the default `h"00"`, so 8). In the generated code that width is fixed in the module/entity declaration, while the VALUE of `INIT` remains overridable at that width. Applying an argument of a different width elaborates a design with the corresponding widths. -- `val LEN = INIT.length` is a named constant, so the generated code declares it by name (a Verilog `localparam`, a VHDL `generic`) and references it wherever it is used. The query itself is spelled natively, `$bits(INIT)` in SystemVerilog and `INIT'length` in VHDL, so the generated code keeps the `INIT`-to-`LEN` relation instead of a baked number (dialects without a width query, such as Verilog-2001, inline the width value instead). +- `val LEN = INIT.length` is a named constant, so the generated code declares it by name and references it wherever it is used. Because the ports name it, it is declared alongside the parameters (a SystemVerilog `localparam` in the parameter port list, a VHDL `generic`) rather than in the module body or architecture, which the port declarations precede. The query itself is spelled natively, `$bits(INIT)` in SystemVerilog and `INIT'length` in VHDL, so the generated code keeps the `INIT`-to-`LEN` relation instead of a baked number (dialects without a width query, such as Verilog-2001, inline the width value instead). - The same recipe serves any derived parameter, for example a `clog2`-computed address width: declare the primary parameters, and compute the derived constants in the body. See also the [width derivation idiom][width-length-ops] (`UInt.until(DEPTH).width`). #### Design Parameter Access Rules From 3040d5c0760ff3bada624f5eb39b5ed6b41d3385 Mon Sep 17 00:00:00 2001 From: Oron Port Date: Mon, 10 Aug 2026 13:03:56 +0300 Subject: [PATCH 07/40] skills: record the two backend-legality lessons from #472/#473 A language keyword that carries a guarantee needs the IR fact that contradicts it, and a sibling backend's fix mechanism tells you the rule rather than the layer it belongs at. Co-Authored-By: Claude Opus 5 (1M context) --- .claude/commands/bugfix.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/.claude/commands/bugfix.md b/.claude/commands/bugfix.md index 224e6fe05..34732e749 100644 --- a/.claude/commands/bugfix.md +++ b/.claude/commands/bugfix.md @@ -952,6 +952,34 @@ When you rewrite an existing predicate into a shared one, expand both forms case confirm they agree on every branch, including the ones no test reaches (a `VAR.SHARED` inside an HDL method). A "simplification" that quietly moves an edge case is a second bug riding along. +- **A keyword that carries a language-level GUARANTEE needs the IR fact that contradicts it.** + SystemVerilog `always_ff` promises a single driver for everything the process writes, which a + `VAR.SHARED` (a multi-ported memory) contradicts by construction, so a conforming frontend + rejects the pair while permissive ones (Yosys, Verilator) accept it (issue #473). Degrade only + the processes that actually make the contradicted claim (the *writers*; a reader is + unconstrained), and derive the fact from an `analysis` predicate rather than from the printer. + A pragma already emitted for the same reason (`/* verilator lint_off MULTIDRIVEN */`) is a + *lint* suppression and cannot rescue a language rule, so its presence is a hint that the + information is available, not that the case is handled. + +- **When the sibling backend already solves a restriction, its mechanism tells you the RULE, not + the LAYER.** VHDL fixes "a declaration the interface names cannot live in the body" with a + stage (`LocalToDesignParams`, which converts such constants into design parameters). Verilog + has the same restriction (issue #472: a body `localparam` referenced from the ANSI port list is + a use-before-declare), and reusing the stage for it introduced two fresh defects that the + printer-side equivalent has neither of: a `DropStructsVecs` length fold whose `Ident` wrapper + became an anonymous design-param default and then printed as a stray statement (the + `case Ident(_) => true` viewability exemption in `DFOwnerPrinter` makes every anonymous ident a + statement), and the v95 body-parameter path folding a derived default to a wrong literal + (`csDesignParamDefault` resolves constant data for a top design, so `W * N` became `24` and an + unresolvable width became `0`). Enumerate what else consumes the IR shape a stage would create + before preferring a stage to a printer fix; when only the emitted text is wrong, the printer is + the layer, and the shared IR stays backend-neutral. What DOES transfer between the backends is + the sibling's *rule*: VHDL keeps a native width query (`'length` / `bitWidth`) only over a + CONSTANT argument, because the query may print into a generic default where naming a port is + illegal, and the moment Verilog's constants moved into the parameter port list `$bits(vec)` had + the identical defect. + - **One literal-format knob can be semantically overloaded across print contexts.** The Verilog bubble digit was `?` everywhere, which is correct in a `casez` pattern (where `?` aliases `z`, the wildcard) and wrong in every value position (where it *drives* high-impedance; the value From ec89141c7e26713c9a2ff1f8d612eb4161083e93 Mon Sep 17 00:00:00 2001 From: Oron Port Date: Mon, 10 Aug 2026 13:55:42 +0300 Subject: [PATCH 08/40] core+plugin: the frontend namespace object is public, named `__hdl` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `protected object hdl` was reachable from user code after all: an implicit conversion into an unbounded ascription (`val acc: Bits[Int] <> VAL = a.bits`) selected the member through the object rather than through its package-level export forwarder, and the inaccessible prefix was reported at the user's own line as `illegal access to protected object hdl in package dfhdl` — naming an object no user code mentions. Making it public removes the only spelling that can fail; the leading underscores keep it out of the way, since `import dfhdl.*` re-exports every member anyway. Fixes #468 Co-Authored-By: Claude Opus 5 (1M context) --- .claude/commands/new-stage.md | 2 +- .../dfhdl/compiler/patching/MetaDesign.scala | 2 +- core/src/main/scala/dfhdl/hdl.scala | 17 ++++++++++++++--- .../dfhdl/platforms/resources/Resource.scala | 2 +- core/src/test/scala/CoreSpec/DFBitsSpec.scala | 16 ++++++++++++++++ plugin/src/main/scala/plugin/CommonPhase.scala | 2 +- plugin/src/main/scala/plugin/LoopFSMPhase.scala | 4 ++-- .../src/main/scala/plugin/PureCheckPhase.scala | 2 +- 8 files changed, 37 insertions(+), 10 deletions(-) diff --git a/.claude/commands/new-stage.md b/.claude/commands/new-stage.md index 66e39314e..8cf02daf8 100644 --- a/.claude/commands/new-stage.md +++ b/.claude/commands/new-stage.md @@ -1322,7 +1322,7 @@ abstract class StageSpec(stageCreatesUnrefAnons: Boolean = false) patch list when the Add's members reference the replaced instance — ref-table effects apply in list order, so the Add's references then resolve to the replacement. 19. **Name shadowing inside MetaDesign bodies** — `MetaDesign` extends `Design`, whose - `export dfhdl.hdl.*` brings frontend names (`DFVal`, `StepBlock`, …) into the *class* scope, + `export dfhdl.__hdl.*` brings frontend names (`DFVal`, `StepBlock`, …) into the *class* scope, shadowing the file-level `import dfhdl.compiler.ir.*` wildcard for overlapping names. Inside a MetaDesign body, add `import dfhdl.compiler.ir` at the file top and qualify IR types as `ir.DFVal`, `ir.StepBlock`, etc., importing only the core names actually needed (e.g. diff --git a/core/src/main/scala/dfhdl/compiler/patching/MetaDesign.scala b/core/src/main/scala/dfhdl/compiler/patching/MetaDesign.scala index cb15d99ad..b037f8670 100644 --- a/core/src/main/scala/dfhdl/compiler/patching/MetaDesign.scala +++ b/core/src/main/scala/dfhdl/compiler/patching/MetaDesign.scala @@ -115,7 +115,7 @@ abstract class MetaDesign[+D <: DomainType]( // meta designs may be intermediate erroneous designs final override private[dfhdl] def skipChecks: Boolean = true - export dfhdl.hdl.{assert => _, *} + export dfhdl.__hdl.{assert => _, *} export dfhdl.core.{asValAny, asVarAny, asVarOf, asDclAny, asConstAny, cloneAnonValueAndDepsHere} export dfhdl.core.IntParam.* extension [T <: DFTypeAny, A, C, I, P](dfVal: DFVal[T, Modifier[A, C, I, P]]) diff --git a/core/src/main/scala/dfhdl/hdl.scala b/core/src/main/scala/dfhdl/hdl.scala index e4a3ceac0..c6c985259 100644 --- a/core/src/main/scala/dfhdl/hdl.scala +++ b/core/src/main/scala/dfhdl/hdl.scala @@ -1,5 +1,16 @@ package dfhdl -protected object hdl: + +// The DFHDL frontend namespace. Users never name it: everything in it is re-exported by the +// package-level `export __hdl.*` below, so `import dfhdl.*` is the only spelling needed. It exists +// as an object because `MetaDesign` and `Resource` re-export it as a unit, which a package cannot +// provide. +// +// It has to be PUBLIC, and the name is the deterrent instead. While it was `protected`, some typer +// paths selected a member through this object rather than through its package-level export +// forwarder, and the resulting prefix is inaccessible from user code: `a.bits` assigned to a +// `Bits[Int] <> VAL` reported `illegal access to protected object hdl in package dfhdl` at the +// user's own line, naming an object no user code mentions (issue #468). +object __hdl: class dsn extends scala.annotation.StaticAnnotation import core.IntP export core.DFBoolOrBit.Val.Ops.* @@ -115,6 +126,6 @@ protected object hdl: try props.load(inputStream) finally inputStream.close() props.getProperty("version") -end hdl +end __hdl -export hdl.* +export __hdl.* diff --git a/core/src/main/scala/dfhdl/platforms/resources/Resource.scala b/core/src/main/scala/dfhdl/platforms/resources/Resource.scala index 721802db3..ed8d087e8 100644 --- a/core/src/main/scala/dfhdl/platforms/resources/Resource.scala +++ b/core/src/main/scala/dfhdl/platforms/resources/Resource.scala @@ -81,5 +81,5 @@ object Resource extends ResourceLP: def apply(resource1: R, resourceOrValue: T)(using DFC): Out = cc.connect(resource1, resourceOrValue) end given - export dfhdl.hdl.<> + export dfhdl.__hdl.<> end Resource diff --git a/core/src/test/scala/CoreSpec/DFBitsSpec.scala b/core/src/test/scala/CoreSpec/DFBitsSpec.scala index bec03f38e..b30c25f44 100644 --- a/core/src/test/scala/CoreSpec/DFBitsSpec.scala +++ b/core/src/test/scala/CoreSpec/DFBitsSpec.scala @@ -389,4 +389,20 @@ class DFBitsSpec extends DFSpec: ) val o2 = b8(u2.resize) } + // A `Bit` value's `.bits` reaches an unbounded `Bits[Int] <> VAL` ascription (the accumulator + // form of the elaboration-time concatenation idiom) through the implicit conversion. The + // conversion used to select the frontend namespace object through a prefix inaccessible from + // user code, so this failed to COMPILE with `illegal access to protected object hdl in package + // dfhdl` (issue #468) — which is what this test pins, the code string below merely confirming + // the value that reaches the ascription. + test("Bit conversion into an unbounded Bits ascription") { + assertCodeString { + """|val bit = Bit <> VAR + |val acc = bit.toBits(1) + |""".stripMargin + } { + val bit = Bit <> VAR + val acc: Bits[Int] <> VAL = bit.bits + } + } end DFBitsSpec diff --git a/plugin/src/main/scala/plugin/CommonPhase.scala b/plugin/src/main/scala/plugin/CommonPhase.scala index 090922e4b..2e94509c2 100755 --- a/plugin/src/main/scala/plugin/CommonPhase.scala +++ b/plugin/src/main/scala/plugin/CommonPhase.scala @@ -556,7 +556,7 @@ abstract class CommonPhase extends PluginPhase: outNBArgAnnotSym = requiredClass("dfhdl.core.Modifier.OUT.NB") contextFunctionSym = defn.FunctionSymbol(1, isContextual = true) genContainerParamSym = requiredMethod("dfhdl.core.r__For_Plugin.genContainerParam") - bTpe = requiredClassRef("dfhdl.hdl.B") + bTpe = requiredClassRef("dfhdl.__hdl.B") if (debugFilter(tree.source.path.toString)) println( s"""=============================================================== diff --git a/plugin/src/main/scala/plugin/LoopFSMPhase.scala b/plugin/src/main/scala/plugin/LoopFSMPhase.scala index feb567c7f..63fa67646 100644 --- a/plugin/src/main/scala/plugin/LoopFSMPhase.scala +++ b/plugin/src/main/scala/plugin/LoopFSMPhase.scala @@ -261,7 +261,7 @@ class LoopFSMPhase(setting: Setting) extends CommonPhase: None end Foreach - // Matched by name, like `HackedGuard` does for `BooleanHack`: `dfhdl.hdl` re-exports `LoopOps`, + // Matched by name, like `HackedGuard` does for `BooleanHack`: `dfhdl.__hdl` re-exports `LoopOps`, // so the call site resolves to an export forwarder rather than to the method in `LoopOps`. private def isFallThroughSym(sym: Symbol)(using Context): Boolean = sym.exists && sym.name.toString == "FALL_THROUGH" @@ -282,7 +282,7 @@ class LoopFSMPhase(setting: Setting) extends CommonPhase: end FallThroughMark // `waitUntil(FALL_THROUGH(cond))(using dfc, waitScope)` and its `waitWhile` counterpart. The - // method is matched by name for the same reason `FallThroughMark` is: `dfhdl.hdl` re-exports + // method is matched by name for the same reason `FallThroughMark` is: `dfhdl.__hdl` re-exports // `Wait.Ops`, so the call site resolves to an export forwarder. The `Boolean` distinguishes the // two polarities (`waitUntil` keeps the condition, `waitWhile` negates it). private object CondWaitMark: diff --git a/plugin/src/main/scala/plugin/PureCheckPhase.scala b/plugin/src/main/scala/plugin/PureCheckPhase.scala index 43fdff173..710e7f0d3 100644 --- a/plugin/src/main/scala/plugin/PureCheckPhase.scala +++ b/plugin/src/main/scala/plugin/PureCheckPhase.scala @@ -175,7 +175,7 @@ class PureCheckPhase(setting: Setting) extends CapturePhase: } ) - // members of the `dfhdl` root package itself (the `hdl` object and the package-level export + // members of the `dfhdl` root package itself (the `__hdl` object and the package-level export // forwarders of the core ops); library code like `dfhdl.lib` lives in SUB-packages and is // deliberately not covered private val dfhdlRootPkgCache = mutable.Map.empty[Symbol, Boolean] From 734c4e80f32631f8c4888ee718fff7ab9ccc0c92 Mon Sep 17 00:00:00 2001 From: Oron Port Date: Mon, 10 Aug 2026 14:07:16 +0300 Subject: [PATCH 09/40] skills: record the inaccessible-prefix species from #468 An error naming a DFHDL-internal symbol at the user's line is a resolution fact, not a reporting bug: find the qualifier in the typer tree, and fix it by publishing the namespace object rather than by chasing the resolution. Co-Authored-By: Claude Opus 5 (1M context) --- .claude/commands/bugfix.md | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/.claude/commands/bugfix.md b/.claude/commands/bugfix.md index 34732e749..8a5a837bc 100644 --- a/.claude/commands/bugfix.md +++ b/.claude/commands/bugfix.md @@ -93,6 +93,36 @@ regression test is a lib spec design using the colliding name with NO explicit a auto-injection path must fire); it pins the fix at compile level, since the unfixed plugin fails the whole test-scope compilation. +### An error naming a DFHDL-internal symbol at the user's line is an inaccessible PREFIX + +`illegal access to protected object hdl in package dfhdl from class Probe` reads like a +reporting bug and is a resolution fact: the typer built a `Select` whose qualifier is a +library-internal object, and user code cannot name that qualifier. Find the qualifier rather +than the error: compile the unit with `-Xprint:typer` and grep the tree for the internal name. +The same op resolved elsewhere in the same run through the *public* spelling +(`dfhdl.bits` in every ordinary use, `dfhdl.hdl.bits` in the one failing use), and that +side-by-side is the whole diagnosis (issue #468). + +Three properties of this species are worth knowing before chasing it: + +- **The access check is not in the typer.** With other typer errors in the same run the tree is + printed and no access error appears at all; it surfaces in a later phase, which is why the + diagnostic carries no inline stack and no context to work back from. +- **`-P:dfhdl.plugin:disableCustomPrinter` rules the plugin out in one compile**, and should be + the first thing tried on any raw-looking scalac diagnostic. +- **The fix is accessibility, not resolution.** A namespace object that exists only to be + re-exported (`export hdl.*` at package level, plus `MetaDesign` / `Resource` re-exporting it as + a unit, which a package cannot provide) has no user-facing API of its own, so publishing it + costs nothing and removes the only spelling that can fail. Make the NAME the deterrent + (`__hdl`), not the access modifier. Renaming such an object means updating the by-name lookups + too: `requiredClassRef("dfhdl.__hdl.B")` in the plugin is not found by a search for the object. + +Minimizing this one outside DFHDL did NOT succeed: the leak needs the compiler to accept a +SECOND typing attempt (the accepted tree fixes the extension's width parameter from its implicit, +not from the expected type), and a synthetic version of the same shape fails at the first attempt +instead. Before spending on such a minimization, check whether the project-side fix is a one-word +change; here it was. + ### Minimize outside DFHDL, early Get off the DFHDL types as fast as possible. Two plugin-free sandboxes: From b123970d8cf5cb093248a174b88a1e0d29290c97 Mon Sep 17 00:00:00 2001 From: Oron Port Date: Tue, 11 Aug 2026 01:35:25 +0300 Subject: [PATCH 10/40] update benchmarks formatting --- benchmarks | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/benchmarks b/benchmarks index 8d0ad9d09..433a8f071 160000 --- a/benchmarks +++ b/benchmarks @@ -1 +1 @@ -Subproject commit 8d0ad9d09541f70ba41d13e976bfe8a145ce394e +Subproject commit 433a8f071483a7f29ef7c6672d3693034933e9da From 958e2fbfdbdb2a001695850b8103636bca14030e Mon Sep 17 00:00:00 2001 From: Oron Port Date: Tue, 11 Aug 2026 03:38:03 +0300 Subject: [PATCH 11/40] ir+core+docs: single-bit arithmetic; bitwise ops check parametric widths (#476, #474) Two independent defects in the elaboration-time width algebra. #476: `UInt(1) + 1` (and any other 1-bit arithmetic, literal or not) failed with "Signed value width must be larger than 1". The target-context widening rule compared the target against the value's width by wrapping the latter in a freshly built `DFXInt(true, funcWidth, BitAccurate)`, purely so `compareWidths` had a type to take, and that construction runs `SInt`'s own width constraint. The comparison now decides on the two IR width refs directly, which also stops minting a throwaway ref per call. #474: a bitwise `^`/`&`/`|` whose operand widths were not both statically known performed no width check at all, so `Bits(LEN) ^ Bits[8]` elaborated silently and emitted an operation the backend zero-extends. The elaboration half of the `LW == RW` check now covers that branch, reporting the same message as the compile-time half with each width rendered relative to the error site. The proof keeps design parameters OPAQUE (`IntParamRef.isProvablyEqualTo`), which is what makes it sound: the resolving comparison reads a parameter's DEFAULT while the design's own body elaborates, so `LEN` defaulting to 8 would "prove equal" to an 8-bit constant and the mismatch would only materialize at an instantiation site applying `LEN = 16`. Symbolic re-spelling still matches (`Bits(W + 1)` against `Bits(1 + W)`). Co-Authored-By: Claude Opus 5 (1M context) --- .../main/scala/dfhdl/compiler/ir/DFRef.scala | 9 +++ .../main/scala/dfhdl/core/CarryPromote.scala | 42 ++++++++----- core/src/main/scala/dfhdl/core/DFBits.scala | 4 +- .../src/main/scala/dfhdl/core/DFDecimal.scala | 26 +++++++- core/src/main/scala/dfhdl/core/DFType.scala | 9 +++ .../test/scala/CoreSpec/DFDecimalSpec.scala | 28 +++++++++ docs/user-guide/type-system/index.md | 39 ++++++++++++ .../test/scala/ElaborationChecksSpec.scala | 62 +++++++++++++++++++ 8 files changed, 201 insertions(+), 18 deletions(-) diff --git a/compiler/ir/src/main/scala/dfhdl/compiler/ir/DFRef.scala b/compiler/ir/src/main/scala/dfhdl/compiler/ir/DFRef.scala index 139b60f51..ae1986d3b 100644 --- a/compiler/ir/src/main/scala/dfhdl/compiler/ir/DFRef.scala +++ b/compiler/ir/src/main/scala/dfhdl/compiler/ir/DFRef.scala @@ -223,6 +223,15 @@ object IntParamRef: decision <- IntExprCalc.widthFitCompare(lVal, rVal) yield decision end widthFitGE + // Provable EQUALITY with design parameters kept opaque: `LEN + 1` matches `1 + LEN`, while + // `LEN` against a literal `8` stays unproven even when the parameter's applied (or default) + // value happens to be 8. This is the predicate for a rule about a DESIGN's own legality, + // which must hold for every applied parameter value; `isSimilarTo` resolves the applied + // expression instead and is for post-elaboration equivalence, where the instantiation is + // known. Note the resolving form is doubly wrong for an elaboration-time rule: while a + // design's own body elaborates, a parameter resolves to its DEFAULT. + def isProvablyEqualTo(that: IntParamRef)(using MemberGetSet): Boolean = + constDiffFrom(that).contains(0) // The constant difference `this - that` when all symbolic terms cancel (see `compare`); // `None` otherwise. Lets printers render a widening as a relative extension (`.eby(k)`, // `EBY_U`/`EBY_S`, VHDL `eby`) exactly when the width delta folds to a literal. Design diff --git a/core/src/main/scala/dfhdl/core/CarryPromote.scala b/core/src/main/scala/dfhdl/core/CarryPromote.scala index 6a6a43395..ad845eef5 100644 --- a/core/src/main/scala/dfhdl/core/CarryPromote.scala +++ b/core/src/main/scala/dfhdl/core/CarryPromote.scala @@ -18,6 +18,14 @@ import DFDecimal.Extensions.* * in `DFDecimal` and `DFBits`. */ private[core] object CarryPromote: + /** The total-width ref of an integer type: its fraction is 0, so the magnitude ref IS the width + * ref. `None` for any other type, which never widens. + */ + private def widthRefOpt(dfTypeIR: ir.DFType): Option[ir.IntParamRef] = + dfTypeIR match + case dec: ir.DFDecimal => Some(dec.magnitudeWidthParamRef) + case _ => None + /** Deep target-context widening, matching Verilog's assignment-context width propagation: an * anonymous non-carry `+`/`-`/`*` cone converted to a WIDER type is re-evaluated at the target's * width and sign. Every func in the cone is retyped to the target and every leaf is converted to @@ -51,17 +59,21 @@ private[core] object CarryPromote: import dfc.getSet val candidateIR = signConversionRelVal(lhsIR).getOrElse(lhsIR) - // symbolic elimination keeps this consistent with the width-fit acceptance - // rule of the TC conversion: `16 > WIDTH max 16` decides as `16 > 16` (no - // widening), so the anonymous form resolves exactly like a named - // intermediate value; if still undecidable, optimistically assume the - // target is wider. - def contextWidenCheck(funcWidth: IntParam[Int]): Boolean = - dfType.asFE[DFSInt[Int]] - .compareWidths(DFXInt(true, funcWidth, BitAccurate), elimSymbolicMaxMin = true)( - _ > _ - ) - .getOrElse(true) + // The target must be strictly wider than the value's own type for the widening to + // apply. Decided directly on the two IR width refs: constructing a DFHDL type as a + // width carrier would run that type's own width constraint, so a 1-bit cone would + // fail `SInt`'s "width must be larger than 1" rule (issue #476). + // + // Symbolic elimination keeps this consistent with the width-fit acceptance rule of + // the TC conversion: `16 > WIDTH max 16` decides as `16 > 16` (no widening), so the + // anonymous form resolves exactly like a named intermediate value; if still + // undecidable, optimistically assume the target is wider. + def contextWidenCheck(valDFType: ir.DFType): Boolean = + widthRefOpt(valDFType).exists { valWidthRef => + dfType.asIR.magnitudeWidthParamRef + .compare(valWidthRef, elimSymbolicMaxMin = true)(_ > _) + .getOrElse(true) + } // The widened Func is BUILT FRESH rather than revised in place (an anonymous // member is never revised; issue #449); the original cone becomes debris for @@ -106,7 +118,7 @@ private[core] object CarryPromote: if func.isAnonymous && { // non-carry (modular) func: its type equals its aligned operands' func.dfType =~ func.args.head.get.dfType && - contextWidenCheck(func.asValOf[DFSInt[Int]].widthIntParam) + contextWidenCheck(func.dfType) } => Some(rebuilt(func, func.args.map(widenedArg(_).asIR))) // A shift's LEFT operand is context-determined in Verilog (the amount is @@ -123,7 +135,7 @@ private[core] object CarryPromote: op = FuncOp.>> | FuncOp.<< ) if func.isAnonymous && funcSigned == dfType.asIR.signed && - contextWidenCheck(func.asValOf[DFSInt[Int]].widthIntParam) => + contextWidenCheck(func.dfType) => Some(rebuilt(func, widenedArg(func.args.head).asIR :: func.args.tail.map(_.get))) case func @ ir.DFVal.Func( dfType = ir.DFUInt(_) | ir.DFSInt(_), @@ -132,7 +144,7 @@ private[core] object CarryPromote: // a sel's type structurally equals both branches' types (the frontend // converts one branch to the other's type), so no operand-shape gate if func.isAnonymous && - contextWidenCheck(func.asValOf[DFSInt[Int]].widthIntParam) => + contextWidenCheck(func.dfType) => Some(rebuilt(func, func.args.head.get :: func.args.tail.map(widenedArg(_).asIR))) // A conditional EXPRESSION (if/match) re-evaluates each branch at the target, // matching the Verilog its branches lower to (per-branch assignments to the @@ -148,7 +160,7 @@ private[core] object CarryPromote: if header.isAnonymous && (header.dfType match case ir.DFUInt(_) | ir.DFSInt(_) => true - case _ => false) && contextWidenCheck(header.asValOf[DFSInt[Int]].widthIntParam) => + case _ => false) && contextWidenCheck(header.dfType) => if (dfc.inMetaProgramming) Some(header.updateDFType(newDT).asValOf[DFSInt[Int]]) else // all-or-nothing: an unexpected branch shape (no terminal ident) leaves the diff --git a/core/src/main/scala/dfhdl/core/DFBits.scala b/core/src/main/scala/dfhdl/core/DFBits.scala index baa05ab3d..dcd72015b 100644 --- a/core/src/main/scala/dfhdl/core/DFBits.scala +++ b/core/src/main/scala/dfhdl/core/DFBits.scala @@ -6,7 +6,7 @@ import dfhdl.internals.* import scala.annotation.{implicitNotFound, targetName, nowarn} import scala.quoted.* import scala.util.boundary, boundary.break -import DFDecimal.Constraints.`LW == RW` +import DFDecimal.Constraints.{`LW == RW`, equalWidthCheck} type DFBits[W <: IntP] = DFType[ir.DFBits, Args1[W]] object DFBits: @@ -660,7 +660,7 @@ object DFBits: val rhsVal = icR(rhs) (lhsVal.widthIntOpt, rhsVal.widthIntOpt) match case (Some(lw), Some(rw)) => check(lw, rw) - case _ => + case _ => equalWidthCheck(lhsVal.dfType, rhsVal.dfType) DFVal.Func(lhsVal.dfType, op.value, List(lhsVal, rhsVal)) } end evOpLogicDFBits diff --git a/core/src/main/scala/dfhdl/core/DFDecimal.scala b/core/src/main/scala/dfhdl/core/DFDecimal.scala index 21b7b90b5..02734a3b1 100644 --- a/core/src/main/scala/dfhdl/core/DFDecimal.scala +++ b/core/src/main/scala/dfhdl/core/DFDecimal.scala @@ -115,6 +115,30 @@ object DFDecimal: " bits width (LHS) and a value of " + RW + " bits width (RHS).\nAn explicit conversion must be applied." ] + + /** The elaboration half of [[`LW == RW`]], for a width pair at least one of whose sides is not + * statically known (a design parameter): decided on the two width REFS, and a pair that cannot + * be PROVEN equal is rejected. The operation requires equal widths, so accepting the + * undecidable case emits an operation whose operands the backend silently extends, which is + * the one outcome bit-accuracy exists to prevent (issue #474). Reports the same text as the + * compile-time half, with each width rendered relative to the error site. Must be invoked + * wherever [[`LW == RW`]] is, on the branch where a width is unknown. + * + * The proof keeps design parameters OPAQUE (see `hasProvablyEqualWidthTo`): a design's own + * legality must hold for every applied parameter value, and the resolving comparison would + * anyway read a parameter's DEFAULT while its own body elaborates, so `Bits(LEN) ^ Bits[8]` + * with `LEN` defaulting to 8 would pass and then emit the mismatch at an instantiation site + * applying `LEN = 16`. + */ + protected[core] def equalWidthCheck[LW <: IntP, RW <: IntP]( + lhs: DFTypeW[LW], + rhs: DFTypeW[RW] + )(using DFC): Unit = + if (!lhs.hasProvablyEqualWidthTo(rhs)) + throw new IllegalArgumentException( + s"""|Cannot apply this operation between a value of ${lhs.widthErrorString} bits width (LHS) and a value of ${rhs.widthErrorString} bits width (RHS). + |An explicit conversion must be applied.""".stripMargin + ) object `LS >= RS` extends Check2[ Boolean, @@ -1322,7 +1346,7 @@ object DFXInt: def apply(lhs: L, rhs: R)(using DFC): Out = trydf { (lhs.widthIntOpt, rhs.widthIntOpt) match case (Some(lw), Some(rw)) => check(lw, rw) - case _ => + case _ => equalWidthCheck(lhs.dfType, rhs.dfType) DFVal.Func(lhs.dfType, op.value, List(lhs, rhs)) } end evOpLogicUInt diff --git a/core/src/main/scala/dfhdl/core/DFType.scala b/core/src/main/scala/dfhdl/core/DFType.scala index 0b06007bd..e04529863 100644 --- a/core/src/main/scala/dfhdl/core/DFType.scala +++ b/core/src/main/scala/dfhdl/core/DFType.scala @@ -275,6 +275,15 @@ object DFType: )(using dfc: DFC): Option[Boolean] = import dfc.getSet widthRef(lhs).compare(widthRef(rhs), elimSymbolicMaxMin)(func) + // Provable width EQUALITY with design parameters kept opaque (see + // `IntParamRef.isProvablyEqualTo`), for elaboration-time rules about a design's own + // legality. `compareWidths` above resolves a parameter to its applied (during the design's + // own body: DEFAULT) value, so it must not back such a rule. + protected[core] def hasProvablyEqualWidthTo[RW <: IntP](rhs: DFTypeW[RW])(using + dfc: DFC + ): Boolean = + import dfc.getSet + widthRef(lhs).isProvablyEqualTo(widthRef(rhs)) protected[core] def widthCodeString(using dfc: DFC): String = import dfc.getSet widthRef(lhs).refCodeString diff --git a/core/src/test/scala/CoreSpec/DFDecimalSpec.scala b/core/src/test/scala/CoreSpec/DFDecimalSpec.scala index 8381a53bd..65eb70d42 100644 --- a/core/src/test/scala/CoreSpec/DFDecimalSpec.scala +++ b/core/src/test/scala/CoreSpec/DFDecimalSpec.scala @@ -977,6 +977,34 @@ class DFDecimalSpec extends DFSpec: s9 := s8 + s8 + 1 } } + // A width-1 unsigned result is a legal type but a width-1 SIGNED one is not, so the + // widening rule's target-vs-value width comparison must not build a signed type as a + // width carrier: every `UInt(1)` arithmetic result would fail `SInt`'s own width rule. + // See https://github.com/DFiantHDL/DFHDL/issues/476 + test("Single-bit arithmetic") { + val u1 = UInt(1) <> VAR + val u1b = UInt(1) <> VAR + val u4 = UInt(4) <> VAR + assertCodeString { + """|u1 := u1 + u1b + |u1 := u1 - u1b + |u1 := u1 * u1b + |u1 := u1 + d"1'1" + |u4 := u1.eby(3) + u1b.eby(3) + |u1 := (u1 +^ u1b).resize(1) + |""".stripMargin + } { + u1 := u1 + u1b + u1 := u1 - u1b + u1 := u1 * u1b + // the wildcard `Int` fits a single bit and adapts to it + u1 := u1 + 1 + // a wider target still widens the cone + u4 := u1 + u1b + // the carry form is 2 bits wide, so it is a legal signed-free construction too + u1 := (u1 +^ u1b).resize(1) + } + } test("Arithmetic target-context widening through sign conversion") { val u2 = UInt(2) <> VAR val s8 = SInt(8) <> VAR diff --git a/docs/user-guide/type-system/index.md b/docs/user-guide/type-system/index.md index ea1732c17..89150f894 100755 --- a/docs/user-guide/type-system/index.md +++ b/docs/user-guide/type-system/index.md @@ -2203,6 +2203,45 @@ val e1 = v8 | 2 val e2 = v8 ^ b"1010" ``` +The equal-width requirement holds for parametric widths too, and a pair that cannot be **proven** equal is rejected. The proof treats a design parameter as an opaque symbol, so it sees through re-spelling (`Bits(W + 1)` matches `Bits(1 + W)`) but never folds a parameter to the value a particular instantiation applies: + +```scala +class Masker( + val W: Int <> CONST = 8, + val MASK: Bits[Int] <> CONST = h"ff" //8 bits, fixed by this argument +) extends EDDesign: + val a = Bits(W) <> IN + val b = Bits(W + 1) <> IN + val c = Bits(1 + W) <> IN + val o1 = Bits(W) <> OUT + val o2 = Bits(W + 1) <> OUT + val o3 = Bits(W) <> OUT + process(all): + //ok: the same parameter on both sides + o1 := a & b"0".repeat(W) + //ok: the proof sees through re-spelling + o2 := b | c + //error: `W` and 8 cannot be proven equal, so an instantiation + //applying `W = 16` would leave the backend to widen `MASK` silently + o3 := a ^ MASK +``` + +`Masker` above takes `W` and `MASK` as **independent** parameters, which is what makes the last operation unprovable: nothing ties the two arguments together, so an instantiation is free to disagree about them. When one is meant to follow the other, derive it instead of parameterizing it twice: + +```scala +class Masker( + val MASK: Bits[Int] <> CONST = h"ff" +) extends EDDesign: + val W: Int <> CONST = MASK.width + val a = Bits(W) <> IN + val o = Bits(W) <> OUT + process(all): + //ok: `W` IS `MASK`'s width, so the two are equal by construction + o := a ^ MASK +``` + +The relation now holds for every argument, so the check passes without an instantiation site to inspect, and the generated HDL carries the derivation rather than a pinned number (`localparam int W = $bits(MASK)` in Verilog). + /// details | Transitioning from Verilog type: verilog `lhs & rhs`/`lhs | rhs`/`lhs ^ rhs`/`~lhs` on `Bits`/`UInt` vector values map directly to Verilog's elementwise bitwise `&`/`|`/`^`/`~`. Verilog's same-symbol unary reduction operators (`&v`, `|v`, `^v`) map to the postfix [reduction operators][reduction-ops] instead. diff --git a/lib/src/test/scala/ElaborationChecksSpec.scala b/lib/src/test/scala/ElaborationChecksSpec.scala index a32fdab3f..b95de4f33 100644 --- a/lib/src/test/scala/ElaborationChecksSpec.scala +++ b/lib/src/test/scala/ElaborationChecksSpec.scala @@ -1692,4 +1692,66 @@ class ElaborationChecksSpec extends DesignSpec: |The previous write occurred at ${currentFilePos}ElaborationChecksSpec.scala:1678:9 - 1678:18""".stripMargin ) + // A bitwise operation requires equal operand widths. When at least one width is a design + // parameter the compile-time half cannot decide, and the elaboration half must reject + // anything it cannot PROVE equal with the parameters kept opaque: resolving them reads a + // parameter's DEFAULT while the design's own body elaborates, so `Bits(LEN) ^ Bits[8]` with + // `LEN` defaulting to 8 would pass here and then emit a real width mismatch at an + // instantiation site applying `LEN = 16`, which the backend silently zero-extends. + // See https://github.com/DFiantHDL/DFHDL/issues/474 + test("a bitwise operation over a parametric and a literal width is rejected"): + object Test: + @top(false) class BitsXorParam( + val LEN: Int <> CONST = 8, + val TAPS: Bits[Int] <> CONST = b"10111000" + ) extends EDDesign: + val i = Bits(LEN) <> IN + val o = Bits(LEN) <> OUT + o <> (i ^ TAPS) + end BitsXorParam + @top(false) class UIntAndParam( + val LEN: Int <> CONST = 8, + val MASK: UInt[Int] <> CONST = d"8'200" + ) extends EDDesign: + val i = UInt(LEN) <> IN + val o = UInt(LEN) <> OUT + o <> (i & MASK) + end UIntAndParam + // the proof sees through symbolic re-spelling, so an equal parametric pair is accepted + @top(false) class EqualParamWidths(val LEN: Int <> CONST = 8) extends EDDesign: + val i = Bits(LEN + 1) <> IN + val j = Bits(1 + LEN) <> IN + val o = Bits(LEN + 1) <> OUT + o <> (i | j) + end EqualParamWidths + end Test + import Test.* + assertElaborationErrors(BitsXorParam())( + s"""|Elaboration errors found! + |DFiant HDL elaboration error! + |Position: ${currentFilePos}ElaborationChecksSpec.scala:1710:9 - 1710:23 + |Hierarchy: BitsXorParam + |Operation: `apply` + |Message: Cannot apply this operation between a value of LEN bits width (LHS) and a value of 8 bits width (RHS). + |An explicit conversion must be applied.""".stripMargin + ) + assertElaborationErrors(UIntAndParam())( + s"""|Elaboration errors found! + |DFiant HDL elaboration error! + |Position: ${currentFilePos}ElaborationChecksSpec.scala:1718:9 - 1718:23 + |Hierarchy: UIntAndParam + |Operation: `apply` + |Message: Cannot apply this operation between a value of LEN bits width (LHS) and a value of 8 bits width (RHS). + |An explicit conversion must be applied.""".stripMargin + ) + EqualParamWidths().assertCodeString( + """|class EqualParamWidths(val LEN: Int <> CONST = 8) extends EDDesign: + | val i = Bits(LEN + 1) <> IN + | val j = Bits(1 + LEN) <> IN + | val o = Bits(LEN + 1) <> OUT + | o <> (i | j) + |end EqualParamWidths + |""".stripMargin + ) + end ElaborationChecksSpec From dc5ff6f1efe5840cc30e25945c438e3d26f16abb Mon Sep 17 00:00:00 2001 From: Oron Port Date: Tue, 11 Aug 2026 03:47:24 +0300 Subject: [PATCH 12/40] skills: record the two width-rule species from #476/#474 A domain type built only as a value carrier runs that type's own constraints, so an operation can fail on a rule belonging to a type the user never wrote (#476). And an elaboration-time rule about a design's OWN legality must keep design parameters opaque, since the resolving comparison reads a parameter's default while that design's body elaborates (#474) -- which also means a root design and an instantiated sub-design are different test subjects for any parameter-sensitive rule. Co-Authored-By: Claude Opus 5 (1M context) --- .claude/commands/bugfix.md | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/.claude/commands/bugfix.md b/.claude/commands/bugfix.md index 8a5a837bc..4aa28c92d 100644 --- a/.claude/commands/bugfix.md +++ b/.claude/commands/bugfix.md @@ -887,6 +887,44 @@ generalizes: with the TC width-fit check, or `sum := x + y` (anonymous, carry-promoted to `max+1`) would be definitively rejected while `val xy = x + y; sum := xy` passes. +### An elaboration-time rule about a design's own legality must keep parameters OPAQUE + +`IntParamRef` has two equality families and picking the wrong one is silently unsound: +`compare`/`isSimilarTo` resolve a design parameter through `appliedOrDefaultVal` +(`ParamResolve.AppliedExpr`), while `constDiffFrom`/`isProvablyEqualTo` keep it an opaque base. +A rule about whether a DESIGN is well-formed must hold for **every** applied parameter value, so +it takes the opaque form. The resolving form is doubly wrong there, and the second reason is the +one that bites: while a design's OWN body elaborates there is no applied value yet, so the +parameter reads as its DEFAULT. `Bits(LEN) ^ Bits[8]` with `LEN` defaulting to 8 therefore +"proved equal", and the mismatch only materialized at an instantiation site applying `LEN = 16`, +as Verilog the backend silently zero-extends (issue #474). + +Two consequences for how you reproduce and test such a rule: + +- **A root design and a sub-design instance are different test subjects.** #474's reporter + observed `.sel` "correctly rejecting" the identical width pair — true only because their probe + was the TOP design, whose parameters have no applied value and stay symbolic under either + family. The same `.sel` inside an instantiated child accepts. Every probe of a parameter- + sensitive rule needs BOTH shapes, and a reporter's "this sibling already rejects" is a + hypothesis, not a control. +- **`widthIntOpt` returning `None` is what routes a value to the elaboration half.** The literal + branch (`case (Some(lw), Some(rw)) => check(lw, rw)`) and the parametric branch answer the same + question, so the elaboration half must report the SAME message as the compile-time `Check2`, + rendered through `widthErrorString` (error-site-relative, issue #448). A bare `case _ =>` on + that branch is the bug shape to grep for wherever a `Check2` enforces equality. + +### A domain type built only as a value carrier runs that type's own constraints + +Comparing two widths by wrapping one in a DFHDL type (`DFXInt(true, funcWidth, BitAccurate)`, +built purely so `compareWidths` had something to take) makes every width that is illegal FOR THAT +TYPE an elaboration error, whatever the surrounding operation was. `SInt` rejects width 1, so +every `UInt(1)` arithmetic result failed with "Signed value width must be larger than 1" on code +containing no signed value at all (issue #476). The tell is an error naming a constraint of a type +the user never wrote, on an operation that has nothing to do with it. Compare the underlying refs +instead (`IntParamRef.compare` on the two `magnitudeWidthParamRef`s), which also avoids minting +the throwaway refs `IntParam.ref` registers per call. Note `.forced` constructors do NOT help: +they force the type PARAMETER and still run the runtime check. + ### Then measure the blast radius Run the full suite with the check in and **no stage fixes yet**. The failures are the deliverable From e9d50d2de09ac540af708a45b58436c064fffbb3 Mon Sep 17 00:00:00 2001 From: Oron Port Date: Tue, 11 Aug 2026 06:23:58 +0300 Subject: [PATCH 13/40] compiler: static assertions as concurrent design contracts A static assertion is an `assert` in a concurrent design or domain body whose condition and message are constant. It states an elaboration-time contract of the design rather than a runtime condition, so it stays concurrent all the way down and renders as an elaboration-time construct. The species is derived structurally (`TextOut.isStaticAssert`), with no new IR op and no marker tag, so re-elaborating the printed `assert(...)` reconstructs it. `ToED` exempts such an assertion, together with the constant cone computing its condition and message, from the process sweep, and `OrderMembers` ranks the pair right after the constant declarations so the output reads them as a contract header. Verilog had no correct rendering for a concurrent text output at all: an immediate assertion and a system-task call are statements, never module items, so a body assertion printed illegal code. It now prints per dialect: an elaboration system task under a generate-`if` from 1800-2009 (checked by synthesis too), an `initial assert ... else` under sv2005, and an `initial` guard with `$display` (plus `$finish` on Fatal) under v95/v2001. VHDL's concurrent assertion was already correct. Everything else stays a runtime statement, and a concurrent event-driven position has no runtime to attach one to: the lowering that gives an RT/DF body statement one does not apply to a body that is already ED. `DB.textOutCheck` therefore rejects it at elaboration and points at `process` / `initial`, instead of letting the user discover it in illegal generated HDL. RT/DF bodies and HDL method bodies are exempt. Two adjacent fixes fall out: `DropProcessAll` now counts a text output's reads when it builds an explicit sensitivity list (a text-output-only `process(all)` previously became `always @()` under v95 and VHDL-93), and DFacsimile reports a static assertion on the first committed cycle only, rather than repeating an elaboration-time contract every cycle. Co-Authored-By: Claude Opus 5 (1M context) --- .../compiler/analysis/DFValAnalysis.scala | 31 +++++++ .../src/main/scala/dfhdl/compiler/ir/DB.scala | 28 ++++++ .../compiler/stages/DropProcessAll.scala | 5 +- .../dfhdl/compiler/stages/OrderMembers.scala | 18 ++-- .../scala/dfhdl/compiler/stages/ToED.scala | 26 +++++- .../stages/verilog/VerilogPrinter.scala | 26 ++++++ .../src/main/scala/dfhdl/sim/DFacsimile.scala | 20 +++- .../scala/StagesSpec/OrderMembersSpec.scala | 24 +++++ .../StagesSpec/PrintCodeStringSpec.scala | 39 ++++++++ .../scala/StagesSpec/PrintVHDLCodeSpec.scala | 39 ++++++++ .../StagesSpec/PrintVerilogCodeSpec.scala | 91 +++++++++++++++++++ .../src/test/scala/StagesSpec/ToEDSpec.scala | 30 ++++++ .../scala/dfhdl/sim/RTProcessDesigns.scala | 9 ++ .../scala/dfhdl/sim/RTProcessSimSpec.scala | 15 +++ .../test/scala/ElaborationChecksSpec.scala | 58 ++++++++++++ 15 files changed, 442 insertions(+), 17 deletions(-) diff --git a/compiler/ir/src/main/scala/dfhdl/compiler/analysis/DFValAnalysis.scala b/compiler/ir/src/main/scala/dfhdl/compiler/analysis/DFValAnalysis.scala index 9a939c0b0..9d0611eb4 100644 --- a/compiler/ir/src/main/scala/dfhdl/compiler/analysis/DFValAnalysis.scala +++ b/compiler/ir/src/main/scala/dfhdl/compiler/analysis/DFValAnalysis.scala @@ -402,6 +402,16 @@ extension (dfVal: DFVal) case _ => false } + /** Part of the anonymous cone computing a static assertion's condition or message, which must + * therefore be placed with it (see `TextOut.isStaticAssert`). + */ + def isReferencedByAnyStaticAssert(using MemberGetSet): Boolean = + dfVal.originMembers.view.exists { + case textOut: TextOut => textOut.isStaticAssert + case dfVal: DFVal => dfVal.isReferencedByAnyStaticAssert + case _ => false + } + @tailrec private def flatName(member: DFVal, suffix: String)(using MemberGetSet): String = member match case named if !named.isAnonymous => s"${member.getName}$suffix" @@ -578,6 +588,27 @@ extension (textOut: TextOut) .collect { case DFRef(dfVal: DFVal) => dfVal } .flatMap(_.collectRelMembers(false)).toList + /** A static assertion: an assertion placed directly in a domain body (a design body or a `domain` + * body) whose guard and message arguments are all constant. It states an elaboration-time + * contract of the design rather than a runtime condition, so it stays a concurrent body + * statement through the lowering to ED and prints as an elaboration-time construct. + * + * The position is part of the definition: an assertion nested in a process, a conditional block + * or a loop is procedural content and is never static, whatever its guard, since the + * elaboration-time forms only exist in concurrent position. + */ + def isStaticAssert(using MemberGetSet): Boolean = + textOut.op match + case TextOut.Op.Assert(assertionRef, _) => + val isConcurrent = textOut.getOwner match + // an HDL method body is procedural, not a concurrent body + case dsn: DFDesignBlock => !dsn.isHDLMethod + case _: DFDomainOwner => true + case _ => false + isConcurrent && assertionRef.get.isConst && textOut.msgArgs.forall(_.get.isConst) + case _ => false +end extension + extension (member: DFMember) private def isPublicMember(using MemberGetSet): Boolean = member match diff --git a/compiler/ir/src/main/scala/dfhdl/compiler/ir/DB.scala b/compiler/ir/src/main/scala/dfhdl/compiler/ir/DB.scala index b4cc519e1..4e1feeeff 100644 --- a/compiler/ir/src/main/scala/dfhdl/compiler/ir/DB.scala +++ b/compiler/ir/src/main/scala/dfhdl/compiler/ir/DB.scala @@ -1781,6 +1781,33 @@ final case class DB private ( throw new IllegalArgumentException(errors.mkString("\n\n")) end mixedAssignKindCheck + // Text output is a runtime statement, and under an event-driven (ED) domain a concurrent + // position has no runtime to attach it to: in Verilog a system-task call and an immediate + // assertion are statements, never module items, and VHDL only has the concurrent assertion. + // So an ED domain body accepts exactly one species of text output, the STATIC ASSERTION (its + // condition and message are constant), which states an elaboration-time contract of the design + // and renders as an elaboration-time construct; everything else belongs in a process or an + // `initial` block. RT/DF bodies are exempt, since their statements are concurrent by nature + // and `ToED` lowers them into processes; so are HDL method bodies, which are procedural. + def textOutCheck(): Unit = + val errors = collection.mutable.ArrayBuffer[String]() + members.foreach { + case textOut: TextOut + if textOut.isInEDDomain && !textOut.isInProcess && + !textOut.getOwnerDesign.isHDLMethod && !textOut.isStaticAssert => + errors += + s"""|DFiant HDL text output error! + |Position: ${textOut.meta.position} + |Hierarchy: ${textOut.getOwnerDesign.getFullName} + |Message: Text output is not allowed as a concurrent statement under an event-driven (ED) domain. + |Only a static assertion (an `assert` whose condition and message are constant) may reside directly in an ED domain body, as a design contract checked at elaboration. + |To Fix: move the statement into a `process` or an `initial` block.""".stripMargin + case _ => + } + if (errors.nonEmpty) + throw new IllegalArgumentException(errors.mkString("\n\n")) + end textOutCheck + def sharedVarCheck(): Unit = val errors = collection.mutable.ArrayBuffer[String]() def memberError(member: DFMember, msg: String): Unit = @@ -2137,6 +2164,7 @@ final case class DB private ( blockScopeCheck() sharedVarCheck() mixedAssignKindCheck() + textOutCheck() // Whole-tree checks, run once on the root: the cross-design connectivity / // RT-domain / device-top checks, via the `*` clones that navigate the diff --git a/compiler/stages/src/main/scala/dfhdl/compiler/stages/DropProcessAll.scala b/compiler/stages/src/main/scala/dfhdl/compiler/stages/DropProcessAll.scala index 2c0a3394b..77dc15d90 100644 --- a/compiler/stages/src/main/scala/dfhdl/compiler/stages/DropProcessAll.scala +++ b/compiler/stages/src/main/scala/dfhdl/compiler/stages/DropProcessAll.scala @@ -67,7 +67,10 @@ case object DropProcessAll extends HierarchyStage: // through the assignments that consume them case DFVal.Func.Call(call, _) if call.dfType == DFUnit => call.args.view.map(_.get) - case mh: DFMatchHeader => Some(mh.selectorRef.get) + case mh: DFMatchHeader => Some(mh.selectorRef.get) + // a text output reads its assertion guard and every message argument + case textOut: TextOut => + textOut.getRefs.view.map(_.get).collect { case dfVal: DFVal => dfVal } case cb: DFConditional.Block => getBlockDependents(cb) ++ cb.getGuardOption case _ => None }.flatMap(getDFValDependents) diff --git a/compiler/stages/src/main/scala/dfhdl/compiler/stages/OrderMembers.scala b/compiler/stages/src/main/scala/dfhdl/compiler/stages/OrderMembers.scala index 1956af87a..f74fde599 100644 --- a/compiler/stages/src/main/scala/dfhdl/compiler/stages/OrderMembers.scala +++ b/compiler/stages/src/main/scala/dfhdl/compiler/stages/OrderMembers.scala @@ -49,16 +49,20 @@ object OrderMembers: case dfVal: DFVal if dfVal.isReferencedByAnyDclOrDesign => 3 // fourth to come are constant declarations that may be referenced by ports case DclConst() => 4 - // fifth are ports - case DclPort() => 5 - // sixth are variables, but not iterators + // fifth are the anonymous members computing a static assertion's condition and message + case dfVal: DFVal if dfVal.isReferencedByAnyStaticAssert => 5 + // sixth are the static assertions stating the design's contract over those constants + case textOut: TextOut if textOut.isStaticAssert => 6 + // seventh are ports + case DclPort() => 7 + // eighth are variables, but not iterators case dcl @ DclVar() - if !dcl.isIterator && dcl.getOwner.isInstanceOf[DFDesignBlock] => 6 - // seventh are design blocks that are direct children of named instances + if !dcl.isIterator && dcl.getOwner.isInstanceOf[DFDesignBlock] => 8 + // ninth are design blocks that are direct children of named instances // (e.g., design blocks inside conditional blocks are not included) - case dsn: (DFDesignBlock | DFDesignInst) if dsn.getOwner == dsn.getOwnerNamed => 7 + case dsn: (DFDesignBlock | DFDesignInst) if dsn.getOwner == dsn.getOwnerNamed => 9 // then the rest - case _ => 8 + case _ => 10 } end Simple // val GuardedLast: Order = new Order: diff --git a/compiler/stages/src/main/scala/dfhdl/compiler/stages/ToED.scala b/compiler/stages/src/main/scala/dfhdl/compiler/stages/ToED.scala index e3f3c147e..0d2ef9b1c 100644 --- a/compiler/stages/src/main/scala/dfhdl/compiler/stages/ToED.scala +++ b/compiler/stages/src/main/scala/dfhdl/compiler/stages/ToED.scala @@ -139,12 +139,28 @@ case object ToED extends HierarchyStage: val nonInitialMembers = if (initialPBs.isEmpty) members else members.filterNot(initialMemberSet) + // A static assertion states an elaboration-time contract of the design, not a + // runtime condition, so it stays a concurrent body member of the lowered ED design. + // Its constant cone stays with it: a cone member swept into the generated process + // would leave the concurrent assertion reading a process-local value. + // (collected exactly as `getProcessAllMembers` collects a text output's cone below, + // so that every member it would have swept is excluded here) + val staticAssertMemberSet: Set[DFMember] = + nonInitialMembers.view.collect { + case textOut: TextOut if textOut.isStaticAssert => + textOut :: textOut.getRefs.view.filterNot(_.isTypeRef).map(_.get).flatMap { + case dfVal: DFVal => dfVal.collectRelMembers(true) + case _ => Nil + }.toList + }.flatten.toSet + def collectFilter(member: DFMember): Boolean = member match - case IteratorDcl() => true - case _: DFVal.Dcl => false - case _: DFVal.DesignParam => false - case DclConst() => false - case _: DFOwnerNamed => false + case m if staticAssertMemberSet.contains(m) => false + case IteratorDcl() => true + case _: DFVal.Dcl => false + case _: DFVal.DesignParam => false + case DclConst() => false + case _: DFOwnerNamed => false // a DIN read marker is replaced outright (see `dinReadPatches`), so it must not also // be moved into the generated process: the two patches would collide on it case _: DFVal.Alias.RegDIN => false diff --git a/compiler/stages/src/main/scala/dfhdl/compiler/stages/verilog/VerilogPrinter.scala b/compiler/stages/src/main/scala/dfhdl/compiler/stages/verilog/VerilogPrinter.scala index ae663887a..130034aa5 100644 --- a/compiler/stages/src/main/scala/dfhdl/compiler/stages/verilog/VerilogPrinter.scala +++ b/compiler/stages/src/main/scala/dfhdl/compiler/stages/verilog/VerilogPrinter.scala @@ -56,6 +56,13 @@ class VerilogPrinter(val dialect: VerilogDialect)(using printer.dialect match case VerilogDialect.v95 | VerilogDialect.v2001 => false case _ => true + // elaboration system tasks ($info/$warning/$error/$fatal as module or generate items, + // IEEE 1800-2009 par. 20.11), which is what lets a static assertion be checked by synthesis + // and not only by simulation + val elabTaskIsSupported: Boolean = + printer.dialect match + case VerilogDialect.v95 | VerilogDialect.v2001 | VerilogDialect.sv2005 => false + case _ => true def csTextOut(textOut: TextOut): String = def csDFValToVerilogFormat(dfValRef: DFVal.Ref): String = dfValRef.get.dfType match @@ -135,6 +142,25 @@ class VerilogPrinter(val dialect: VerilogDialect)(using case _ => "" s"${csSeverity(severity)}($errCodeArg$msg);" else csDisplay(severity, msgLines) + // A static assertion is a concurrent design contract, and a Verilog module body has no + // concurrent statements: an immediate assertion and a system-task call are both statements, + // never module items. It is checked at elaboration where the dialect has the tasks for it, + // and at simulation time zero from an `initial` block otherwise. + case TextOut.Op.Assert(assertionRef, severity) if textOut.isStaticAssert => + val cond = assertionRef.refCodeString + val failMsg = if (msg.isEmpty) scalaToVerilogString("Assertion failed!") else msg + if (elabTaskIsSupported) + val errCodeArg = if (severity == TextOut.Severity.Fatal) "1, " else "" + s"if (!($cond)) ${csSeverity(severity)}($errCodeArg$failMsg);" + else if (assertIsSupported) + s"""|initial + | assert ($cond) + | else ${csSeverity(severity)}($failMsg);""".stripMargin + else + val failLines = if (msgLines.isEmpty) List(("Assertion failed!", Nil)) else msgLines + s"""|initial if (!($cond)) begin + |${csDisplay(severity, failLines).hindent} + |end""".stripMargin case TextOut.Op.Assert(assertionRef, severity) => if (msg.isEmpty) if (assertIsSupported) s"assert (${assertionRef.refCodeString});" diff --git a/compiler/stages/src/main/scala/dfhdl/sim/DFacsimile.scala b/compiler/stages/src/main/scala/dfhdl/sim/DFacsimile.scala index 89c844b78..86fd967b3 100644 --- a/compiler/stages/src/main/scala/dfhdl/sim/DFacsimile.scala +++ b/compiler/stages/src/main/scala/dfhdl/sim/DFacsimile.scala @@ -42,7 +42,9 @@ enum SimTier derives CanEqual: * cycle's settled values; report/assert severities feed the run's severity policy, `Fatal` and * `finish` end the run. A design-body (combinational-context) statement whose condition stays * true fires on every such cycle — the clocked reading of what an event-driven simulator would - * report per activation. + * report per activation. The exception is a static assertion (a body assertion with a constant + * guard and message), which states an elaboration-time contract and is therefore reported on + * the first committed cycle only. * * Known minimum limitations: `**`/`clog2` on non-constants, multiplication/division with results * wider than 64 bits, bubble (`?`) values simulate as 0 (2-state), non-constant string message @@ -180,12 +182,17 @@ private[sim] enum ActKind derives CanEqual: * nonzero (the full path condition — FSM site dispatch, branch guards, and a failing assertion * condition — folded into one 1-bit node). Message values read the fired cycle's settled sweep * (register operands are MOV-snapshot). `where` is the instance path for report/assert context. + * + * `once` marks a static assertion, whose guard and message are constant: it states an + * elaboration-time contract, so it is reported on the first committed cycle only (the simulation + * analog of the backends checking it at elaboration) rather than on every cycle. */ private[sim] final case class SimAction( guard: Int, kind: ActKind, segs: Vector[ActSeg], - where: String + where: String, + once: Boolean = false ) /** A running simulation instance: one state/signal array + a kernel over it. Values are addressed @@ -265,6 +272,10 @@ final class Sim private[sim] ( sb ++= render(bits) sb.result() + // static assertions state an elaboration-time contract, so a failing one is reported on the + // first committed cycle and never again + private var onceDone = false + /** Executes the fired actions of the just-committed cycle in program order, reading the cycle's * settled values (combinational slots and register MOV snapshots survive the commit). A finish * or fatal stops the remaining actions of the cycle; a severity pause lets them complete first. @@ -276,7 +287,7 @@ final class Sim private[sim] ( var i = 0 while i < actions.length && !terminal do val a = actions(i) - if sig(a.guard) != 0L then + if sig(a.guard) != 0L && !(a.once && onceDone) then a.kind match case ActKind.Output => textSink(actText(a)) case ActKind.Report(severity) => @@ -313,6 +324,7 @@ final class Sim private[sim] ( end if i += 1 end while + onceDone = true if !terminal then pausePend.foreach(sev => stopVar = Some(SimStop.SevPause(sev))) stopVar.nonEmpty end fireActions @@ -1469,7 +1481,7 @@ private final class Builder(rawDB: DB): case TextOut.Op.Assert(_, severity) => val body = if msgSegs.isEmpty then Vector(ActSeg.Lit("assertion failed")) else msgSegs (ActKind.Report(severity), body) - actions += SimAction(nl.snap(guard), kind, segs, where) + actions += SimAction(nl.snap(guard), kind, segs, where, once = t.isStaticAssert) end if end buildTextOut diff --git a/compiler/stages/src/test/scala/StagesSpec/OrderMembersSpec.scala b/compiler/stages/src/test/scala/StagesSpec/OrderMembersSpec.scala index b69830cc9..4a65feb02 100644 --- a/compiler/stages/src/test/scala/StagesSpec/OrderMembersSpec.scala +++ b/compiler/stages/src/test/scala/StagesSpec/OrderMembersSpec.scala @@ -104,4 +104,28 @@ class OrderMembersSpec extends StageSpec: |end Foo""".stripMargin ) } + test("static assertion after the constants") { + // a static assertion states the design's contract over its parameters and constants, so it + // renders right after them (before the ports), wherever the user wrote it; the anonymous + // cone computing its condition and message comes along + class Guarded(val W: Int <> CONST = 8) extends EDDesign: + val x = UInt(W) <> IN + val y = UInt(W) <> OUT + val MAX: UInt[8] <> CONST = d"8'255" + y <> x + assert(W > 0, s"W must be positive, got $W") + end Guarded + val guarded = Guarded().simpleOrder + assertCodeString( + guarded, + """|class Guarded(val W: Int <> CONST = 8) extends EDDesign: + | val MAX: UInt[8] <> CONST = d"8'255" + | assert(W > 0, s"W must be positive, got ${W}") + | val x = UInt(W) <> IN + | val y = UInt(W) <> OUT + | y <> x + |end Guarded + |""".stripMargin + ) + } end OrderMembersSpec diff --git a/compiler/stages/src/test/scala/StagesSpec/PrintCodeStringSpec.scala b/compiler/stages/src/test/scala/StagesSpec/PrintCodeStringSpec.scala index 9a4ca57db..75b8993d0 100644 --- a/compiler/stages/src/test/scala/StagesSpec/PrintCodeStringSpec.scala +++ b/compiler/stages/src/test/scala/StagesSpec/PrintCodeStringSpec.scala @@ -3248,4 +3248,43 @@ class PrintCodeStringSpec extends StageSpec(stageCreatesUnrefAnons = true): |""".stripMargin ) } + test("static assertion") { + // a static assertion (constant condition and message) is a concurrent design-body statement + // in every domain, and prints as a plain `assert` — nothing distinguishes it in the source, + // which is the point: re-elaborating this printout derives the same species structurally + class Guarded(val W: Int <> CONST = 8) extends EDDesign: + val i = UInt(W) <> IN + val o = UInt(W) <> OUT + assert(W > 0, s"W must be positive, got $W") + assert(W <= 32, s"W must not exceed 32, got $W", Severity.Fatal) + o <> i + end Guarded + class GuardedRT extends RTDesign: + val i = UInt(8) <> IN + val o = UInt(8) <> OUT + assert(i.width == 8) + o := i + end GuardedRT + assertCodeString( + Guarded(), + """|class Guarded(val W: Int <> CONST = 8) extends EDDesign: + | val i = UInt(W) <> IN + | val o = UInt(W) <> OUT + | assert(W > 0, s"W must be positive, got ${W}") + | assert(W <= 32, s"W must not exceed 32, got ${W}", Severity.Fatal) + | o <> i + |end Guarded + |""".stripMargin + ) + assertCodeString( + GuardedRT(), + """|class GuardedRT extends RTDesign: + | val i = UInt(8) <> IN + | val o = UInt(8) <> OUT + | assert(i.width == 8) + | o := i + |end GuardedRT + |""".stripMargin + ) + } end PrintCodeStringSpec diff --git a/compiler/stages/src/test/scala/StagesSpec/PrintVHDLCodeSpec.scala b/compiler/stages/src/test/scala/StagesSpec/PrintVHDLCodeSpec.scala index 4e6e36471..554d5ac96 100644 --- a/compiler/stages/src/test/scala/StagesSpec/PrintVHDLCodeSpec.scala +++ b/compiler/stages/src/test/scala/StagesSpec/PrintVHDLCodeSpec.scala @@ -3621,4 +3621,43 @@ class PrintVHDLCodeSpec extends StageSpec: |""".stripMargin ) } + // VHDL already has a concurrent assertion, and a static condition makes it an elaboration-time + // check; the static-assertion species simply keeps it out of the process sweep + test("static assertion") { + class StaticGuard(val W: Int <> CONST = 8) extends EDDesign: + val x = UInt(W) <> IN + val y = UInt(W) <> OUT + assert(W > 0, s"W must be positive, got $W") + assert(W <= 32, s"W must not exceed 32, got $W", Severity.Fatal) + y <> x + end StaticGuard + val top = StaticGuard().getCompiledCodeString + assertNoDiff( + top, + """|library ieee; + |use ieee.std_logic_1164.all; + |use ieee.numeric_std.all; + |use work.dfhdl_pkg.all; + | + |entity StaticGuard is + |generic ( + | W : integer := 8 + |); + |port ( + | x : in unsigned(W - 1 downto 0); + | y : out unsigned(W - 1 downto 0) + |); + |end StaticGuard; + | + |architecture StaticGuard_arch of StaticGuard is + |begin + | assert W > 0 + | report "W must be positive, got " & to_string(W) & "" severity ERROR; + | assert W <= 32 + | report "W must not exceed 32, got " & to_string(W) & "" severity FAILURE; + | y <= x; + |end StaticGuard_arch; + |""".stripMargin + ) + } end PrintVHDLCodeSpec diff --git a/compiler/stages/src/test/scala/StagesSpec/PrintVerilogCodeSpec.scala b/compiler/stages/src/test/scala/StagesSpec/PrintVerilogCodeSpec.scala index 3fdc4e378..9a3541e75 100644 --- a/compiler/stages/src/test/scala/StagesSpec/PrintVerilogCodeSpec.scala +++ b/compiler/stages/src/test/scala/StagesSpec/PrintVerilogCodeSpec.scala @@ -3471,4 +3471,95 @@ class PrintVerilogCodeSpec extends StageSpec: |""".stripMargin ) } + // A static assertion (constant condition and message) is a concurrent design contract, and a + // Verilog module body has no concurrent statements: an immediate assertion and a system-task + // call are both statements, never module items. From 1800-2009 it is an elaboration system + // task under a generate-`if`, so synthesis checks it too; older dialects check it at + // simulation time zero from an `initial` block. + test("static assertion under sv2009") { + given options.CompilerOptions.Backend = _.verilog.sv2009 + class StaticGuardNew(val W: Int <> CONST = 8) extends EDDesign: + val x = UInt(W) <> IN + val y = UInt(W) <> OUT + assert(W > 0, s"W must be positive, got $W") + assert(W <= 32, s"W must not exceed 32, got $W", Severity.Fatal) + y <> x + end StaticGuardNew + val top = StaticGuardNew().getCompiledCodeString + assertNoDiff( + top, + """|`default_nettype none + |`timescale 1ns/1ps + | + |module StaticGuardNew#(parameter int W = 8)( + | input wire logic [W - 1:0] x, + | output logic [W - 1:0] y + |); + | `include "dfhdl_defs.svh" + | if (!(W > 0)) $error("W must be positive, got %d", W); + | if (!(W <= 32)) $fatal(1, "W must not exceed 32, got %d", W); + | assign y = x; + |endmodule + |""".stripMargin + ) + } + test("static assertion under sv2005") { + given options.CompilerOptions.Backend = _.verilog.sv2005 + class StaticGuardSV(val W: Int <> CONST = 8) extends EDDesign: + val x = UInt(W) <> IN + val y = UInt(W) <> OUT + assert(W > 0, s"W must be positive, got $W") + y <> x + end StaticGuardSV + val top = StaticGuardSV().getCompiledCodeString + assertNoDiff( + top, + """|`default_nettype none + |`timescale 1ns/1ps + | + |module StaticGuardSV#(parameter int W = 8)( + | input wire logic [W - 1:0] x, + | output logic [W - 1:0] y + |); + | `include "dfhdl_defs.svh" + | initial + | assert (W > 0) + | else $error("W must be positive, got %d", W); + | assign y = x; + |endmodule + |""".stripMargin + ) + } + test("static assertion under v2001") { + given options.CompilerOptions.Backend = _.verilog.v2001 + class StaticGuardOld(val W: Int <> CONST = 8) extends EDDesign: + val x = UInt(W) <> IN + val y = UInt(W) <> OUT + assert(W > 0, s"W must be positive, got $W") + assert(W <= 32, s"W must not exceed 32, got $W", Severity.Fatal) + y <> x + end StaticGuardOld + val top = StaticGuardOld().getCompiledCodeString + assertNoDiff( + top, + """|`default_nettype none + |`timescale 1ns/1ps + | + |module StaticGuardOld#(parameter integer W = 8)( + | input wire [W - 1:0] x, + | output wire [W - 1:0] y + |); + | `include "dfhdl_defs.vh" + | initial if (!(W > 0)) begin + | $display("ERROR: W must be positive, got %d", W); + | end + | initial if (!(W <= 32)) begin + | $display("FATAL: W must not exceed 32, got %d", W); + | $finish; + | end + | assign y = x; + |endmodule + |""".stripMargin + ) + } end PrintVerilogCodeSpec diff --git a/compiler/stages/src/test/scala/StagesSpec/ToEDSpec.scala b/compiler/stages/src/test/scala/StagesSpec/ToEDSpec.scala index 6b7eee10f..85fb2bc68 100644 --- a/compiler/stages/src/test/scala/StagesSpec/ToEDSpec.scala +++ b/compiler/stages/src/test/scala/StagesSpec/ToEDSpec.scala @@ -1711,4 +1711,34 @@ class ToEDSpec extends StageSpec(stageCreatesUnrefAnons = true): |""".stripMargin ) } + test("static assertion stays concurrent") { + // a static assertion (constant condition and message) states an elaboration-time contract of + // the design, so it is exempt from the process sweep and stays a concurrent body statement, + // together with its constant cone; a dynamic text output is swept as usual + @hw.constraints.timing.clock(grpName = "cfg") + class ID(val W: Int <> CONST = 8) extends RTDesign: + val x = UInt(W) <> IN + val y = UInt(W) <> OUT + assert(W > 0, s"W must be positive, got $W") + println(s"x: $x") + y := x + end ID + val id = ID().toED + assertCodeString( + id, + """|case class Clk_cfg() extends Clk + | + |class ID(val W: Int <> CONST = 8) extends EDDesign: + | @timing.clock(rate = 50.MHz, edge = _.rising, portName = "clk", inclusionPolicy = _.asneeded, grpName = "cfg") + | val clk = Clk_cfg <> IN + | assert(W > 0, s"W must be positive, got ${W}") + | val x = UInt(W) <> IN + | val y = UInt(W) <> OUT + | process(clk): + | if (clk.actual.rising) println(s"x: ${x}") + | y <> x + |end ID + |""".stripMargin + ) + } end ToEDSpec diff --git a/compiler/stages/src/test/scala/dfhdl/sim/RTProcessDesigns.scala b/compiler/stages/src/test/scala/dfhdl/sim/RTProcessDesigns.scala index 23cf7d77f..d659761c4 100644 --- a/compiler/stages/src/test/scala/dfhdl/sim/RTProcessDesigns.scala +++ b/compiler/stages/src/test/scala/dfhdl/sim/RTProcessDesigns.scala @@ -311,6 +311,15 @@ class BodyAssertDesign extends RTDesign: cnt.din := cnt + 1 assert(cnt < 5, s"cnt reached $cnt", Severity.Warning) +/** A failing STATIC assertion (constant condition and message) alongside body register logic: an + * elaboration-time contract, so it is reported on the first committed cycle and never again. + */ +class StaticAssertDesign extends RTDesign: + val W: Int <> CONST = 8 + val cnt = UInt(8) <> OUT.REG init 0 + cnt.din := cnt + 1 + assert(W > 32, s"W must exceed 32, got $W", Severity.Warning) + /** Three constant-false `while` parks and then `finish()`, fused into the third park's exit path * (the run ends during cycle 3, one cycle per skipped loop). */ diff --git a/compiler/stages/src/test/scala/dfhdl/sim/RTProcessSimSpec.scala b/compiler/stages/src/test/scala/dfhdl/sim/RTProcessSimSpec.scala index d28a8acd2..40d8f5077 100644 --- a/compiler/stages/src/test/scala/dfhdl/sim/RTProcessSimSpec.scala +++ b/compiler/stages/src/test/scala/dfhdl/sim/RTProcessSimSpec.scala @@ -373,6 +373,21 @@ class RTProcessSimSpec extends SimSpec: assertEquals(run.continue(20), RunStatus.Paused(PausedReason.Warning)) assertEquals(run.cycles, 7L) + bothTiers("a static assertion is reported once"): tier => + // the condition is constant, so it would otherwise fail identically on every cycle; a static + // assertion states an elaboration-time contract and is reported on the first cycle only + val run = (new StaticAssertDesign).simulation.withTier(tier).run() + val out = new StringBuilder + run.raw.textSink = s => + out ++= s; () + assertEquals(run.continue(20), RunStatus.Paused(PausedReason.Limit)) + assertEquals(run.cycles, 20L) + assertEquals( + out.result(), + "WARNING: W must exceed 32, got 8 [StaticAssertDesign @ cycle 1]\n" + ) + assertEquals(run.raw.warningCount, 1L) + bothTiers("event starvation finishes a block-less run of a closed design"): tier => // RunOnceProc halts at an endless wait with no pokeable inputs: nothing can ever happen val run = (new RunOnceProc).simulation.withTier(tier).run() diff --git a/lib/src/test/scala/ElaborationChecksSpec.scala b/lib/src/test/scala/ElaborationChecksSpec.scala index b95de4f33..ccf0c1474 100644 --- a/lib/src/test/scala/ElaborationChecksSpec.scala +++ b/lib/src/test/scala/ElaborationChecksSpec.scala @@ -1754,4 +1754,62 @@ class ElaborationChecksSpec extends DesignSpec: |""".stripMargin ) + test("concurrent text output under an ED domain"): + object Test: + // a runtime text output has no runtime to attach to in a concurrent ED position (the + // lowering that gives an RT/DF body statement one does not apply to a body that is already + // ED), so it must reside in a process or an `initial` block + @top(false) class EDPrint extends EDDesign: + val i = UInt(8) <> IN + val o = UInt(8) <> OUT + println(s"i is $i") + o <> i + end EDPrint + // an assertion over a runtime value is just as dynamic as a print + @top(false) class EDDynAssert extends EDDesign: + val i = UInt(8) <> IN + val o = UInt(8) <> OUT + assert(i < d"8'200", s"i too large: $i") + o <> i + end EDDynAssert + // a STATIC assertion (constant condition and message) is the one accepted species: it + // states an elaboration-time contract and renders as an elaboration-time construct + @top(false) class EDStaticAssert(val W: Int <> CONST = 8) extends EDDesign: + val i = UInt(W) <> IN + val o = UInt(W) <> OUT + assert(W > 0, s"W must be positive, got $W") + o <> i + end EDStaticAssert + // an RT body is exempt: its statements are concurrent by nature and the lowering to ED + // sweeps them into a process (see `StagesSpec.ToEDSpec`) + @top(false) class RTPrint extends RTDesign: + val i = UInt(8) <> IN + val o = UInt(8) <> OUT + println(s"i is $i") + o := i + end RTPrint + end Test + import Test.* + assertElaborationErrors(EDPrint())( + s"""|Elaboration errors found! + |DFiant HDL text output error! + |Position: ${currentFilePos}ElaborationChecksSpec.scala:1765:9 - 1765:28 + |Hierarchy: EDPrint + |Message: Text output is not allowed as a concurrent statement under an event-driven (ED) domain. + |Only a static assertion (an `assert` whose condition and message are constant) may reside directly in an ED domain body, as a design contract checked at elaboration. + |To Fix: move the statement into a `process` or an `initial` block.""".stripMargin + ) + assertElaborationErrors(EDDynAssert())( + s"""|Elaboration errors found! + |DFiant HDL text output error! + |Position: ${currentFilePos}ElaborationChecksSpec.scala:1772:9 - 1772:49 + |Hierarchy: EDDynAssert + |Message: Text output is not allowed as a concurrent statement under an event-driven (ED) domain. + |Only a static assertion (an `assert` whose condition and message are constant) may reside directly in an ED domain body, as a design contract checked at elaboration. + |To Fix: move the statement into a `process` or an `initial` block.""".stripMargin + ) + // the accepted species elaborate without error + val _ = EDStaticAssert() + val _ = RTPrint() + end ElaborationChecksSpec From 10ca4ab2c8ecab73e7c8e07f30409c0f6322fe4b Mon Sep 17 00:00:00 2001 From: Oron Port Date: Tue, 11 Aug 2026 06:24:02 +0300 Subject: [PATCH 14/40] skill: format before the final full-suite run Running the suite, then scalafmt, then the suite again is pure waste: formatting rewrites the very spec files the first run exercised. The position-sensitive tests section is where this already bit, so the general rule lands there. Co-Authored-By: Claude Opus 5 (1M context) --- .claude/commands/bugfix.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.claude/commands/bugfix.md b/.claude/commands/bugfix.md index 4aa28c92d..1b9dee309 100644 --- a/.claude/commands/bugfix.md +++ b/.claude/commands/bugfix.md @@ -946,6 +946,11 @@ scalafmt reflows the test design (a braces-on-one-line block becomes multi-line) shifts those positions. Write the design in the already-normalized indented form so reformatting does not move it, and re-check the positions after running scalafmt. +This is the general reason **scalafmt belongs before the final full-suite run, not after it**: +formatting rewrites the very spec files the suite just exercised, so a run that precedes it has to +be repeated. Format once the narrow specs are green, revert the unrelated churn scalafmt always +produces, then run the suite. + Any edit that changes the file's LINE COUNT shifts every expectation below it, so adding a test in the middle breaks unrelated tests that were passing. Append new tests at the end of the file. When a mid-file edit is unavoidable (rewriting an existing test), do not hand-patch the fallout: munit From 7778923b466f112112faabf50193d769cad4b231 Mon Sep 17 00:00:00 2001 From: Oron Port Date: Tue, 11 Aug 2026 06:52:45 +0300 Subject: [PATCH 15/40] compiler: name a text output through its `val` binding `val posW = assert(W > 0, ...)` now carries `posW` to the backend as a statement label. The name was already stamped on the member's meta by the plugin; `TextOut` becomes `DFMember.Named` so the rest of the pipeline can read it, and the DFHDL printer emits the `val` binding so the name survives the print / re-elaborate round trip. VHDL labels a statement directly, concurrent and sequential alike. Verilog names a block rather than a statement, so a named assertion becomes a named block: the generate block of the elaboration form (which also settles the implicit `genblk` a linter complains about), the `initial` block of the older dialects, and a plain statement label where 1800 immediate assertions are available. An anonymous assertion keeps the tightest form the dialect allows, unchanged. A label lives in the enclosing module/architecture namespace even when the statement sits inside a process, which the per-block scoping of `UniqueNames` does not model: a label sharing a port's name made both Verilator and GHDL reject the design. Text outputs are therefore uniquified per DESIGN, in a pass of their own so that a collision renames the label and never the declaration it collided with. Also fixes the arity of `$fatal` in an assertion's else-branch, which was missing the mandatory finish number. Co-Authored-By: Claude Opus 5 (1M context) --- .../scala/dfhdl/compiler/ir/DFMember.scala | 7 +- .../dfhdl/compiler/printing/Printer.scala | 6 +- .../dfhdl/compiler/stages/UniqueNames.scala | 28 +++++++- .../stages/verilog/VerilogPrinter.scala | 47 +++++++++---- .../compiler/stages/vhdl/VHDLPrinter.scala | 6 +- .../StagesSpec/PrintCodeStringSpec.scala | 26 +++++++ .../scala/StagesSpec/PrintVHDLCodeSpec.scala | 42 ++++++++++++ .../StagesSpec/PrintVerilogCodeSpec.scala | 68 ++++++++++++++++++- .../scala/StagesSpec/UniqueNamesSpec.scala | 26 +++++++ 9 files changed, 237 insertions(+), 19 deletions(-) diff --git a/compiler/ir/src/main/scala/dfhdl/compiler/ir/DFMember.scala b/compiler/ir/src/main/scala/dfhdl/compiler/ir/DFMember.scala index 3a7d63255..0ec392afe 100644 --- a/compiler/ir/src/main/scala/dfhdl/compiler/ir/DFMember.scala +++ b/compiler/ir/src/main/scala/dfhdl/compiler/ir/DFMember.scala @@ -2156,6 +2156,10 @@ end Wait object Wait: type TriggerRef = DFRef.TwoWay[DFVal, Wait] +// Named so that `val xyz = assert(...)` carries `xyz` all the way to the backend label (and +// through `UniqueNames`, which shares one namespace with the design's declarations). A text +// output is a statement, so nothing ever references it by that name; it exists to identify the +// statement in the generated code and in a tool's report. final case class TextOut( op: TextOut.Op, msgParts: List[String], @@ -2163,7 +2167,8 @@ final case class TextOut( ownerRef: DFOwner.Ref, meta: Meta, tags: DFTags -) extends Statement: +) extends Statement, + DFMember.Named: protected def `prot_=~`(that: DFMember)(using MemberGetSet): Boolean = that match case that: TextOut => this.op =~ that.op && this.msgParts == that.msgParts && this.msgArgs =~ that.msgArgs && diff --git a/compiler/ir/src/main/scala/dfhdl/compiler/printing/Printer.scala b/compiler/ir/src/main/scala/dfhdl/compiler/printing/Printer.scala index ed3bb6b3a..1e032e513 100644 --- a/compiler/ir/src/main/scala/dfhdl/compiler/printing/Printer.scala +++ b/compiler/ir/src/main/scala/dfhdl/compiler/printing/Printer.scala @@ -874,7 +874,9 @@ class DFPrinter(using val getSet: MemberGetSet, val printerOptions: PrinterOptio textOut.msgArgs.view.map(a => s"$${${a.refCodeString}}") ).mkString.emptyOr(m => s"s\"$m\"") end match - textOut.op match + // a named text output is bound to a `val`, which is what carries its name into the backend + val csName = if (textOut.isAnonymous) "" else s"val ${textOut.getName} = " + val csOp = textOut.op match case TextOut.Op.Finish => "finish()" case TextOut.Op.Report(severity) => val csSeverity = if (severity == TextOut.Severity.Info) "" else s", Severity.${severity}" @@ -885,7 +887,7 @@ class DFPrinter(using val getSet: MemberGetSet, val printerOptions: PrinterOptio case TextOut.Op.Print => s"print($msg)" case TextOut.Op.Println => s"println($msg)" case TextOut.Op.Debug => s"debug($msg)" - end match + s"$csName$csOp" end csTextOut // to remove ambiguity in referencing a port inside a class instance we add `this.` as prefix def csCommentInline(comment: String): String = diff --git a/compiler/stages/src/main/scala/dfhdl/compiler/stages/UniqueNames.scala b/compiler/stages/src/main/scala/dfhdl/compiler/stages/UniqueNames.scala index f5f6c3327..653072523 100644 --- a/compiler/stages/src/main/scala/dfhdl/compiler/stages/UniqueNames.scala +++ b/compiler/stages/src/main/scala/dfhdl/compiler/stages/UniqueNames.scala @@ -106,7 +106,9 @@ private abstract class UniqueNames(reservedNames: Set[String], caseSensitive: Bo // and will be handled after the binds are converted to explicit selectors case Bind(_) => None // design block names are their declaration names (design/class name), so they are handled differently - case _: DFDesignBlock => None + case _: DFDesignBlock => None + // text output names are statement labels, scoped to the design (see below) + case _: TextOut => None case m: DFMember.Named if !m.isAnonymous => Some(m) case _ => None }, @@ -115,6 +117,30 @@ private abstract class UniqueNames(reservedNames: Set[String], caseSensitive: Bo _.getName, (m, n) => m -> Patch.Replace(m.setName(n), Patch.Replace.Config.FullReplacement) ).foreach(entry => memberRenamePatches(entry._1) = entry) + // A text output's name becomes a statement label in the generated HDL, and a label + // lives in the enclosing module/architecture namespace rather than in the process + // that holds the statement. Labels are therefore uniquified against every name in the + // design, in a pass of their own so that a collision renames the label and never the + // declaration it collided with. + block match + case design: DFDesignBlock => + val designNamesLC = lowerCases( + sub.membersNoGlobals.view.collect { + case m: DFMember.Named if !m.isAnonymous && !m.isInstanceOf[TextOut] => + m.getName + }.toSet + ) + renamer( + sub.membersNoGlobals.collect { + case t: TextOut if !t.isAnonymous => t + }, + designNamesLC ++ localReservedNamesLC + )( + _.getName, + (m, n) => m -> Patch.Replace(m.setName(n), Patch.Replace.Config.FullReplacement) + ).foreach(entry => memberRenamePatches(entry._1) = entry) + case _ => + end match } } } diff --git a/compiler/stages/src/main/scala/dfhdl/compiler/stages/verilog/VerilogPrinter.scala b/compiler/stages/src/main/scala/dfhdl/compiler/stages/verilog/VerilogPrinter.scala index 130034aa5..e157e9174 100644 --- a/compiler/stages/src/main/scala/dfhdl/compiler/stages/verilog/VerilogPrinter.scala +++ b/compiler/stages/src/main/scala/dfhdl/compiler/stages/verilog/VerilogPrinter.scala @@ -149,30 +149,53 @@ class VerilogPrinter(val dialect: VerilogDialect)(using case TextOut.Op.Assert(assertionRef, severity) if textOut.isStaticAssert => val cond = assertionRef.refCodeString val failMsg = if (msg.isEmpty) scalaToVerilogString("Assertion failed!") else msg + // Verilog names a block, not a statement, so a named assertion becomes a named block: + // the generate block of the elaboration form, or the `initial` block of the others. + // Naming the generate block is also what keeps a linter from complaining about the + // implicit `genblk` the LRM would otherwise assign it. + // an anonymous assertion keeps the tightest form the dialect allows; a named one is + // wrapped in a named block, which is the only thing Verilog lets a name attach to + def named(body: String): String = + s"""|begin : ${textOut.getName} + |${body.hindent} + |end""".stripMargin if (elabTaskIsSupported) val errCodeArg = if (severity == TextOut.Severity.Fatal) "1, " else "" - s"if (!($cond)) ${csSeverity(severity)}($errCodeArg$failMsg);" + val task = s"${csSeverity(severity)}($errCodeArg$failMsg);" + if (textOut.isAnonymous) s"if (!($cond)) $task" + else s"if (!($cond)) ${named(task)}" else if (assertIsSupported) - s"""|initial - | assert ($cond) - | else ${csSeverity(severity)}($failMsg);""".stripMargin + val errCodeArg = if (severity == TextOut.Severity.Fatal) "1, " else "" + val body = + s"""|assert ($cond) + |else ${csSeverity(severity)}($errCodeArg$failMsg);""".stripMargin + if (textOut.isAnonymous) s"initial\n${body.hindent}" + else s"initial ${named(body)}" else val failLines = if (msgLines.isEmpty) List(("Assertion failed!", Nil)) else msgLines - s"""|initial if (!($cond)) begin - |${csDisplay(severity, failLines).hindent} - |end""".stripMargin + val body = + s"""|if (!($cond)) begin + |${csDisplay(severity, failLines).hindent} + |end""".stripMargin + if (textOut.isAnonymous) s"initial $body" + else s"initial ${named(body)}" + end if case TextOut.Op.Assert(assertionRef, severity) => + // a procedural assertion: 1800 allows a statement label, older dialects only a block + val csLabel = if (textOut.isAnonymous) "" else s"${textOut.getName}: " + val csBlockName = if (textOut.isAnonymous) "" else s" : ${textOut.getName}" + val errCodeArg = if (severity == TextOut.Severity.Fatal) "1, " else "" if (msg.isEmpty) - if (assertIsSupported) s"assert (${assertionRef.refCodeString});" + if (assertIsSupported) s"${csLabel}assert (${assertionRef.refCodeString});" else - s"""|if (!(${assertionRef.refCodeString})) begin + s"""|if (!(${assertionRef.refCodeString})) begin$csBlockName |${csDisplay(severity, List(("Assertion failed!", Nil))).hindent} |end""".stripMargin else if (assertIsSupported) - s"""|assert (${assertionRef.refCodeString}) - |else ${csSeverity(severity)}($msg);""".stripMargin + s"""|${csLabel}assert (${assertionRef.refCodeString}) + |else ${csSeverity(severity)}($errCodeArg$msg);""".stripMargin else - s"""|if (!(${assertionRef.refCodeString})) begin + s"""|if (!(${assertionRef.refCodeString})) begin$csBlockName |${csDisplay(severity, msgLines).hindent} |end""".stripMargin case TextOut.Op.Print => s"$$write($msg);" diff --git a/compiler/stages/src/main/scala/dfhdl/compiler/stages/vhdl/VHDLPrinter.scala b/compiler/stages/src/main/scala/dfhdl/compiler/stages/vhdl/VHDLPrinter.scala index 193db78f3..11e2c08d0 100644 --- a/compiler/stages/src/main/scala/dfhdl/compiler/stages/vhdl/VHDLPrinter.scala +++ b/compiler/stages/src/main/scala/dfhdl/compiler/stages/vhdl/VHDLPrinter.scala @@ -143,10 +143,12 @@ class VHDLPrinter(val dialect: VHDLDialect)(using "std.env.finish;" case TextOut.Op.Report(severity) => csReport(severity, msg) case TextOut.Op.Assert(assertionRef, severity) => + // a named assertion labels its statement, concurrent and sequential alike + val csLabel = if (textOut.isAnonymous) "" else s"${textOut.getName}: " if (msg.isEmpty) - s"assert ${printer.csFixedCond(assertionRef)};" + s"${csLabel}assert ${printer.csFixedCond(assertionRef)};" else - s"""|assert ${printer.csFixedCond(assertionRef)} + s"""|${csLabel}assert ${printer.csFixedCond(assertionRef)} |${csReport(severity, msg).hindent}""".stripMargin case TextOut.Op.Print => s"print($msg);" case TextOut.Op.Println => diff --git a/compiler/stages/src/test/scala/StagesSpec/PrintCodeStringSpec.scala b/compiler/stages/src/test/scala/StagesSpec/PrintCodeStringSpec.scala index 75b8993d0..86c47aec0 100644 --- a/compiler/stages/src/test/scala/StagesSpec/PrintCodeStringSpec.scala +++ b/compiler/stages/src/test/scala/StagesSpec/PrintCodeStringSpec.scala @@ -3287,4 +3287,30 @@ class PrintCodeStringSpec extends StageSpec(stageCreatesUnrefAnons = true): |""".stripMargin ) } + test("named text output") { + // binding a text output to a `val` names the statement, which the backends turn into a + // label; the name shares the design's namespace, so `UniqueNames` resolves a collision + class Named(val W: Int <> CONST = 8) extends EDDesign: + val i = UInt(W) <> IN + val o = UInt(W) <> OUT + val posW = assert(W > 0, s"W must be positive, got $W") + process(all): + val inRange = assert(i < d"8'200", s"i too large: $i") + val trace = println(s"i: $i") + o <> i + end Named + assertCodeString( + Named(), + """|class Named(val W: Int <> CONST = 8) extends EDDesign: + | val i = UInt(W) <> IN + | val o = UInt(W) <> OUT + | val posW = assert(W > 0, s"W must be positive, got ${W}") + | process(all): + | val inRange = assert(i < d"8'200".resize(W), s"i too large: ${i}") + | val trace = println(s"i: ${i}") + | o <> i + |end Named + |""".stripMargin + ) + } end PrintCodeStringSpec diff --git a/compiler/stages/src/test/scala/StagesSpec/PrintVHDLCodeSpec.scala b/compiler/stages/src/test/scala/StagesSpec/PrintVHDLCodeSpec.scala index 554d5ac96..b18588444 100644 --- a/compiler/stages/src/test/scala/StagesSpec/PrintVHDLCodeSpec.scala +++ b/compiler/stages/src/test/scala/StagesSpec/PrintVHDLCodeSpec.scala @@ -3660,4 +3660,46 @@ class PrintVHDLCodeSpec extends StageSpec: |""".stripMargin ) } + // VHDL labels a statement directly, concurrent and sequential alike + test("named assertion") { + class NamedGuard(val W: Int <> CONST = 8) extends EDDesign: + val i = UInt(W) <> IN + val o = UInt(W) <> OUT + val posW = assert(W > 0, s"W must be positive, got $W") + process(all): + val inRange = assert(i < d"8'200".resize(W), s"i too large: $i") + o <> i + end NamedGuard + val top = NamedGuard().getCompiledCodeString + assertNoDiff( + top, + """|library ieee; + |use ieee.std_logic_1164.all; + |use ieee.numeric_std.all; + |use work.dfhdl_pkg.all; + | + |entity NamedGuard is + |generic ( + | W : integer := 8 + |); + |port ( + | i : in unsigned(W - 1 downto 0); + | o : out unsigned(W - 1 downto 0) + |); + |end NamedGuard; + | + |architecture NamedGuard_arch of NamedGuard is + |begin + | posW: assert W > 0 + | report "W must be positive, got " & to_string(W) & "" severity ERROR; + | process (all) + | begin + | inRange: assert i < resize(8d"200", W) + | report "i too large: " & to_string(i) & "" severity ERROR; + | end process; + | o <= i; + |end NamedGuard_arch; + |""".stripMargin + ) + } end PrintVHDLCodeSpec diff --git a/compiler/stages/src/test/scala/StagesSpec/PrintVerilogCodeSpec.scala b/compiler/stages/src/test/scala/StagesSpec/PrintVerilogCodeSpec.scala index 9a3541e75..1f56d63ff 100644 --- a/compiler/stages/src/test/scala/StagesSpec/PrintVerilogCodeSpec.scala +++ b/compiler/stages/src/test/scala/StagesSpec/PrintVerilogCodeSpec.scala @@ -1123,7 +1123,7 @@ class PrintVerilogCodeSpec extends StageSpec: | assert (param == "hello2") | else $error("I am the one %s who knocks", param); | assert (param8) - | else $fatal( + | else $fatal(1, | "I\\am\n", | "the \"one\"(!)\n", | "%s\n", param, @@ -3562,4 +3562,70 @@ class PrintVerilogCodeSpec extends StageSpec: |""".stripMargin ) } + // Verilog names a block, not a statement, so a named assertion becomes a named block: the + // generate block of the elaboration form, the `initial` block of the older ones. A procedural + // immediate assertion takes a statement label directly (1800 only). + test("named assertion under sv2009") { + given options.CompilerOptions.Backend = _.verilog.sv2009 + class NamedGuardNew(val W: Int <> CONST = 8) extends EDDesign: + val i = UInt(W) <> IN + val o = UInt(W) <> OUT + val posW = assert(W > 0, s"W must be positive, got $W") + process(all): + val inRange = assert(i < d"8'200".resize(W), s"i too large: $i") + o <> i + end NamedGuardNew + val top = NamedGuardNew().getCompiledCodeString + assertNoDiff( + top, + """|`default_nettype none + |`timescale 1ns/1ps + | + |module NamedGuardNew#(parameter int W = 8)( + | input wire logic [W - 1:0] i, + | output logic [W - 1:0] o + |); + | `include "dfhdl_defs.svh" + | if (!(W > 0)) begin : posW + | $error("W must be positive, got %d", W); + | end + | always_comb + | begin + | inRange: assert (i < W'(8'd200)) + | else $error("i too large: %d", i); + | end + | assign o = i; + |endmodule + |""".stripMargin + ) + } + test("named assertion under v2001") { + given options.CompilerOptions.Backend = _.verilog.v2001 + class NamedGuardOld(val W: Int <> CONST = 8) extends EDDesign: + val i = UInt(W) <> IN + val o = UInt(W) <> OUT + val posW = assert(W > 0, s"W must be positive, got $W") + o <> i + end NamedGuardOld + val top = NamedGuardOld().getCompiledCodeString + assertNoDiff( + top, + """|`default_nettype none + |`timescale 1ns/1ps + | + |module NamedGuardOld#(parameter integer W = 8)( + | input wire [W - 1:0] i, + | output wire [W - 1:0] o + |); + | `include "dfhdl_defs.vh" + | initial begin : posW + | if (!(W > 0)) begin + | $display("ERROR: W must be positive, got %d", W); + | end + | end + | assign o = i; + |endmodule + |""".stripMargin + ) + } end PrintVerilogCodeSpec diff --git a/compiler/stages/src/test/scala/StagesSpec/UniqueNamesSpec.scala b/compiler/stages/src/test/scala/StagesSpec/UniqueNamesSpec.scala index 9901b46a4..a40a03deb 100644 --- a/compiler/stages/src/test/scala/StagesSpec/UniqueNamesSpec.scala +++ b/compiler/stages/src/test/scala/StagesSpec/UniqueNamesSpec.scala @@ -138,4 +138,30 @@ class UniqueNamesSpec extends StageSpec: ) } + test("named text output") { + // a text output's name becomes a statement LABEL, which lives in the enclosing + // module/architecture namespace even when the statement sits inside a process. So it is + // uniquified against the whole design, and the collision renames the label, never the + // declaration it collided with. + class Labels extends EDDesign: + val chk = UInt(8) <> IN + val o = UInt(8) <> OUT + process(all): + val chk = assert(this.chk < d"8'200", s"too large") + o <> chk + end Labels + val labels = Labels().uniqueNames(Set(), true) + assertCodeString( + labels, + """|class Labels extends EDDesign: + | val chk = UInt(8) <> IN + | val o = UInt(8) <> OUT + | process(all): + | val chk_0 = assert(chk < d"8'200", s"too large") + | o <> chk + |end Labels + |""".stripMargin + ) + } + end UniqueNamesSpec From 9c3152635c9f35db1704f58920ebd4356a1ce6f8 Mon Sep 17 00:00:00 2001 From: Oron Port Date: Tue, 11 Aug 2026 15:21:14 +0300 Subject: [PATCH 16/40] core+compiler: auto value constraints for unprovable width fits A parametric width relation that elaboration can neither prove nor refute was handled two ways, and both were wrong. The `DFXInt` TC leaf rejected it ("undefined compared to"), demanding a `.resize` of code that is very probably correct; the `ArithCheck` arm accepted it in silence, letting a parameter override truncate. Such a relation is now ACCEPTED, with the fit it needs stated in the design as a static assertion, so every instantiation checks at its own elaboration what this one could not: class Bar(val W: Int <> CONST = 8) extends RTDesign: val x = UInt(W) <> IN val z = UInt(16) <> OUT z := x val constraint = assert(16 >= W, s"Design parameter violation found. Expected: 16 >= W", Severity.Fatal) reaching sv2009 as a module-scope `if (!(16 >= W)) begin : constraint $fatal(1, ...); end` and VHDL as a labeled concurrent assert. A provably violated relation stays the hard error it was, reporting the check's own call-site text. The condition IS the record: an undecided check tags the guard it built `AutoConstraint` and leaves it where it stands. The end of the design body collects the tagged guards from the design context in member order, drops the ones stating a relation another already states (compared as relations through `IntExprCalc.sameDiff`, so `W + W >= 8` and `2 * W >= 8` are one), clones each survivor's cone into the body and plants the assertion over the clone. Cloning is what lets a check inside a conditional block state its relation at the design's level; the original is then read by nothing and the existing end-of-design sweep collects it, which is also what makes a dropped constraint free. The tag is consumed there, so no member of a finished design carries one. What the assertion reports is derived from the condition and nothing else. A check's message describes ONE operation, while the assertion describes the design's interface: dedup merges several operations into one statement, and its reader instantiates the generated module rather than having written the assignment. Only the elaboration root generates constraints, which is the same scope as the problem: a sub-design's parameters are fixed by the instantiation elaborating it, so its widths resolve and the relation is decided then. Co-Authored-By: Claude Opus 5 (1M context) --- .../main/scala/dfhdl/compiler/ir/DFTags.scala | 12 + .../scala/dfhdl/compiler/ir/IntExprCalc.scala | 13 ++ .../StagesSpec/PrintCodeStringSpec.scala | 87 ++++++++ .../scala/StagesSpec/PrintVHDLCodeSpec.scala | 41 ++++ .../StagesSpec/PrintVerilogCodeSpec.scala | 35 +++ .../scala/dfhdl/core/AutoConstraint.scala | 133 +++++++++++ .../src/main/scala/dfhdl/core/DFDecimal.scala | 66 ++++-- core/src/main/scala/dfhdl/core/Design.scala | 12 +- core/src/main/scala/dfhdl/core/IntParam.scala | 16 ++ .../src/main/scala/dfhdl/core/MutableDB.scala | 11 + .../test/scala/ElaborationChecksSpec.scala | 208 ++++++++---------- 11 files changed, 504 insertions(+), 130 deletions(-) create mode 100644 core/src/main/scala/dfhdl/core/AutoConstraint.scala diff --git a/compiler/ir/src/main/scala/dfhdl/compiler/ir/DFTags.scala b/compiler/ir/src/main/scala/dfhdl/compiler/ir/DFTags.scala index 5817becd1..17b8003d6 100644 --- a/compiler/ir/src/main/scala/dfhdl/compiler/ir/DFTags.scala +++ b/compiler/ir/src/main/scala/dfhdl/compiler/ir/DFTags.scala @@ -35,6 +35,18 @@ case class DFHDLVersionTag(version: String) extends DFTag case object PhantomTag extends DFTag type PhantomTag = PhantomTag.type +/** Marks a constant condition that an operation ASSUMED and elaboration could not prove, pending + * materialization as a static assertion of the design's contract. A pure marker: the condition it + * marks is the whole constraint, down to the text the assertion reports. + * + * Created and consumed inside one design's elaboration (the end of the design body materializes + * every pending constraint and drops the tag), so no member of a finished design carries it. It is + * therefore an elaboration-internal marker, not a stage marker: nothing downstream reads it, and + * the materialized assertion is what carries the constraint from there on. + */ +case object AutoConstraint extends DFTag +type AutoConstraint = AutoConstraint.type + opaque type DFTags = Map[String, DFTag] object DFTags: given ReadWriter[DFTags] = summon[ReadWriter[Map[String, DFTag]]] diff --git a/compiler/ir/src/main/scala/dfhdl/compiler/ir/IntExprCalc.scala b/compiler/ir/src/main/scala/dfhdl/compiler/ir/IntExprCalc.scala index 8a7059069..5c50675dc 100644 --- a/compiler/ir/src/main/scala/dfhdl/compiler/ir/IntExprCalc.scala +++ b/compiler/ir/src/main/scala/dfhdl/compiler/ir/IntExprCalc.scala @@ -72,6 +72,19 @@ object IntExprCalc: else None end widthFitCompare + /** Whether `a1 - b1` and `a2 - b2` are the same linear expression. + * + * The identity of a parametric relation between two integer expressions, so `W + W >= 8` and + * `2 * W >= 8` state one relation rather than two. Design parameters stay OPAQUE, which is what + * makes it an identity of the DESIGN's relation: two relations that coincide only for the values + * of one instantiation stay distinct. + */ + def sameDiff(a1: DFVal, b1: DFVal)(a2: DFVal, b2: DFVal)(using MemberGetSet): Boolean = + val calc = Calc(ParamResolve.Opaque) + def diff(a: DFVal, b: DFVal): Linear = calc.sub(calc.linear(a), calc.linear(b)) + val d = calc.sub(diff(a1, b1), diff(a2, b2)) + d.terms.isEmpty && d.offset == 0 + /** How the calculus treats a [[DFVal.DesignParam]] it reaches. */ private enum ParamResolve derives CanEqual: /** Stays an opaque base, so a decision holds for any parameter assignment (elaboration-time diff --git a/compiler/stages/src/test/scala/StagesSpec/PrintCodeStringSpec.scala b/compiler/stages/src/test/scala/StagesSpec/PrintCodeStringSpec.scala index 86c47aec0..3fec31f85 100644 --- a/compiler/stages/src/test/scala/StagesSpec/PrintCodeStringSpec.scala +++ b/compiler/stages/src/test/scala/StagesSpec/PrintCodeStringSpec.scala @@ -3313,4 +3313,91 @@ class PrintCodeStringSpec extends StageSpec(stageCreatesUnrefAnons = true): |""".stripMargin ) } + test("auto constraint from an unprovable width fit") { + // A width relation that is neither provably held nor provably violated is accepted, and the + // fit the operation needs is stated as a static assertion at the tail of the body. The two + // writes to `z` assume the same relation and state it once; the write to `n` assumes another. + class Fits(val W: Int <> CONST = 8) extends RTDesign: + val x = UInt(W) <> IN + val z = UInt(16) <> OUT + val n = UInt(8) <> OUT + z := x + z := x + 1 + n := x + end Fits + assertCodeString( + Fits(), + """|class Fits(val W: Int <> CONST = 8) extends RTDesign: + | val x = UInt(W) <> IN + | val z = UInt(16) <> OUT + | val n = UInt(8) <> OUT + | z := x.resize(16) + | z := x.resize(16) + d"1'1".resize(W).resize(16) + | n := x.resize(8) + | val constraint_0 = assert(16 >= W, s"Design parameter violation found. Expected: 16 >= W", Severity.Fatal) + | val constraint_1 = assert(8 >= W, s"Design parameter violation found. Expected: 8 >= W", Severity.Fatal) + |end Fits + |""".stripMargin + ) + } + test("auto constraint raised inside a block") { + // The condition is built where the check runs, which here is inside a conditional inside a + // process: a scope the body cannot read from. Materialization clones its cone into the body, + // so the assertion states the relation at the design's own level. + class Blocked(val W: Int <> CONST = 8) extends EDDesign: + val sel = Bit <> IN + val x = UInt(W) <> IN + val z = UInt(16) <> OUT + process(all): + if (sel) z :== x + else z :== 0 + end Blocked + assertCodeString( + Blocked(), + """|class Blocked(val W: Int <> CONST = 8) extends EDDesign: + | val sel = Bit <> IN + | val x = UInt(W) <> IN + | val z = UInt(16) <> OUT + | process(all): + | if (sel) z :== x.resize(16) + | else z :== d"16'0" + | val constraint = assert(16 >= W, s"Design parameter violation found. Expected: 16 >= W", Severity.Fatal) + |end Blocked + |""".stripMargin + ) + } + test("auto constraint qualifies same-named width constants") { + // The message is built from the same lambda as the compile-time check's, so two same-named + // constants from different designs stay distinguishable in it, exactly as they do in a width + // error. + class WidthChild(val W: Int <> CONST = 4) extends EDDesign: + val OUTPUT_WIDTH = W * 2 + val o = UInt(OUTPUT_WIDTH) <> OUT + o <> 0 + end WidthChild + class WidthParent(val W: Int <> CONST = 8) extends EDDesign: + val OUTPUT_WIDTH = W + val o = UInt(OUTPUT_WIDTH) <> OUT + val c = WidthChild(W = 4) + o <> c.o + end WidthParent + assertCodeString( + WidthParent(), + """|class WidthChild(val W: Int <> CONST = 4) extends EDDesign: + | val OUTPUT_WIDTH: Int <> CONST = W * 2 + | val o = UInt(OUTPUT_WIDTH) <> OUT + | o <> d"1'0".resize(OUTPUT_WIDTH) + |end WidthChild + | + |class WidthParent(val W: Int <> CONST = 8) extends EDDesign: + | val OUTPUT_WIDTH: Int <> CONST = W + | val o = UInt(OUTPUT_WIDTH) <> OUT + | val c = WidthChild(W = 4) + | val c_OUTPUT_WIDTH: Int <> CONST = 4 * 2 + | o <> c.o.resize(OUTPUT_WIDTH) + | val constraint = assert(OUTPUT_WIDTH >= c_OUTPUT_WIDTH, s"Design parameter violation found. Expected: OUTPUT_WIDTH >= c_OUTPUT_WIDTH", Severity.Fatal) + |end WidthParent + |""".stripMargin + ) + } end PrintCodeStringSpec diff --git a/compiler/stages/src/test/scala/StagesSpec/PrintVHDLCodeSpec.scala b/compiler/stages/src/test/scala/StagesSpec/PrintVHDLCodeSpec.scala index b18588444..ef0beb38b 100644 --- a/compiler/stages/src/test/scala/StagesSpec/PrintVHDLCodeSpec.scala +++ b/compiler/stages/src/test/scala/StagesSpec/PrintVHDLCodeSpec.scala @@ -3702,4 +3702,45 @@ class PrintVHDLCodeSpec extends StageSpec: |""".stripMargin ) } + test("auto constraint") { + // an assumption the width algebra could not prove reaches the backend as the design's own + // elaboration-time contract, over the generic the generated entity leaves overridable + class Fits(val W: Int <> CONST = 8) extends RTDesign: + val x = UInt(W) <> IN + val z = UInt(16) <> OUT + val n = UInt(8) <> OUT + z := x + n := x + end Fits + val top = Fits().getCompiledCodeString + assertNoDiff( + top, + """|library ieee; + |use ieee.std_logic_1164.all; + |use ieee.numeric_std.all; + |use work.dfhdl_pkg.all; + | + |entity Fits is + |generic ( + | W : integer := 8 + |); + |port ( + | x : in unsigned(W - 1 downto 0); + | z : out unsigned(15 downto 0); + | n : out unsigned(7 downto 0) + |); + |end Fits; + | + |architecture Fits_arch of Fits is + |begin + | constraint_0: assert 16 >= W + | report "Design parameter violation found. Expected: 16 >= W" severity FAILURE; + | constraint_1: assert 8 >= W + | report "Design parameter violation found. Expected: 8 >= W" severity FAILURE; + | z <= resize(x, 16); + | n <= resize(x, 8); + |end Fits_arch; + |""".stripMargin + ) + } end PrintVHDLCodeSpec diff --git a/compiler/stages/src/test/scala/StagesSpec/PrintVerilogCodeSpec.scala b/compiler/stages/src/test/scala/StagesSpec/PrintVerilogCodeSpec.scala index 1f56d63ff..a46b05dad 100644 --- a/compiler/stages/src/test/scala/StagesSpec/PrintVerilogCodeSpec.scala +++ b/compiler/stages/src/test/scala/StagesSpec/PrintVerilogCodeSpec.scala @@ -3628,4 +3628,39 @@ class PrintVerilogCodeSpec extends StageSpec: |""".stripMargin ) } + test("auto constraint under sv2009") { + // an assumption the width algebra could not prove reaches the backend as the design's own + // elaboration-time contract, over the parameter the generated module leaves overridable + given options.CompilerOptions.Backend = _.verilog.sv2009 + class Fits(val W: Int <> CONST = 8) extends RTDesign: + val x = UInt(W) <> IN + val z = UInt(16) <> OUT + val n = UInt(8) <> OUT + z := x + n := x + end Fits + val top = Fits().getCompiledCodeString + assertNoDiff( + top, + """|`default_nettype none + |`timescale 1ns/1ps + | + |module Fits#(parameter int W = 8)( + | input wire logic [W - 1:0] x, + | output logic [15:0] z, + | output logic [7:0] n + |); + | `include "dfhdl_defs.svh" + | if (!(16 >= W)) begin : constraint_0 + | $fatal(1, "Design parameter violation found. Expected: 16 >= W"); + | end + | if (!(8 >= W)) begin : constraint_1 + | $fatal(1, "Design parameter violation found. Expected: 8 >= W"); + | end + | assign z = 16'(x); + | assign n = 8'(x); + |endmodule + |""".stripMargin + ) + } end PrintVerilogCodeSpec diff --git a/core/src/main/scala/dfhdl/core/AutoConstraint.scala b/core/src/main/scala/dfhdl/core/AutoConstraint.scala new file mode 100644 index 000000000..b943e088f --- /dev/null +++ b/core/src/main/scala/dfhdl/core/AutoConstraint.scala @@ -0,0 +1,133 @@ +package dfhdl.core +import dfhdl.compiler.ir +import dfhdl.internals.* +import ir.DFVal.Func.{Op => FuncOp} +import ir.TextOut.Severity +import scala.collection.mutable + +/** The assumptions a design's elaboration made but could not prove, and the static assertions they + * become. + * + * The width algebra decides a parametric relation three ways: provably true (nothing to do), + * provably false (an elaboration error), and undecidable. An undecidable relation that an + * operation nevertheless RELIES ON is an assumption, and it is neither honest to reject the + * operation for it nor to accept it silently. The third way is to accept the operation and state + * the assumption in the design, so that every instantiation checks at its own elaboration what + * this one could not. + * + * A check that cannot decide calls [[raise]] with the condition it assumed. That condition is a + * constant expression over the design's parameters; it is tagged [[ir.AutoConstraint]] and left + * exactly where the check built it. The end of the design body ([[materialize]]) collects the + * tagged conditions, drops the ones stating a relation another already states, and plants one + * static assertion per survivor at the tail of the body. + * + * What the assertion reports is derived from the condition and nothing else. The check that raised + * it has a message of its own, but that message describes ONE operation, while the assertion + * describes the design's interface: it survives dedup and minimization, which merge the + * assumptions of several operations into one statement, and it is read by whoever instantiates the + * generated module rather than by whoever wrote the assignment. The operation's own message stays + * where it belongs, on the elaboration error for a relation that is provably violated. + */ +object AutoConstraint: + /** The name a materialized constraint carries, enumerated when a design has more than one. It + * labels the statement in the generated HDL, where an unnamed SystemVerilog elaboration block is + * what a linter complains about. Enumerated HERE rather than left to `UniqueNames`, because the + * printed DFHDL is source: two `val constraint = ...` bindings in one body would not + * re-elaborate. The enumeration follows `UniqueNames`'s own, so it renames nothing further. + */ + private def constraintName(idx: Int, count: Int): String = + if (count == 1) "constraint" else s"constraint_${idx.toPaddedString(count)}" + + /** A constraint's condition. Constant, so the assertion it becomes is a contract checked at the + * generated design's elaboration rather than a runtime test. + */ + type Guard = DFValOf[DFBool] + + /** The condition `lhs >= rhs`, built as a value instead of decided. */ + def ge(lhs: IntParam[Int], rhs: IntParam[Int])(using dfc: DFC): Guard = + given DFC = dfc.anonymize + DFVal.Func[DFBool, Any](DFBool, FuncOp.>=, List(lhs.toDFConst.asIR, rhs.toDFConst.asIR)) + + /** Decides the width fit `lhs >= rhs`, or `None` when it holds for some parameter assignments and + * not others. The undecided answer is what [[raise]] exists for. + */ + def widthFitGE(lhs: IntParam[Int], rhs: IntParam[Int])(using dfc: DFC): Option[Boolean] = + import dfc.getSet + // decided on the values rather than on references to them: a reference minted here belongs to + // no member, and a reference with no origin member is one the printer cannot resolve a + // relative name against (see `IntParam.errorString`) + (lhs.toScalaIntOpt, rhs.toScalaIntOpt) match + case (Some(lhsInt), Some(rhsInt)) => Some(lhsInt >= rhsInt) + case _ => ir.IntExprCalc.widthFitCompare(lhs.toDFConst.asIR, rhs.toDFConst.asIR) + + /** Records `guard` as an assumption of the design being elaborated, to be materialized as a + * static assertion at the end of its body. + * + * Nothing is recorded anywhere else: the guard IS the record, and its own meta is the position + * of the operation that assumed it. + */ + def raise(guard: Guard)(using dfc: DFC): Unit = + // nothing states a constraint outside a design: global scope has no body to put it in, and a + // stage's meta design transforms an already-elaborated one and assumes nothing of its own + if (!dfc.inMetaProgramming && dfc.ownerOption.isDefined) + import dfc.getSet + guard.asIR.setTags(_.tag(ir.AutoConstraint)) + () + + /** Whether two guards state the same constraint. A relation between integer expressions is + * compared as a relation (`W + W >= 8` and `2 * W >= 8` are one), and anything else + * structurally. + */ + private def sameConstraint(a: ir.DFVal, b: ir.DFVal)(using ir.MemberGetSet): Boolean = + (a, b) match + case ( + ir.DFVal.Func(op = opA, args = List(lhsA, rhsA)), + ir.DFVal.Func(op = opB, args = List(lhsB, rhsB)) + ) if opA == opB => + ir.IntExprCalc.sameDiff(lhsA.get, rhsA.get)(lhsB.get, rhsB.get) + case _ => a =~ b + + /** The condition as the design states it, which is the whole of what a violation has to report. + */ + private def report(guard: ir.DFVal)(using dfc: DFC): String = + import dfc.getSet + import dfhdl.compiler.printing.{Printer, DefaultPrinter} + given printer: Printer = DefaultPrinter + val callOwner: ir.DFOwner | ir.DFMember.Empty = dfc.ownerOption match + case Some(owner) => owner.asIR + case None => ir.DFMember.Empty + s"Design parameter violation found. Expected: ${printer.csDFValRef(guard, callOwner)}" + + /** Plants the design's pending constraints as static assertions at the tail of its body. + * + * Run at the end of the body, under the body's own context: the guards were built wherever their + * checks fired, which for a check inside a conditional block is a scope the body cannot read + * from, so each survivor's cone is CLONED here and the assertion is made over the clone. The + * original is then read by nothing and the end-of-design sweep collects it, which is also what + * makes a constraint dropped below cost nothing. + * + * The tag is consumed here. It marks a PENDING constraint, and a materialized one is not + * pending, so the clone is planted without it and no member of the finished design carries one. + */ + private[core] def materialize()(using dfc: DFC): Unit = + import dfc.getSet + val ctx = dfc.mutableDB.DesignContext.current + if (!dfc.inMetaProgramming) + val pending = ctx.autoConstraintGuards.map(_.setTags(_.removeTagOf[ir.AutoConstraint])) + val kept = mutable.ListBuffer.empty[ir.DFVal] + pending.foreach { guard => + if (!kept.exists(sameConstraint(_, guard))) kept += guard + } + kept.zipWithIndex.foreach { (guard, idx) => + val constraintDFC = + dfc.emptyTags.setMeta(guard.meta).setName(constraintName(idx, kept.length)) + val cloned = guard.cloneAnonValueAndDepsHere(using constraintDFC.anonymize) + TextOut( + TextOut.Op.Assert(cloned.asValOf[DFBool], Severity.Fatal)(using constraintDFC), + List(report(cloned)(using constraintDFC)), + Nil + )(using constraintDFC) + } + end if + end materialize +end AutoConstraint diff --git a/core/src/main/scala/dfhdl/core/DFDecimal.scala b/core/src/main/scala/dfhdl/core/DFDecimal.scala index 02734a3b1..e29c3eb0b 100644 --- a/core/src/main/scala/dfhdl/core/DFDecimal.scala +++ b/core/src/main/scala/dfhdl/core/DFDecimal.scala @@ -139,6 +139,33 @@ object DFDecimal: s"""|Cannot apply this operation between a value of ${lhs.widthErrorString} bits width (LHS) and a value of ${rhs.widthErrorString} bits width (RHS). |An explicit conversion must be applied.""".stripMargin ) + + /** The elaboration half of [[`LW >= RW`]], for a width pair at least one of whose sides is not + * statically known (a design parameter): decided on the two width expressions, and a pair that + * is neither provably fitting nor provably violated is ACCEPTED, with the fit stated as a + * constraint of the design (see [[AutoConstraint]]). Rejecting it instead would demand a + * `.resize` of code that is very probably correct, while accepting it silently would let a + * parameter override truncate. The generated assertion is the third answer: the operation + * stands, and every instantiation checks the width relation it needs. + * + * A provably violated pair stays a hard error, as it is at every other width check site: an + * assertion that always fails helps no one. It reports the text of the compile-time half, with + * each width rendered relative to the error site. The generated assertion does NOT: what it + * states is the design's interface rather than this operation, so its own condition is what it + * reports. Must be invoked wherever [[`LW >= RW`]] is, on the branch where a width is unknown. + */ + protected[core] def widthFitCheck( + lhs: IntParam[Int], + rhs: IntParam[Int] + )(using DFC): Unit = + AutoConstraint.widthFitGE(lhs, rhs) match + case Some(true) => // fits for every parameter assignment + case Some(false) => + throw new IllegalArgumentException( + s"""The applied RHS value width (${rhs.errorString}) is larger than the LHS variable width (${lhs.errorString}).""" + ) + case None => AutoConstraint.raise(AutoConstraint.ge(lhs, rhs)) + end widthFitCheck object `LS >= RS` extends Check2[ Boolean, @@ -331,7 +358,7 @@ object DFDecimal: )(using dfc: DFC): Unit = if (!isWildcardL.value) import dfc.getSet - import DFXInt.Val.getActualSignedWidthOpt + import DFXInt.Val.{getActualSignedWidthOpt, getActualWidthParam} (lhs.getActualSignedWidthOpt, rhs.getActualSignedWidthOpt) match case (Some(lhsSigned, lhsWidthIntOpt), Some(rhsSigned, rhsWidthIntOpt)) => checkS(lhsSigned, rhsSigned) @@ -342,7 +369,18 @@ object DFDecimal: else rhsWidth checkW(lhsWidth, rhsSignedWidth) case _ => + // a width is parametric, so the same relation is decided on the width + // EXPRESSIONS instead, and stated as a design constraint when undecidable + import IntParam.+ + val lhsWidthParam = lhs.getActualWidthParam(lhsWidthIntOpt) + val rhsWidthParam = rhs.getActualWidthParam(rhsWidthIntOpt) + val rhsSignedWidthParam = + if (lhsSigned && !rhsSigned) rhsWidthParam + 1 + else rhsWidthParam + widthFitCheck(lhsWidthParam, rhsSignedWidthParam) + end match case _ => + end match end apply end given @@ -1100,6 +1138,17 @@ object DFXInt: end Candidate extension [S <: Boolean, W <: IntP, N <: NativeType](dfVal: DFValOf[DFXInt[S, W, N]]) + // The width behind `getActualSignedWidthOpt`, as the (possibly parametric) expression it + // is. A resolved width is taken as given, so a wildcard `Int`'s value-derived width stays + // the width the check saw rather than becoming the type's. + private[core] def getActualWidthParam(widthIntOpt: Option[Int])(using + dfc: DFC + ): IntParam[Int] = + widthIntOpt match + case Some(width) => IntParam.forced[Int](width) + case None => + // an integer type (fraction 0): the magnitude ref is the total-width ref + dfVal.dfType.asIR.magnitudeWidthParamRef.get private[core] def getActualSignedWidthOpt(using dfc: DFC ): Option[(signed: Boolean, widthIntOpt: Option[Int])] = @@ -1154,24 +1203,13 @@ object DFXInt: // ref and may be parametric val dfTypeWidthRef = dfType.asIR.magnitudeWidthParamRef val rhsWidthRef = rhs.dfType.asIR.magnitudeWidthParamRef - def dfTypeWidthStr = dfTypeWidthRef.refErrorString - def rhsWidthStr = rhsWidthRef.refErrorString // width-fit acceptance rule: LHS >= RHS after symbolic elimination (a // mixed max/min drops its symbolic operands, so `16 >= WIDTH max 16` // decides as `16 >= 16`), falling back to a non-negativity proof over // the validity domain (all widths are >= 1), so `2 * W >= W` accepts // for a free parameter `W`; a residual undecidable comparison (e.g. - // `16 >= W`) is conservatively rejected below - dfTypeWidthRef.widthFitGE(rhsWidthRef) match - case Some(false) => - throw new IllegalArgumentException( - s"""The applied RHS value width ($rhsWidthStr) is larger than the LHS variable width ($dfTypeWidthStr).""" - ) - case None => - throw new IllegalArgumentException( - s"""The applied RHS value width ($rhsWidthStr) is undefined compared to the LHS variable width ($dfTypeWidthStr).""" - ) - case _ => // ok + // `16 >= W`) becomes a constraint of the design + widthFitCheck(dfTypeWidthRef.get, rhsWidthRef.get) end if case None => end match diff --git a/core/src/main/scala/dfhdl/core/Design.scala b/core/src/main/scala/dfhdl/core/Design.scala index 45d29ef11..22d41ebfd 100644 --- a/core/src/main/scala/dfhdl/core/Design.scala +++ b/core/src/main/scala/dfhdl/core/Design.scala @@ -94,6 +94,14 @@ trait Design extends Container, HasClsMeta, HasClsArgs: final protected def __clsGetParam[V <: DFValAny](bodyClass: Class[?], idx: Int): V = dfc.mutableDB.DesignContext.current.clsParams((bodyClass, idx)).asInstanceOf[V] + // The design's body has run and every member it built is in its context, so this is where the + // conditions the body assumed but could not prove become assertions of its contract. It has to + // precede the owner exit, which ends the design context and sweeps its unread anonymous values: + // until an assertion reads a clone of it, a pending constraint's guard is exactly one of those. + private def exitDesignOwner(): Unit = + AutoConstraint.materialize()(using dfc) + dfc.exitOwner() + private var hasStartedLate: Boolean = false final override def onCreateStartLate: Unit = hasStartedLate = true @@ -121,7 +129,7 @@ trait Design extends Container, HasClsMeta, HasClsArgs: else if (ctx.clsLoadKey.nonEmpty && !bodyParams) ctx.clsLoadKey else DesignLoadKey.designClsKeyWith(__clsScalaArgs) val joinedCanonical = keyOpt.exists(gate.joinCanonicalOf) - dfc.exitOwner() + exitDesignOwner() Design.Inst(endedDesign, paramEntries) if (!joinedCanonical) keyOpt.foreach( @@ -197,7 +205,7 @@ trait Design extends Container, HasClsMeta, HasClsArgs: if (hasStartedLate) dfc.exitLate() else - dfc.exitOwner() + exitDesignOwner() import dfc.getSet // At the end of the top-level instance we check for warnings and errors if (containedOwner.asIR.isTop && thisOwner.isEmpty) diff --git a/core/src/main/scala/dfhdl/core/IntParam.scala b/core/src/main/scala/dfhdl/core/IntParam.scala index 14b9e62f9..69ea7f293 100644 --- a/core/src/main/scala/dfhdl/core/IntParam.scala +++ b/core/src/main/scala/dfhdl/core/IntParam.scala @@ -239,6 +239,22 @@ object IntParam extends IntParamLP: case ir.ConstData.KnownConst(Some(i: BigInt)) => Some(i.toInt) case _ => None def toScalaIntUNSAFE: Int = toScalaIntOpt.get + // Diagnostic rendering of the parameter, the value-based sibling of + // `IntParamRef.refErrorString`: a named parameter is qualified relative to the error site's + // owner, an anonymous expression prints as the expression. Rendering through a reference is + // not an option for an expression built AT the error site, since a reference no member holds + // has no origin for the printer to resolve a relative name against. + def errorString: String = + lhs match + case int: Int => int.toString + case const: DFConstInt32 => + import dfc.getSet + import dfhdl.compiler.printing.{Printer, DefaultPrinter} + given printer: Printer = DefaultPrinter + val callOwner: ir.DFOwner | ir.DFMember.Empty = dfc.ownerOption match + case Some(owner) => owner.asIR + case None => ir.DFMember.Empty + printer.csDFValRef(const.asIR, callOwner) def ref: ir.IntParamRef = lhs match case int: Int => ir.IntParamRef(int) diff --git a/core/src/main/scala/dfhdl/core/MutableDB.scala b/core/src/main/scala/dfhdl/core/MutableDB.scala index fa3a80d10..965acf35e 100644 --- a/core/src/main/scala/dfhdl/core/MutableDB.scala +++ b/core/src/main/scala/dfhdl/core/MutableDB.scala @@ -100,6 +100,17 @@ class DesignContext: case _ => false } + // The conditions this design's elaboration assumed but could not prove, in member order (the + // elaboration order of the operations that assumed them). The tag is the whole record, so this + // is a query and not a table; `AutoConstraint.materialize` consumes it at the end of the body, + // which is why a finished design has none. + def autoConstraintGuards: List[DFVal] = + members.view.collect { + case MemberEntry(irValue = dfVal: DFVal, ignore = false) + if dfVal.hasTagOf[dfhdl.compiler.ir.AutoConstraint] => + dfVal + }.toList + def setOriginRefs(member: DFMember): Unit = member.getRefs.foreach { r => originRefTable += r -> member } diff --git a/lib/src/test/scala/ElaborationChecksSpec.scala b/lib/src/test/scala/ElaborationChecksSpec.scala index ccf0c1474..49260fa5b 100644 --- a/lib/src/test/scala/ElaborationChecksSpec.scala +++ b/lib/src/test/scala/ElaborationChecksSpec.scala @@ -549,14 +549,10 @@ class ElaborationChecksSpec extends DesignSpec: val z = UInt(WIDTH2) <> OUT init h"${WIDTH2 - 1}'0" end Foo import Test.* + // only the provably violated width is an error; the undecidable `WIDTH1` against `WIDTH2` + // is accepted and states its fit as a constraint of the design instead assertElaborationErrors(Foo())( s"""|Elaboration errors found! - |DFiant HDL elaboration error! - |Position: ${currentFilePos}ElaborationChecksSpec.scala:547:42 - 547:56 - |Hierarchy: Foo - |Operation: `apply` - |Message: The applied RHS value width (WIDTH2) is undefined compared to the LHS variable width (WIDTH1). - | |DFiant HDL elaboration error! |Position: ${currentFilePos}ElaborationChecksSpec.scala:548:42 - 548:60 |Hierarchy: Foo @@ -578,14 +574,14 @@ class ElaborationChecksSpec extends DesignSpec: assertElaborationErrors(Foo())( s"""|Elaboration errors found! |DFiant HDL elaboration error! - |Position: ${currentFilePos}ElaborationChecksSpec.scala:572:42 - 572:56 + |Position: ${currentFilePos}ElaborationChecksSpec.scala:568:42 - 568:56 |Hierarchy: Foo |Operation: `apply` |Message: The argument width (WIDTH2) is different than the receiver width (WIDTH1). |Consider applying `.resize` to resolve this issue. | |DFiant HDL elaboration error! - |Position: ${currentFilePos}ElaborationChecksSpec.scala:575:17 - 575:23 + |Position: ${currentFilePos}ElaborationChecksSpec.scala:571:17 - 571:23 |Hierarchy: Foo.w |Operation: `apply` |Message: Cannot apply this operation between a value of WIDTH1 bits width (LHS) and a value of WIDTH2 bits width (RHS). @@ -603,12 +599,12 @@ class ElaborationChecksSpec extends DesignSpec: assertElaborationErrors(MultiConn())( s"""|Elaboration errors found! |DFiant HDL connectivity error! - |Position: ${currentFilePos}ElaborationChecksSpec.scala:600:9 - 600:18 + |Position: ${currentFilePos}ElaborationChecksSpec.scala:596:9 - 596:18 |Hierarchy: MultiConn |LHS: y(0) |RHS: 0 |Message: Found multiple connections write to the same variable/port `MultiConn.y`. - |The previous write occurred at ${currentFilePos}ElaborationChecksSpec.scala:599:9 - 599:18""".stripMargin + |The previous write occurred at ${currentFilePos}ElaborationChecksSpec.scala:595:9 - 595:18""".stripMargin ) test("the same bit assigned and connected check"): @@ -622,12 +618,12 @@ class ElaborationChecksSpec extends DesignSpec: assertElaborationErrors(AssignConn())( s"""|Elaboration errors found! |DFiant HDL connectivity error! - |Position: ${currentFilePos}ElaborationChecksSpec.scala:619:9 - 619:18 + |Position: ${currentFilePos}ElaborationChecksSpec.scala:615:9 - 615:18 |Hierarchy: AssignConn |LHS: y(0) |RHS: 1 |Message: Found multiple connections write to the same variable/port `AssignConn.y`. - |The previous write occurred at ${currentFilePos}ElaborationChecksSpec.scala:618:9 - 618:25""".stripMargin + |The previous write occurred at ${currentFilePos}ElaborationChecksSpec.scala:614:9 - 614:25""".stripMargin ) // `wait` inside an `initial` block used to be caught here, at elaboration. The scope lattice // rejects it at COMPILE time now (`Initial` is a `Sequence`, deliberately not a `TimedSequence`, @@ -647,11 +643,11 @@ class ElaborationChecksSpec extends DesignSpec: assertElaborationErrors(Top())( s"""|Elaboration errors found! |DFiant HDL initial block error! - |Position: ${currentFilePos}ElaborationChecksSpec.scala:643:11 - 643:21 + |Position: ${currentFilePos}ElaborationChecksSpec.scala:639:11 - 639:21 |Hierarchy: Top |Message: An `initial` block under a register-transfer (RT) domain may only assign constant values. |DFiant HDL initial block error! - |Position: ${currentFilePos}ElaborationChecksSpec.scala:644:11 - 644:25 + |Position: ${currentFilePos}ElaborationChecksSpec.scala:640:11 - 640:25 |Hierarchy: Top |Message: Text output statements are not allowed inside an `initial` block under a register-transfer (RT) domain.""".stripMargin ) @@ -673,11 +669,11 @@ class ElaborationChecksSpec extends DesignSpec: assertElaborationErrors(Top())( s"""|Elaboration errors found! |DFiant HDL initial block error! - |Position: ${currentFilePos}ElaborationChecksSpec.scala:666:11 - 666:17 + |Position: ${currentFilePos}ElaborationChecksSpec.scala:662:11 - 662:17 |Hierarchy: Top |Message: The declaration `a` has an `init` value and is also assigned inside an `initial` block. These are mutually exclusive. |DFiant HDL initial block error! - |Position: ${currentFilePos}ElaborationChecksSpec.scala:669:11 - 669:17 + |Position: ${currentFilePos}ElaborationChecksSpec.scala:665:11 - 665:17 |Hierarchy: Top |Message: The declaration `b` is assigned inside more than one `initial` block.""".stripMargin ) @@ -697,11 +693,11 @@ class ElaborationChecksSpec extends DesignSpec: assertElaborationErrors(Top())( s"""|Elaboration errors found! |DFiant HDL initial block error! - |Position: ${currentFilePos}ElaborationChecksSpec.scala:691:15 - 691:28 + |Position: ${currentFilePos}ElaborationChecksSpec.scala:687:15 - 687:28 |Hierarchy: Top |Message: A conditional guard inside an `initial` block under a register-transfer (RT) domain must be a constant. |DFiant HDL initial block error! - |Position: ${currentFilePos}ElaborationChecksSpec.scala:691:28 - 694:33 + |Position: ${currentFilePos}ElaborationChecksSpec.scala:687:28 - 690:33 |Hierarchy: Top |Message: A `match` selector inside an `initial` block under a register-transfer (RT) domain must be a constant.""".stripMargin ) @@ -714,7 +710,7 @@ class ElaborationChecksSpec extends DesignSpec: assertElaborationErrors(Top())( s"""|Elaboration errors found! |DFiant HDL elaboration error! - |Position: ${currentFilePos}ElaborationChecksSpec.scala:712:17 - 712:22 + |Position: ${currentFilePos}ElaborationChecksSpec.scala:708:17 - 708:22 |Hierarchy: Top.d |Operation: `.din` |Message: Cannot name a register DIN read. @@ -746,7 +742,7 @@ class ElaborationChecksSpec extends DesignSpec: assertElaborationErrors(Top())( s"""|Elaboration errors found! |DFiant HDL elaboration error! - |Position: ${currentFilePos}ElaborationChecksSpec.scala:740:9 - 740:24 + |Position: ${currentFilePos}ElaborationChecksSpec.scala:736:9 - 736:24 |Hierarchy: Top |Operation: `<>` |Message: Found a reference to an uninitialized DFHDL value. @@ -768,7 +764,7 @@ class ElaborationChecksSpec extends DesignSpec: assertElaborationErrors(Top())( s"""|Elaboration errors found! |DFiant HDL elaboration error! - |Position: ${currentFilePos}ElaborationChecksSpec.scala:763:9 - 763:17 + |Position: ${currentFilePos}ElaborationChecksSpec.scala:759:9 - 759:17 |Hierarchy: Top |Operation: `:=` |Message: Found a reference to an uninitialized DFHDL value. @@ -789,7 +785,7 @@ class ElaborationChecksSpec extends DesignSpec: assertElaborationErrors(Top())( s"""|Elaboration errors found! |DFiant HDL elaboration error! - |Position: ${currentFilePos}ElaborationChecksSpec.scala:785:19 - 785:32 + |Position: ${currentFilePos}ElaborationChecksSpec.scala:781:19 - 781:32 |Hierarchy: Top.bad |Operation: `Port/Variable constructor` |Message: Found a reference to an uninitialized DFHDL type. @@ -813,7 +809,7 @@ class ElaborationChecksSpec extends DesignSpec: assertElaborationErrors(Top())( s"""|Elaboration errors found! |DFiant HDL elaboration error! - |Position: ${currentFilePos}ElaborationChecksSpec.scala:809:14 - 809:21 + |Position: ${currentFilePos}ElaborationChecksSpec.scala:805:14 - 805:21 |Hierarchy: Top.addK |Operation: `designFromDefImpl` |Message: Found a reference to an uninitialized DFHDL value. @@ -841,7 +837,7 @@ class ElaborationChecksSpec extends DesignSpec: assertElaborationErrors(Top())( s"""|Elaboration errors found! |DFiant HDL conditional expression error! - |Position: ${currentFilePos}ElaborationChecksSpec.scala:837:23 - 837:30 + |Position: ${currentFilePos}ElaborationChecksSpec.scala:833:23 - 833:30 |Hierarchy: Top |Message: Found the named value `inv` inside a conditional expression branch. |An event-driven (ED) domain body is a concurrent scope, so a conditional expression @@ -905,7 +901,7 @@ class ElaborationChecksSpec extends DesignSpec: assertElaborationErrors(Top())( s"""|Elaboration errors found! |DFiant HDL connectivity error! - |Position: ${currentFilePos}ElaborationChecksSpec.scala:902:9 - 902:22 + |Position: ${currentFilePos}ElaborationChecksSpec.scala:898:9 - 898:22 |Hierarchy: Top |LHS: sub.i |RHS: OPEN @@ -935,7 +931,7 @@ class ElaborationChecksSpec extends DesignSpec: assertElaborationErrors(Top())( s"""|Elaboration errors found! |DFiant HDL connectivity error! - |Position: ${currentFilePos}ElaborationChecksSpec.scala:932:9 - 932:28 + |Position: ${currentFilePos}ElaborationChecksSpec.scala:928:9 - 928:28 |Hierarchy: Top |LHS: sub.o(3, 0) |RHS: OPEN @@ -969,10 +965,10 @@ class ElaborationChecksSpec extends DesignSpec: assertElaborationErrors(Top())( s"""|Elaboration errors found! |DFiant HDL scope error! - |Position: ${currentFilePos}ElaborationChecksSpec.scala:967:11 - 967:30 + |Position: ${currentFilePos}ElaborationChecksSpec.scala:963:11 - 963:30 |Hierarchy: Top |Message: Found a read of `i`, declared inside the `for` loop at - |${currentFilePos}ElaborationChecksSpec.scala:965:11 - 966:31, from outside that block. + |${currentFilePos}ElaborationChecksSpec.scala:961:11 - 962:31, from outside that block. |A declaration made inside a block exists only within it. This usually comes from |a Scala `var` reassigned inside the block: the reassignment binds the Scala name |to a value built under the block, so reading the `var` afterwards reaches the @@ -1012,7 +1008,7 @@ class ElaborationChecksSpec extends DesignSpec: assertElaborationErrors(Top())( s"""|Elaboration errors found! |DFiant HDL shared variable error! - |Position: ${currentFilePos}ElaborationChecksSpec.scala:1009:11 - 1009:21 + |Position: ${currentFilePos}ElaborationChecksSpec.scala:1005:11 - 1005:21 |Hierarchy: Top |Message: A shared variable cannot be written inside a combinational process (`process(all)`). |A shared-variable write commits at the end of a clock step, so it must reside inside a clocked process. @@ -1035,7 +1031,7 @@ class ElaborationChecksSpec extends DesignSpec: assertElaborationErrors(Top())( s"""|Elaboration errors found! |DFiant HDL shared variable error! - |Position: ${currentFilePos}ElaborationChecksSpec.scala:1033:9 - 1033:17 + |Position: ${currentFilePos}ElaborationChecksSpec.scala:1029:9 - 1029:17 |Hierarchy: Top |Message: A shared variable can only be accessed inside a process under an event-driven (ED) domain. |A concurrent access has no faithful VHDL rendering: a shared variable is not a signal, so its change never re-triggers a concurrent statement. @@ -1062,7 +1058,7 @@ class ElaborationChecksSpec extends DesignSpec: assertElaborationErrors(Top())( s"""|Elaboration errors found! |DFiant HDL shared variable error! - |Position: ${currentFilePos}ElaborationChecksSpec.scala:1059:13 - 1059:30 + |Position: ${currentFilePos}ElaborationChecksSpec.scala:1055:13 - 1055:30 |Hierarchy: Top |Message: A shared-variable write must lower into the clocked process, but its guard path reads a value that is reassigned later in the domain body, or it reads a `.din` value. |To Fix: restructure so that nothing the write's guards depend on is reassigned after the write, or hoist the guard condition computation after its operands' final assignments. @@ -1086,7 +1082,7 @@ class ElaborationChecksSpec extends DesignSpec: assertElaborationErrors(Top())( s"""|Elaboration errors found! |DFiant HDL shared variable error! - |Position: ${currentFilePos}ElaborationChecksSpec.scala:1084:15 - 1084:29 + |Position: ${currentFilePos}ElaborationChecksSpec.scala:1080:15 - 1080:29 |Hierarchy: Top |Message: A shared-variable write inside a loop requires the whole loop to lower into the clocked process, but the loop mixes combinational content or reads values that are reassigned later in the domain body. |To Fix: split the loop so that the shared-variable write is in a purely-sequential loop. @@ -1120,7 +1116,7 @@ class ElaborationChecksSpec extends DesignSpec: assertElaborationErrors(SelFixed())( s"""|Elaboration errors found! |DFiant HDL elaboration error! - |Position: ${currentFilePos}ElaborationChecksSpec.scala:1108:9 - 1108:27 + |Position: ${currentFilePos}ElaborationChecksSpec.scala:1104:9 - 1104:27 |Hierarchy: SelFixed |Operation: `apply` |Message: The applied RHS value width (10) is larger than the LHS variable width (8).""".stripMargin @@ -1222,12 +1218,12 @@ class ElaborationChecksSpec extends DesignSpec: assertElaborationErrors(SliceOverlap())( s"""|Elaboration errors found! |DFiant HDL connectivity error! - |Position: ${currentFilePos}ElaborationChecksSpec.scala:1219:9 - 1219:45 + |Position: ${currentFilePos}ElaborationChecksSpec.scala:1215:9 - 1215:45 |Hierarchy: SliceOverlap |LHS: o(W - 1, 0) |RHS: i((W + W) - 1, W) |Message: Found multiple connections write to the same variable/port `SliceOverlap.o`. - |The previous write occurred at ${currentFilePos}ElaborationChecksSpec.scala:1218:9 - 1218:45""".stripMargin + |The previous write occurred at ${currentFilePos}ElaborationChecksSpec.scala:1214:9 - 1214:45""".stripMargin ) // A relation no symbolic proof can settle is decided at the parameters actually elaborated @@ -1257,12 +1253,12 @@ class ElaborationChecksSpec extends DesignSpec: assertElaborationErrors(SliceUnprovableCollide())( s"""|Elaboration errors found! |DFiant HDL connectivity error! - |Position: ${currentFilePos}ElaborationChecksSpec.scala:1254:9 - 1254:43 + |Position: ${currentFilePos}ElaborationChecksSpec.scala:1250:9 - 1250:43 |Hierarchy: SliceUnprovableCollide |LHS: o((2 * W) - 1, W) |RHS: i((2 * W) - 1, W) |Message: Found multiple connections write to the same variable/port `SliceUnprovableCollide.o`. - |The previous write occurred at ${currentFilePos}ElaborationChecksSpec.scala:1253:9 - 1253:27""".stripMargin + |The previous write occurred at ${currentFilePos}ElaborationChecksSpec.scala:1249:9 - 1249:27""".stripMargin ) test("consistent assignment kinds per process are accepted"): object Test: @@ -1321,24 +1317,24 @@ class ElaborationChecksSpec extends DesignSpec: assertElaborationErrors(MixedWhole())( s"""|Elaboration errors found! |DFiant HDL connectivity error! - |Position: ${currentFilePos}ElaborationChecksSpec.scala:1309:16 - 1309:23 + |Position: ${currentFilePos}ElaborationChecksSpec.scala:1305:16 - 1305:23 |Hierarchy: MixedWhole |LHS: q |RHS: d |Message: Found both blocking (`:=`) and non-blocking (`:==`) assignments to the same variable/port `MixedWhole.q` within the same process. |Use one assignment kind consistently for this variable inside the process. - |The previous write occurred at ${currentFilePos}ElaborationChecksSpec.scala:1308:20 - 1308:26""".stripMargin + |The previous write occurred at ${currentFilePos}ElaborationChecksSpec.scala:1304:20 - 1304:26""".stripMargin ) assertElaborationErrors(MixedParts())( s"""|Elaboration errors found! |DFiant HDL connectivity error! - |Position: ${currentFilePos}ElaborationChecksSpec.scala:1317:11 - 1317:30 + |Position: ${currentFilePos}ElaborationChecksSpec.scala:1313:11 - 1313:30 |Hierarchy: MixedParts |LHS: q(7, 4) |RHS: d(7, 4) |Message: Found both blocking (`:=`) and non-blocking (`:==`) assignments to the same variable/port `MixedParts.q` within the same process. |Use one assignment kind consistently for this variable inside the process. - |The previous write occurred at ${currentFilePos}ElaborationChecksSpec.scala:1316:11 - 1316:29""".stripMargin + |The previous write occurred at ${currentFilePos}ElaborationChecksSpec.scala:1312:11 - 1312:29""".stripMargin ) test("parametric max width-fit accepted via symbolic elimination"): object Test: @@ -1388,28 +1384,15 @@ class ElaborationChecksSpec extends DesignSpec: val xy = x + y sum15 := xy end MaxTooNarrow - @top(false) class PlainSymWidth(val WIDTH: Int <> CONST = 14) extends RTDesign: - val x = UInt(WIDTH) <> IN - val sum = UInt(16) <> OUT - sum := x - end PlainSymWidth import Test.* assertElaborationErrors(MaxTooNarrow())( s"""|Elaboration errors found! |DFiant HDL elaboration error! - |Position: ${currentFilePos}ElaborationChecksSpec.scala:1389:9 - 1389:20 + |Position: ${currentFilePos}ElaborationChecksSpec.scala:1385:9 - 1385:20 |Hierarchy: MaxTooNarrow |Operation: `:=` |Message: The applied RHS value width (WIDTH max 16) is larger than the LHS variable width (15).""".stripMargin ) - assertElaborationErrors(PlainSymWidth())( - s"""|Elaboration errors found! - |DFiant HDL elaboration error! - |Position: ${currentFilePos}ElaborationChecksSpec.scala:1394:9 - 1394:17 - |Hierarchy: PlainSymWidth - |Operation: `:=` - |Message: The applied RHS value width (WIDTH) is undefined compared to the LHS variable width (16).""".stripMargin - ) test("same-named width constants are qualified in DFBits width errors"): object Test: @@ -1428,49 +1411,20 @@ class ElaborationChecksSpec extends DesignSpec: assertElaborationErrors(Parent())( s"""|Elaboration errors found! |DFiant HDL elaboration error! - |Position: ${currentFilePos}ElaborationChecksSpec.scala:1425:9 - 1425:17 + |Position: ${currentFilePos}ElaborationChecksSpec.scala:1408:9 - 1408:17 |Hierarchy: Parent |Operation: `apply` |Message: The argument width (c.OUTPUT_WIDTH) is different than the receiver width (OUTPUT_WIDTH). |Consider applying `.resize` to resolve this issue. | |DFiant HDL elaboration error! - |Position: ${currentFilePos}ElaborationChecksSpec.scala:1425:9 - 1425:17 + |Position: ${currentFilePos}ElaborationChecksSpec.scala:1408:9 - 1408:17 |Hierarchy: Parent |Operation: `apply` |Message: The argument width (OUTPUT_WIDTH) is different than the receiver width (c.OUTPUT_WIDTH). |Consider applying `.resize` to resolve this issue.""".stripMargin ) - test("same-named width constants are qualified in DFDecimal width errors"): - object Test: - @top(false) class Child(val W: Int <> CONST = 4) extends EDDesign: - val OUTPUT_WIDTH = W * 2 - val o = UInt(OUTPUT_WIDTH) <> OUT - o <> 0 - end Child - @top(false) class Parent(val W: Int <> CONST = 8) extends EDDesign: - val OUTPUT_WIDTH = W - val o = UInt(OUTPUT_WIDTH) <> OUT - val c = Child(W = 4) - o <> c.o - end Parent - import Test.* - assertElaborationErrors(Parent())( - s"""|Elaboration errors found! - |DFiant HDL elaboration error! - |Position: ${currentFilePos}ElaborationChecksSpec.scala:1456:9 - 1456:17 - |Hierarchy: Parent - |Operation: `apply` - |Message: The applied RHS value width (c.OUTPUT_WIDTH) is undefined compared to the LHS variable width (OUTPUT_WIDTH). - | - |DFiant HDL elaboration error! - |Position: ${currentFilePos}ElaborationChecksSpec.scala:1456:9 - 1456:17 - |Hierarchy: Parent - |Operation: `apply` - |Message: The applied RHS value width (OUTPUT_WIDTH) is undefined compared to the LHS variable width (c.OUTPUT_WIDTH).""".stripMargin - ) - test("same-named design parameters are qualified in width errors"): object Test: @top(false) class Child(val W: Int <> CONST = 8) extends EDDesign: @@ -1486,14 +1440,14 @@ class ElaborationChecksSpec extends DesignSpec: assertElaborationErrors(Parent())( s"""|Elaboration errors found! |DFiant HDL elaboration error! - |Position: ${currentFilePos}ElaborationChecksSpec.scala:1483:9 - 1483:17 + |Position: ${currentFilePos}ElaborationChecksSpec.scala:1437:9 - 1437:17 |Hierarchy: Parent |Operation: `apply` |Message: The argument width (c.W) is different than the receiver width (W). |Consider applying `.resize` to resolve this issue. | |DFiant HDL elaboration error! - |Position: ${currentFilePos}ElaborationChecksSpec.scala:1483:9 - 1483:17 + |Position: ${currentFilePos}ElaborationChecksSpec.scala:1437:9 - 1437:17 |Hierarchy: Parent |Operation: `apply` |Message: The argument width (W) is different than the receiver width (c.W). @@ -1531,32 +1485,18 @@ class ElaborationChecksSpec extends DesignSpec: test("parametric width-fit proof rejections"): object Test: - @top(false) class MulTooNarrow(val W: Int <> CONST = 8) extends EDDesign: - val a, b = SInt(W) <> IN - val prod16 = SInt(16) <> OUT - prod16 <> a * b - end MulTooNarrow @top(false) class ProvablyNarrow(val W: Int <> CONST = 8) extends RTDesign: val x = SInt(2 * W) <> IN val narrow = SInt(W) <> OUT narrow := x end ProvablyNarrow import Test.* - // a literal target against a free parameter stays undecidable: a valid W may exceed it - assertElaborationErrors(MulTooNarrow())( - s"""|Elaboration errors found! - |DFiant HDL elaboration error! - |Position: ${currentFilePos}ElaborationChecksSpec.scala:1537:9 - 1537:24 - |Hierarchy: MulTooNarrow - |Operation: `apply` - |Message: The applied RHS value width (W) is undefined compared to the LHS variable width (16).""".stripMargin - ) - // the width-fit proof decides the negative direction definitively: W >= 2 * W is - // violated for every valid W, so the vague "undefined" error upgrades to "larger than" + // the width-fit proof decides the negative direction definitively: W >= 2 * W is violated + // for every valid W, so the relation is rejected rather than left to a constraint assertElaborationErrors(ProvablyNarrow())( s"""|Elaboration errors found! |DFiant HDL elaboration error! - |Position: ${currentFilePos}ElaborationChecksSpec.scala:1542:9 - 1542:20 + |Position: ${currentFilePos}ElaborationChecksSpec.scala:1491:9 - 1491:20 |Hierarchy: ProvablyNarrow |Operation: `:=` |Message: The applied RHS value width (2 * W) is larger than the LHS variable width (W).""".stripMargin @@ -1581,7 +1521,7 @@ class ElaborationChecksSpec extends DesignSpec: assertElaborationErrors(Top())( s"""|Elaboration errors found! |DFiant HDL elaboration error! - |Position: ${currentFilePos}ElaborationChecksSpec.scala:1578:14 - 1578:35 + |Position: ${currentFilePos}ElaborationChecksSpec.scala:1518:14 - 1518:35 |Hierarchy: Top |Operation: `setName` |Message: Cannot set a name for a port of an internal design. @@ -1659,12 +1599,12 @@ class ElaborationChecksSpec extends DesignSpec: assertElaborationErrors(ParamIdxCollide())( s"""|Elaboration errors found! |DFiant HDL connectivity error! - |Position: ${currentFilePos}ElaborationChecksSpec.scala:1655:9 - 1655:18 + |Position: ${currentFilePos}ElaborationChecksSpec.scala:1595:9 - 1595:18 |Hierarchy: ParamIdxCollide |LHS: v(1) |RHS: a |Message: Found multiple connections write to the same variable/port `ParamIdxCollide.v`. - |The previous write occurred at ${currentFilePos}ElaborationChecksSpec.scala:1653:9 - 1653:22""".stripMargin + |The previous write occurred at ${currentFilePos}ElaborationChecksSpec.scala:1593:9 - 1593:22""".stripMargin ) // A variable already driven reads as a source, so a second driver reaches the analysis as a @@ -1684,12 +1624,12 @@ class ElaborationChecksSpec extends DesignSpec: assertElaborationErrors(VarRedrive())( s"""|Elaboration errors found! |DFiant HDL connectivity error! - |Position: ${currentFilePos}ElaborationChecksSpec.scala:1679:9 - 1679:18 + |Position: ${currentFilePos}ElaborationChecksSpec.scala:1619:9 - 1619:18 |Hierarchy: VarRedrive |LHS: v(0) |RHS: a |Message: Found multiple connections write to the same variable/port `VarRedrive.v`. - |The previous write occurred at ${currentFilePos}ElaborationChecksSpec.scala:1678:9 - 1678:18""".stripMargin + |The previous write occurred at ${currentFilePos}ElaborationChecksSpec.scala:1618:9 - 1618:18""".stripMargin ) // A bitwise operation requires equal operand widths. When at least one width is a design @@ -1729,7 +1669,7 @@ class ElaborationChecksSpec extends DesignSpec: assertElaborationErrors(BitsXorParam())( s"""|Elaboration errors found! |DFiant HDL elaboration error! - |Position: ${currentFilePos}ElaborationChecksSpec.scala:1710:9 - 1710:23 + |Position: ${currentFilePos}ElaborationChecksSpec.scala:1650:9 - 1650:23 |Hierarchy: BitsXorParam |Operation: `apply` |Message: Cannot apply this operation between a value of LEN bits width (LHS) and a value of 8 bits width (RHS). @@ -1738,7 +1678,7 @@ class ElaborationChecksSpec extends DesignSpec: assertElaborationErrors(UIntAndParam())( s"""|Elaboration errors found! |DFiant HDL elaboration error! - |Position: ${currentFilePos}ElaborationChecksSpec.scala:1718:9 - 1718:23 + |Position: ${currentFilePos}ElaborationChecksSpec.scala:1658:9 - 1658:23 |Hierarchy: UIntAndParam |Operation: `apply` |Message: Cannot apply this operation between a value of LEN bits width (LHS) and a value of 8 bits width (RHS). @@ -1793,7 +1733,7 @@ class ElaborationChecksSpec extends DesignSpec: assertElaborationErrors(EDPrint())( s"""|Elaboration errors found! |DFiant HDL text output error! - |Position: ${currentFilePos}ElaborationChecksSpec.scala:1765:9 - 1765:28 + |Position: ${currentFilePos}ElaborationChecksSpec.scala:1705:9 - 1705:28 |Hierarchy: EDPrint |Message: Text output is not allowed as a concurrent statement under an event-driven (ED) domain. |Only a static assertion (an `assert` whose condition and message are constant) may reside directly in an ED domain body, as a design contract checked at elaboration. @@ -1802,7 +1742,7 @@ class ElaborationChecksSpec extends DesignSpec: assertElaborationErrors(EDDynAssert())( s"""|Elaboration errors found! |DFiant HDL text output error! - |Position: ${currentFilePos}ElaborationChecksSpec.scala:1772:9 - 1772:49 + |Position: ${currentFilePos}ElaborationChecksSpec.scala:1712:9 - 1712:49 |Hierarchy: EDDynAssert |Message: Text output is not allowed as a concurrent statement under an event-driven (ED) domain. |Only a static assertion (an `assert` whose condition and message are constant) may reside directly in an ED domain body, as a design contract checked at elaboration. @@ -1812,4 +1752,44 @@ class ElaborationChecksSpec extends DesignSpec: val _ = EDStaticAssert() val _ = RTPrint() + // A check that cannot decide a width relation marks the condition it assumed, and the end of + // the design body turns every marked condition into an assertion and drops the marker. It is + // an elaboration-internal marker, so a design that has finished elaborating carries none of + // them anywhere in its hierarchy: nothing downstream can come to depend on one. + test("auto constraints leave no marker in the elaborated design"): + object Test: + class Inner(val W: Int <> CONST = 8) extends EDDesign: + val i = UInt(W) <> IN + val o = UInt(16) <> OUT + o <> i + end Inner + @top(false) class Outer(val W: Int <> CONST = 8) extends EDDesign: + val i = UInt(W) <> IN + val o = UInt(16) <> OUT + val o2 = UInt(24) <> OUT + val inner = Inner(W) + inner.i <> i + o <> inner.o + o2 <> i + end Outer + end Test + import Test.* + import dfhdl.compiler.stages.db + val designDB = Outer().db + val allMembers = (designDB :: designDB.subDBs.values.toList).flatMap(_.members) + // one constraint, from `Outer`. `Inner`'s own fit needs no constraint: a sub-design's + // parameters are fixed by the instantiation elaborating it, so its widths resolve and the + // relation is decided here and now. Only the elaboration ROOT's parameters stay free. + assertEquals( + allMembers.count { + case textOut: compiler.ir.TextOut => !textOut.isAnonymous + case _ => false + }, + 1 + ) + assert( + allMembers.forall(!_.hasTagOf[compiler.ir.AutoConstraint]), + "an auto-constraint marker survived elaboration" + ) + end ElaborationChecksSpec From 09fbc01c3a4cc4372a8e5a276a292981430c8a17 Mon Sep 17 00:00:00 2001 From: Oron Port Date: Tue, 11 Aug 2026 15:21:23 +0300 Subject: [PATCH 17/40] skill: shut the sbt server down on a GC-time warning The long-lived server accumulates heap across the many compile/test cycles a bug fix takes, and once it reports time spent in GC every later cycle is slower than the restart would have cost. Co-Authored-By: Claude Opus 5 (1M context) --- .claude/commands/bugfix.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.claude/commands/bugfix.md b/.claude/commands/bugfix.md index 1b9dee309..e6ac7a585 100644 --- a/.claude/commands/bugfix.md +++ b/.claude/commands/bugfix.md @@ -503,6 +503,17 @@ reproducibly threw `scala.MatchError: 23 ... TreeUnpickler.readConstant` on this stale TASTy, not the change under test: `sbtn.bat 'clean; clearDFHDL; Test/compile'` clears it. Do not chase it, and do not trust a suite run that followed one. +**A GC warning means the server is spent, so restart it.** When sbt reports + +``` +[warn] In the last 17 seconds, 5.88 (34.7%) were spent in GC. [Heap: 2.35GB free of 3.94GB, ...] +``` + +run `sbtn.bat shutdown` before continuing. The long-lived server accumulates heap across the many +compile/test cycles a bug fix takes, and once it is thrashing every later cycle is slower than the +restart would have cost. Do not raise `-Xmx` to silence it; the next command starts a fresh server +on its own. + --- ## 2. Localize the stage that introduced the shape From 826d215f0666099928906808a5662f65850ef057 Mon Sep 17 00:00:00 2001 From: Oron Port Date: Tue, 11 Aug 2026 15:40:28 +0300 Subject: [PATCH 18/40] core+compiler: minimize auto constraints against each other and the design's own assertions Several operations assuming related relations is the normal case rather than the exception: `z(16) := x(W)` and `n(8) := x(W)` assume `16 >= W` and `8 >= W`, and once the second is stated the first says nothing. Every comparison now normalizes onto one canonical form, `IntExprCalc.linearDiff(lhs, rhs) >= 0`, and two relations are comparable exactly when their symbolic terms cancel, the one with the smaller constant being the stronger. Materialization keeps a constraint only when nothing already kept implies it, and drops anything kept that IT implies. Deduplication falls out as the case where two imply each other, so the separate dedup pass is gone. The body's own static assertions are read as facts, when their severity makes them requirements rather than reports (`Error` and `Fatal`, not `Info`/`Warning`). The relation is one-way and deliberately so: a user assertion is never removed, subsumed or rewritten, but a generated constraint it already implies is dropped. Having written `assert(W <= 8, ...)`, the user should not then be shown a generated `16 >= W` beside it. Normalizing every comparison onto the same shape is what lets a user's `W <= 8` be compared with a generated `16 >= W` without either being rewritten; a strict comparison is the non-strict one over integers, one tighter. Co-Authored-By: Claude Opus 5 (1M context) --- .../scala/dfhdl/compiler/ir/IntExprCalc.scala | 26 ++++-- .../StagesSpec/PrintCodeStringSpec.scala | 78 +++++++++++++++- .../scala/StagesSpec/PrintVHDLCodeSpec.scala | 15 +-- .../StagesSpec/PrintVerilogCodeSpec.scala | 17 ++-- .../scala/dfhdl/core/AutoConstraint.scala | 92 +++++++++++++++---- 5 files changed, 185 insertions(+), 43 deletions(-) diff --git a/compiler/ir/src/main/scala/dfhdl/compiler/ir/IntExprCalc.scala b/compiler/ir/src/main/scala/dfhdl/compiler/ir/IntExprCalc.scala index 5c50675dc..32c5d9208 100644 --- a/compiler/ir/src/main/scala/dfhdl/compiler/ir/IntExprCalc.scala +++ b/compiler/ir/src/main/scala/dfhdl/compiler/ir/IntExprCalc.scala @@ -72,18 +72,26 @@ object IntExprCalc: else None end widthFitCompare - /** Whether `a1 - b1` and `a2 - b2` are the same linear expression. + /** The linear form of `a - b`, the canonical shape of a relation between two integer expressions: + * `a >= b` is `linearDiff(a, b) >= 0`, and every other comparison normalizes onto the same + * shape. So `W + W >= 8` and `2 * W >= 8` state one relation rather than two. * - * The identity of a parametric relation between two integer expressions, so `W + W >= 8` and - * `2 * W >= 8` state one relation rather than two. Design parameters stay OPAQUE, which is what - * makes it an identity of the DESIGN's relation: two relations that coincide only for the values - * of one instantiation stay distinct. + * Design parameters stay OPAQUE, which is what makes it a statement about the DESIGN: two + * relations that coincide only for the values of one instantiation stay distinct. */ - def sameDiff(a1: DFVal, b1: DFVal)(a2: DFVal, b2: DFVal)(using MemberGetSet): Boolean = + def linearDiff(a: DFVal, b: DFVal)(using MemberGetSet): Linear = val calc = Calc(ParamResolve.Opaque) - def diff(a: DFVal, b: DFVal): Linear = calc.sub(calc.linear(a), calc.linear(b)) - val d = calc.sub(diff(a1, b1), diff(a2, b2)) - d.terms.isEmpty && d.offset == 0 + calc.sub(calc.linear(a), calc.linear(b)) + + /** The constant `x - y`, or `None` when their symbolic terms do not cancel. + * + * This is what compares two relations in the [[linearDiff]] form: an answer at all means they + * constrain the same expression, and `x - y >= 0` means `y >= 0` implies `x >= 0`, i.e. `y` is + * the stronger of the two and `x` states nothing further. + */ + def constOffsetDiff(x: Linear, y: Linear)(using MemberGetSet): Option[Int] = + val d = Calc(ParamResolve.Opaque).sub(x, y) + Option.when(d.terms.isEmpty)(d.offset) /** How the calculus treats a [[DFVal.DesignParam]] it reaches. */ private enum ParamResolve derives CanEqual: diff --git a/compiler/stages/src/test/scala/StagesSpec/PrintCodeStringSpec.scala b/compiler/stages/src/test/scala/StagesSpec/PrintCodeStringSpec.scala index 3fec31f85..b88d634a5 100644 --- a/compiler/stages/src/test/scala/StagesSpec/PrintCodeStringSpec.scala +++ b/compiler/stages/src/test/scala/StagesSpec/PrintCodeStringSpec.scala @@ -3317,25 +3317,30 @@ class PrintCodeStringSpec extends StageSpec(stageCreatesUnrefAnons = true): // A width relation that is neither provably held nor provably violated is accepted, and the // fit the operation needs is stated as a static assertion at the tail of the body. The two // writes to `z` assume the same relation and state it once; the write to `n` assumes another. - class Fits(val W: Int <> CONST = 8) extends RTDesign: + class Fits(val W: Int <> CONST = 8, val V: Int <> CONST = 8) extends RTDesign: val x = UInt(W) <> IN + val y = UInt(V) <> IN val z = UInt(16) <> OUT val n = UInt(8) <> OUT z := x z := x + 1 - n := x + n := y end Fits assertCodeString( Fits(), - """|class Fits(val W: Int <> CONST = 8) extends RTDesign: + """|class Fits( + | val W: Int <> CONST = 8, + | val V: Int <> CONST = 8 + |) extends RTDesign: | val x = UInt(W) <> IN + | val y = UInt(V) <> IN | val z = UInt(16) <> OUT | val n = UInt(8) <> OUT | z := x.resize(16) | z := x.resize(16) + d"1'1".resize(W).resize(16) - | n := x.resize(8) + | n := y.resize(8) | val constraint_0 = assert(16 >= W, s"Design parameter violation found. Expected: 16 >= W", Severity.Fatal) - | val constraint_1 = assert(8 >= W, s"Design parameter violation found. Expected: 8 >= W", Severity.Fatal) + | val constraint_1 = assert(8 >= V, s"Design parameter violation found. Expected: 8 >= V", Severity.Fatal) |end Fits |""".stripMargin ) @@ -3366,6 +3371,69 @@ class PrintCodeStringSpec extends StageSpec(stageCreatesUnrefAnons = true): |""".stripMargin ) } + test("auto constraint minimization") { + // Two operations assuming related relations is the normal case, and the weaker of the two + // says nothing once the stronger is stated: `n` needs 8 bits to hold `x`, which is the whole + // of what `z` needs 16 for. + class Subsumed(val W: Int <> CONST = 8) extends RTDesign: + val x = UInt(W) <> IN + val z = UInt(16) <> OUT + val n = UInt(8) <> OUT + z := x + n := x + end Subsumed + assertCodeString( + Subsumed(), + """|class Subsumed(val W: Int <> CONST = 8) extends RTDesign: + | val x = UInt(W) <> IN + | val z = UInt(16) <> OUT + | val n = UInt(8) <> OUT + | z := x.resize(16) + | n := x.resize(8) + | val constraint = assert(8 >= W, s"Design parameter violation found. Expected: 8 >= W", Severity.Fatal) + |end Subsumed + |""".stripMargin + ) + } + test("auto constraint minimization against the design's own assertions") { + // A static assertion the user wrote is a requirement of the design just as a generated one is, + // so it is read as a fact: having stated the bound themselves, the user is not then shown a + // weaker generated echo of it. It is never the other way round, and never for a severity that + // reports rather than requires. + class UserStated(val W: Int <> CONST = 8) extends RTDesign: + val x = UInt(W) <> IN + val z = UInt(16) <> OUT + val bound = assert(W <= 8, s"W must not exceed 8, got $W", Severity.Fatal) + z := x + end UserStated + class UserReports(val W: Int <> CONST = 8) extends RTDesign: + val x = UInt(W) <> IN + val z = UInt(16) <> OUT + val note = assert(W <= 8, s"W is unusually large: $W", Severity.Warning) + z := x + end UserReports + assertCodeString( + UserStated(), + """|class UserStated(val W: Int <> CONST = 8) extends RTDesign: + | val x = UInt(W) <> IN + | val z = UInt(16) <> OUT + | val bound = assert(W <= 8, s"W must not exceed 8, got ${W}", Severity.Fatal) + | z := x.resize(16) + |end UserStated + |""".stripMargin + ) + assertCodeString( + UserReports(), + """|class UserReports(val W: Int <> CONST = 8) extends RTDesign: + | val x = UInt(W) <> IN + | val z = UInt(16) <> OUT + | val note = assert(W <= 8, s"W is unusually large: ${W}", Severity.Warning) + | z := x.resize(16) + | val constraint = assert(16 >= W, s"Design parameter violation found. Expected: 16 >= W", Severity.Fatal) + |end UserReports + |""".stripMargin + ) + } test("auto constraint qualifies same-named width constants") { // The message is built from the same lambda as the compile-time check's, so two same-named // constants from different designs stay distinguishable in it, exactly as they do in a width diff --git a/compiler/stages/src/test/scala/StagesSpec/PrintVHDLCodeSpec.scala b/compiler/stages/src/test/scala/StagesSpec/PrintVHDLCodeSpec.scala index ef0beb38b..b87a02a05 100644 --- a/compiler/stages/src/test/scala/StagesSpec/PrintVHDLCodeSpec.scala +++ b/compiler/stages/src/test/scala/StagesSpec/PrintVHDLCodeSpec.scala @@ -3705,12 +3705,13 @@ class PrintVHDLCodeSpec extends StageSpec: test("auto constraint") { // an assumption the width algebra could not prove reaches the backend as the design's own // elaboration-time contract, over the generic the generated entity leaves overridable - class Fits(val W: Int <> CONST = 8) extends RTDesign: + class Fits(val W: Int <> CONST = 8, val V: Int <> CONST = 8) extends RTDesign: val x = UInt(W) <> IN + val y = UInt(V) <> IN val z = UInt(16) <> OUT val n = UInt(8) <> OUT z := x - n := x + n := y end Fits val top = Fits().getCompiledCodeString assertNoDiff( @@ -3722,10 +3723,12 @@ class PrintVHDLCodeSpec extends StageSpec: | |entity Fits is |generic ( - | W : integer := 8 + | W : integer := 8; + | V : integer := 8 |); |port ( | x : in unsigned(W - 1 downto 0); + | y : in unsigned(V - 1 downto 0); | z : out unsigned(15 downto 0); | n : out unsigned(7 downto 0) |); @@ -3735,10 +3738,10 @@ class PrintVHDLCodeSpec extends StageSpec: |begin | constraint_0: assert 16 >= W | report "Design parameter violation found. Expected: 16 >= W" severity FAILURE; - | constraint_1: assert 8 >= W - | report "Design parameter violation found. Expected: 8 >= W" severity FAILURE; + | constraint_1: assert 8 >= V + | report "Design parameter violation found. Expected: 8 >= V" severity FAILURE; | z <= resize(x, 16); - | n <= resize(x, 8); + | n <= resize(y, 8); |end Fits_arch; |""".stripMargin ) diff --git a/compiler/stages/src/test/scala/StagesSpec/PrintVerilogCodeSpec.scala b/compiler/stages/src/test/scala/StagesSpec/PrintVerilogCodeSpec.scala index a46b05dad..9d94ee914 100644 --- a/compiler/stages/src/test/scala/StagesSpec/PrintVerilogCodeSpec.scala +++ b/compiler/stages/src/test/scala/StagesSpec/PrintVerilogCodeSpec.scala @@ -3632,12 +3632,13 @@ class PrintVerilogCodeSpec extends StageSpec: // an assumption the width algebra could not prove reaches the backend as the design's own // elaboration-time contract, over the parameter the generated module leaves overridable given options.CompilerOptions.Backend = _.verilog.sv2009 - class Fits(val W: Int <> CONST = 8) extends RTDesign: + class Fits(val W: Int <> CONST = 8, val V: Int <> CONST = 8) extends RTDesign: val x = UInt(W) <> IN + val y = UInt(V) <> IN val z = UInt(16) <> OUT val n = UInt(8) <> OUT z := x - n := x + n := y end Fits val top = Fits().getCompiledCodeString assertNoDiff( @@ -3645,8 +3646,12 @@ class PrintVerilogCodeSpec extends StageSpec: """|`default_nettype none |`timescale 1ns/1ps | - |module Fits#(parameter int W = 8)( + |module Fits#( + | parameter int W = 8, + | parameter int V = 8 + |)( | input wire logic [W - 1:0] x, + | input wire logic [V - 1:0] y, | output logic [15:0] z, | output logic [7:0] n |); @@ -3654,11 +3659,11 @@ class PrintVerilogCodeSpec extends StageSpec: | if (!(16 >= W)) begin : constraint_0 | $fatal(1, "Design parameter violation found. Expected: 16 >= W"); | end - | if (!(8 >= W)) begin : constraint_1 - | $fatal(1, "Design parameter violation found. Expected: 8 >= W"); + | if (!(8 >= V)) begin : constraint_1 + | $fatal(1, "Design parameter violation found. Expected: 8 >= V"); | end | assign z = 16'(x); - | assign n = 8'(x); + | assign n = 8'(y); |endmodule |""".stripMargin ) diff --git a/core/src/main/scala/dfhdl/core/AutoConstraint.scala b/core/src/main/scala/dfhdl/core/AutoConstraint.scala index b943e088f..4ab0f7f94 100644 --- a/core/src/main/scala/dfhdl/core/AutoConstraint.scala +++ b/core/src/main/scala/dfhdl/core/AutoConstraint.scala @@ -74,18 +74,55 @@ object AutoConstraint: guard.asIR.setTags(_.tag(ir.AutoConstraint)) () - /** Whether two guards state the same constraint. A relation between integer expressions is - * compared as a relation (`W + W >= 8` and `2 * W >= 8` are one), and anything else - * structurally. + /** What a guard requires, canonically: the relation it states, as `linear >= 0`. */ + private type Requirement = ir.IntExprCalc.Linear + + /** The requirement a guard states, or `None` for a guard that is not a comparison of two integer + * expressions. Nothing generates such a guard, but a user's own assertion may well be one, and + * it then simply takes no part in minimization. + * + * Every comparison normalizes onto the same shape, so a user's `W <= 8` is comparable with a + * generated `16 >= W` without either being rewritten. A strict comparison is the non-strict one + * over integers, one tighter. + */ + private def requirementOf(guard: ir.DFVal)(using ir.MemberGetSet): Option[Requirement] = + def diff(a: ir.DFVal, b: ir.DFVal): Requirement = ir.IntExprCalc.linearDiff(a, b) + def tighter(req: Requirement): Requirement = req.copy(offset = req.offset - 1) + guard match + case ir.DFVal.Func(op = op, args = List(lhs, rhs)) => + op match + case FuncOp.>= => Some(diff(lhs.get, rhs.get)) + case FuncOp.> => Some(tighter(diff(lhs.get, rhs.get))) + case FuncOp.<= => Some(diff(rhs.get, lhs.get)) + case FuncOp.< => Some(tighter(diff(rhs.get, lhs.get))) + case _ => None + case _ => None + + /** Whether `stronger` leaves `weaker` with nothing to say: they constrain the same expression, + * and satisfying `stronger` satisfies `weaker`. + */ + private def implies(stronger: Requirement, weaker: Requirement)(using ir.MemberGetSet): Boolean = + ir.IntExprCalc.constOffsetDiff(weaker, stronger).exists(_ >= 0) + + /** The design's own contract, as the body stated it: the requirements of its static assertions + * whose severity makes them requirements at all. `Info` and `Warning` report, they do not + * constrain. + * + * These are read as facts and never touched. The user wrote them, so they stay exactly as + * written, in their own position, with their own message and severity; what they can do is make + * a GENERATED constraint redundant, and having written `assert(W <= 8, ...)` the user should not + * then be shown a generated `16 >= W` next to it. */ - private def sameConstraint(a: ir.DFVal, b: ir.DFVal)(using ir.MemberGetSet): Boolean = - (a, b) match - case ( - ir.DFVal.Func(op = opA, args = List(lhsA, rhsA)), - ir.DFVal.Func(op = opB, args = List(lhsB, rhsB)) - ) if opA == opB => - ir.IntExprCalc.sameDiff(lhsA.get, rhsA.get)(lhsB.get, rhsB.get) - case _ => a =~ b + private def userRequirements(ctx: DesignContext)(using dfc: DFC): List[Requirement] = + import dfc.getSet + import dfhdl.compiler.analysis.isStaticAssert + ctx.getImmutableMemberList.view.collect { + case textOut: ir.TextOut if textOut.isStaticAssert => + textOut.op match + case ir.TextOut.Op.Assert(assertionRef, Severity.Error | Severity.Fatal) => + requirementOf(assertionRef.get) + case _ => None + }.flatten.toList /** The condition as the design states it, which is the whole of what a violation has to report. */ @@ -106,6 +143,11 @@ object AutoConstraint: * original is then read by nothing and the end-of-design sweep collects it, which is also what * makes a constraint dropped below cost nothing. * + * MINIMIZED first, against each other and against the body's own assertions: a constraint that + * another statement already implies says nothing, and several operations assuming related + * relations is the normal case rather than the exception. Deduplication falls out of this, as + * the case where two constraints imply each other. + * * The tag is consumed here. It marks a PENDING constraint, and a materialized one is not * pending, so the clone is planted without it and no member of the finished design carries one. */ @@ -114,13 +156,29 @@ object AutoConstraint: val ctx = dfc.mutableDB.DesignContext.current if (!dfc.inMetaProgramming) val pending = ctx.autoConstraintGuards.map(_.setTags(_.removeTagOf[ir.AutoConstraint])) - val kept = mutable.ListBuffer.empty[ir.DFVal] - pending.foreach { guard => - if (!kept.exists(sameConstraint(_, guard))) kept += guard - } - kept.zipWithIndex.foreach { (guard, idx) => + val kept = mutable.ListBuffer.empty[(ir.DFVal, Option[Requirement])] + if (pending.nonEmpty) + val userReqs = userRequirements(ctx) + pending.foreach { guard => + val reqOpt = requirementOf(guard) + val alreadyStated = reqOpt match + case Some(req) => + userReqs.exists(implies(_, req)) || + kept.exists((_, keptOpt) => keptOpt.exists(implies(_, req))) + // a guard with no comparable form takes no part: kept unless structurally repeated + case None => kept.exists((keptGuard, _) => keptGuard =~ guard) + if (!alreadyStated) + // this one may in turn be the stronger statement of something already kept + reqOpt.foreach(req => + kept.filterInPlace((_, keptOpt) => !keptOpt.exists(implies(req, _))) + ) + kept += ((guard, reqOpt)) + } + end if + val survivors = kept.map(_._1).toList + survivors.zipWithIndex.foreach { (guard, idx) => val constraintDFC = - dfc.emptyTags.setMeta(guard.meta).setName(constraintName(idx, kept.length)) + dfc.emptyTags.setMeta(guard.meta).setName(constraintName(idx, survivors.length)) val cloned = guard.cloneAnonValueAndDepsHere(using constraintDFC.anonymize) TextOut( TextOut.Op.Assert(cloned.asValOf[DFBool], Severity.Fatal)(using constraintDFC), From 0d3695c27e74afbed345859e7a92d0be54149e96 Mon Sep 17 00:00:00 2001 From: Oron Port Date: Tue, 11 Aug 2026 21:25:14 +0300 Subject: [PATCH 19/40] compiler+lib: fix the Verilog-95 signed ordering macros and cover them in testApps Verilog-95 has no `signed` keyword, so `<`, `>`, `<=` and `>=` over signed values are macros in dfhdl_defs.vh rather than native operators. Nothing executed them, and both were wrong: - SIGNED_GREATER_EQUAL was `(a > b) || a != b`, so `>=` answered true for every operand pair. - All four read an operand's sign bit by BIT-SELECT, which requires an indexable primary. A negative literal operand emitted `-4'd2[3]`, which iverilog rejects outright, so a comparison against a negative literal was uncompilable rather than merely wrong. The sign is read by SHIFT now, so any expression is a legal operand. util.SignedCmpSim checks all 256 operand pairs, and a negative literal operand, against an independent reference: flipping the sign bit maps two's complement onto offset binary, where an unsigned comparison answers the signed one, and unsigned comparisons emit plain operators in every dialect. testApps now iterates a design list rather than hardcoding CipherSim, so both run against every installed tool and dialect. Co-Authored-By: Claude Opus 5 (1M context) --- .../stages/src/main/resources/dfhdl_defs.vh | 23 ++++++----- lib/src/test/scala/util/SignedCmpSim.scala | 39 +++++++++++++++++++ project/DFHDLCommands.scala | 18 +++++++-- 3 files changed, 67 insertions(+), 13 deletions(-) create mode 100644 lib/src/test/scala/util/SignedCmpSim.scala diff --git a/compiler/stages/src/main/resources/dfhdl_defs.vh b/compiler/stages/src/main/resources/dfhdl_defs.vh index a0d7724d3..fa7b09359 100644 --- a/compiler/stages/src/main/resources/dfhdl_defs.vh +++ b/compiler/stages/src/main/resources/dfhdl_defs.vh @@ -72,18 +72,23 @@ `define EBY_U(vec, by) {{(by){1'b0}}, vec} `define EBY_S_V95(vec, fromW, by) {{(by){vec[(fromW) - 1]}}, vec} `define EBY_S(vec, fromW, by) $signed(`EBY_S_V95(vec, fromW, by)) +// Signed ordering over two vectors of the SAME width: the sign bits decide when they differ, +// and an unsigned comparison of the magnitudes when they agree. The sign bit is read by SHIFT +// rather than by bit-select, so an operand may be any expression (a bit-select would require an +// indexable primary, which a widened or arithmetic operand is not). +`define IS_NEG(a, width) (((a) >> ((width)-1))) `define SIGNED_GREATER_THAN(a, b, width) \ - ((a[width-1] && !b[width-1]) ? 1'b0 : /* a is negative, b is positive */ \ - (!a[width-1] && b[width-1]) ? 1'b1 : /* a is positive, b is negative */ \ - (a > b)) /* both are same sign */ + ((`IS_NEG(a, width) && !`IS_NEG(b, width)) ? 1'b0 : /* a is negative, b is positive */ \ + (!`IS_NEG(a, width) && `IS_NEG(b, width)) ? 1'b1 : /* a is positive, b is negative */ \ + ((a) > (b))) /* both are same sign */ `define SIGNED_LESS_THAN(a, b, width) \ - ((a[width-1] && !b[width-1]) ? 1'b1 : /* a is negative, b is positive */ \ - (!a[width-1] && b[width-1]) ? 1'b0 : /* a is positive, b is negative */ \ - (a < b)) /* both are same sign */ + ((`IS_NEG(a, width) && !`IS_NEG(b, width)) ? 1'b1 : /* a is negative, b is positive */ \ + (!`IS_NEG(a, width) && `IS_NEG(b, width)) ? 1'b0 : /* a is positive, b is negative */ \ + ((a) < (b))) /* both are same sign */ `define SIGNED_GREATER_EQUAL(a, b, width) \ - (`SIGNED_GREATER_THAN(a, b, width) || a != b) - `define SIGNED_LESS_EQUAL(a, b, width) \ - (`SIGNED_LESS_THAN(a, b, width) || a == b) + (`SIGNED_GREATER_THAN(a, b, width) || ((a) == (b))) +`define SIGNED_LESS_EQUAL(a, b, width) \ + (`SIGNED_LESS_THAN(a, b, width) || ((a) == (b))) `define SIGNED_SHIFT_RIGHT(data, shift, width) \ ((data[width-1] == 1'b1) ? ((data >> shift) | ({width{1'b1}} << (width - shift))) : (data >> shift)) function integer clog2; diff --git a/lib/src/test/scala/util/SignedCmpSim.scala b/lib/src/test/scala/util/SignedCmpSim.scala new file mode 100644 index 000000000..573319b1d --- /dev/null +++ b/lib/src/test/scala/util/SignedCmpSim.scala @@ -0,0 +1,39 @@ +package util +import dfhdl.* + +/** Conformance check for SIGNED ordering, run by `testApps` against every installed tool and + * dialect. + * + * It earns its place on Verilog-95, which has no `signed` keyword and so implements `<`, `>`, `<=` + * and `>=` over signed values as macros in `dfhdl_defs.vh` rather than as native operators (see + * `VerilogValPrinter.csDFValFuncExpr`). Nothing else executes those macros, so their arithmetic + * was unverified: `>=` was defined as "greater OR NOT EQUAL" and answered true for every operand + * pair, and reading an operand's sign bit by bit-select made a negative literal operand (`a < + * sd"4'-2"`) illegal Verilog. + * + * Every one of the 256 operand pairs is checked against an independent reference. Flipping the + * sign bit maps two's complement onto offset binary, where an UNSIGNED comparison answers the + * signed one; unsigned comparisons emit plain operators in every dialect, so the reference shares + * no machinery with the operators under test. + */ +class SignedCmpSim extends RTDesign: + val cnt = UInt(8) <> VAR.REG init 0 + cnt.din := cnt + 1 + val a = SInt(4) <> VAR + val b = SInt(4) <> VAR + a := cnt.bits(3, 0).sint + b := cnt.bits(7, 4).sint + val ao = (a.bits ^ b"1000").uint + val bo = (b.bits ^ b"1000").uint + assert((a < b) == (ao < bo), s"lt $a $b") + assert((a > b) == (ao > bo), s"gt $a $b") + assert((a <= b) == (ao <= bo), s"le $a $b") + assert((a >= b) == (ao >= bo), s"ge $a $b") + // the same four against a NEGATIVE literal, whose emitted form is not an indexable primary. + // -2 is 4'b1110, so its offset-binary image is 4'b0110 = 6. + assert((a < sd"4'-2") == (ao < d"4'6"), s"nlt $a") + assert((a > sd"4'-2") == (ao > d"4'6"), s"ngt $a") + assert((a <= sd"4'-2") == (ao <= d"4'6"), s"nle $a") + assert((a >= sd"4'-2") == (ao >= d"4'6"), s"nge $a") + if (cnt == d"8'255") finish() +end SignedCmpSim diff --git a/project/DFHDLCommands.scala b/project/DFHDLCommands.scala index 115bdc124..6025838e5 100644 --- a/project/DFHDLCommands.scala +++ b/project/DFHDLCommands.scala @@ -174,6 +174,8 @@ object DFHDLCommands { val verilogDialects = List("verilog.v95", "verilog.v2001", "verilog.sv2005") // Skip tests that are known to fail because of the tool val skip = Set(("iverilog", "verilog.sv2005"), ("vivado", "vhdl.v2008")) + // Self-checking designs simulated against every installed tool and dialect + val simDesigns = List("dfhdl.AES.CipherSim", "util.SignedCmpSim") val testApps = Command.command("testApps") { state => var newState = Command.process("clearSandbox", state, _ => ()) @@ -202,13 +204,21 @@ object DFHDLCommands { allTools.filter(tool => helpStr.linesIterator.exists(line => line.contains(tool) && line.contains("Found version"))) } //TODO: fix caching issues - for (tool <- vhdlTools if existingTools.contains(tool); dialect <- vhdlDialects if !skip.contains((tool, dialect))) { - val arguments = s" dfhdl.AES.CipherSim simulate -b $dialect -t $tool --Werror-tool" + for ( + design <- simDesigns; + tool <- vhdlTools if existingTools.contains(tool); + dialect <- vhdlDialects if !skip.contains((tool, dialect)) + ) { + val arguments = s" $design simulate -b $dialect -t $tool --Werror-tool" val (updatedState, _) = extracted.runInputTask(runMainTask, arguments, newState) newState = updatedState } - for (tool <- verilogTools if existingTools.contains(tool); dialect <- verilogDialects if !skip.contains((tool, dialect))) { - val arguments = s" dfhdl.AES.CipherSim simulate -b $dialect -t $tool --Werror-tool" + for ( + design <- simDesigns; + tool <- verilogTools if existingTools.contains(tool); + dialect <- verilogDialects if !skip.contains((tool, dialect)) + ) { + val arguments = s" $design simulate -b $dialect -t $tool --Werror-tool" val (updatedState, _) = extracted.runInputTask(runMainTask, arguments, newState) newState = updatedState } From a322eca961ef028ba782d1e49df9fa662dc55d88 Mon Sep 17 00:00:00 2001 From: Oron Port Date: Tue, 11 Aug 2026 21:43:05 +0300 Subject: [PATCH 20/40] docs: how to compare values of different widths The comparison rules showed `u8 == u4` as an error without saying what to do instead, which leaves the reader to pick a `.resize` direction, and only one of the two is safe. Both pages now show closing the difference by widening the NARROWER operand, and spell out that narrowing the wider one compiles clean while asking a different question. The Verilog transition guide gets the contrast it needs: Verilog extends relational operands for you, but by context determination, which extends more than the operand. A narrower operand that is an expression is evaluated at the wider operand's width, so `(x + x) < a` never wraps there while its DFHDL transcription wraps first and widens after. Every snippet was compiled before being written down. Co-Authored-By: Claude Opus 5 (1M context) --- docs/transitioning/from-verilog/index.md | 39 ++++++++++++++++++++++++ docs/user-guide/type-system/index.md | 34 +++++++++++++++++++++ 2 files changed, 73 insertions(+) diff --git a/docs/transitioning/from-verilog/index.md b/docs/transitioning/from-verilog/index.md index 17d2aff83..6973704f8 100644 --- a/docs/transitioning/from-verilog/index.md +++ b/docs/transitioning/from-verilog/index.md @@ -1062,6 +1062,45 @@ result <> ((a +^ b +^ c +^ d) / 4).resize /// +/// admonition | Comparison Operand Widths + type: verilog +Verilog extends every relational operand to the width of the widest one, so comparing signals of +different widths needs nothing from you. DFHDL requires the two operands to have the SAME width, +and the difference is closed in the source by widening the narrower one: + +
+ +```sv linenums="0" title="Verilog" +input wire logic [7:0] a; +input wire logic [3:0] b; +output logic lt; + +// b is zero-extended to 8 bits +assign lt = b < a; +``` + +```scala linenums="0" title="DFHDL" +val a = UInt(8) <> IN +val b = UInt(4) <> IN +val lt = Bit <> OUT + +// widen b explicitly, by 4 bits +lt := b.eby(4) < a +``` + +
+ +The extension Verilog performs is not confined to the operand. Relational operand widths there are +*context-determined*, so a narrower operand that is an EXPRESSION is evaluated at the wider +operand's width: with a 4-bit `x`, `(x + x) < a` computes the sum at 8 bits and never wraps, while +the DFHDL transcription `(x + x).eby(4) < a` wraps at 4 bits and then widens. This is the +*Integer Literal Width and Silent Overflow* trap above, one step removed, and the remedies are the +same: carry operations (`+^`) to keep the extra bit, or an explicit widening of the operands before +the arithmetic. + +See [Comparison Operations][comparison-ops] for the full rules. +/// + ## Parametric Constants diff --git a/docs/user-guide/type-system/index.md b/docs/user-guide/type-system/index.md index 89150f894..cf779fd00 100755 --- a/docs/user-guide/type-system/index.md +++ b/docs/user-guide/type-system/index.md @@ -2766,6 +2766,40 @@ val e2 = u8 == u4 val e3 = u8 > 1000 ``` +#### Comparing Values of Different Widths + +Close the width difference by **widening the narrower operand**, using the [width adjustment][width-adjustment] operations: + +```scala +val u8 = UInt(8) <> VAR +val u4 = UInt(4) <> VAR +val s8 = SInt(8) <> VAR +val s4 = SInt(4) <> VAR + +val c1 = u8 == u4.eby(4) // Boolean: u4 zero-extended to 8 bits +val c2 = s8 > s4.eby(4) // Boolean: s4 sign-extended to 8 bits +val c3 = u8.eby(2) > 1000 // Boolean: 1000 needs 10 bits, so u8 widens to meet it +``` + +Widening preserves the value in both signednesses (`.eby`/`.resize` zero-extend a `UInt` and sign-extend an `SInt`), so the comparison still asks what you meant. Narrowing the wider operand does not, and nothing flags it, because the resulting widths do match: + +```scala +// compiles, but u8 is TRUNCATED to its low 4 bits, so this asks a different question: +// it is true for u8 = 0x13 and u4 = 0x3 +val wrong = u8.resize(4) == u4 +``` + +That asymmetry is why the width is not closed for you: only one of the two directions is safe, and which one it is depends on intent that the operands do not carry. + +/// admonition | Why not extend the operands automatically? + type: note +Both target languages would accept it, which makes the strictness look gratuitous. It is not. + +VHDL's `numeric_std` compares `unsigned`/`signed` operands of unequal length by resizing internally, so the comparison alone is portable. Verilog arrives at the same answer by a different route: operand widths there are *context-determined*, which extends more than the operand itself. A narrower operand that is an expression is **evaluated** at the wider operand's width, so with a 4-bit `a` and an 8-bit `b`, `(a + a) < b` computes the sum at 8 bits and stops wrapping where DFHDL says it wraps. + +Widening in the source keeps one meaning across every backend, and keeps the width change where you can see it. +/// + /// details | Scala `Int` constants auto-lift in comparisons type: note Plain Scala `Int` values can be used directly in comparisons and arithmetic with DFHDL typed variables. No explicit coercion is needed: From 2714d5d706535a3160a42e79126e228fcf1113ef Mon Sep 17 00:00:00 2001 From: Oron Port Date: Tue, 11 Aug 2026 22:48:07 +0300 Subject: [PATCH 21/40] core: `.extend` and `.truncate` width-adjustment tags for `UInt`/`SInt` Two IR tags that each permit ONE direction of a context-decided width adjustment, and the `DFXInt` methods that apply them. A tag is a PERMISSION, not an assertion: where the direction it permits does not apply it contributes nothing, and the context's ordinary width rules decide, error included. This is what an operation with no designated target has no other way of saying. An assignment names its target, so `u8 := u4` can extend implicitly. A comparison's operands are symmetric, and nothing in `a < b` privileges widening the narrower over narrowing the wider, so the tag marks WHICH operand adapts and the other one's width is the destination. No consumer yet: no check reads either tag, so nothing changes behaviour. `ir.ResizeTag` and the no-argument `.resize` are untouched and still the working spelling. Co-Authored-By: Claude Opus 5 (1M context) --- .../ir/src/main/scala/dfhdl/compiler/ir/DFTags.scala | 9 +++++++++ core/src/main/scala/dfhdl/core/DFDecimal.scala | 6 ++++++ 2 files changed, 15 insertions(+) diff --git a/compiler/ir/src/main/scala/dfhdl/compiler/ir/DFTags.scala b/compiler/ir/src/main/scala/dfhdl/compiler/ir/DFTags.scala index 17b8003d6..92c9169a1 100644 --- a/compiler/ir/src/main/scala/dfhdl/compiler/ir/DFTags.scala +++ b/compiler/ir/src/main/scala/dfhdl/compiler/ir/DFTags.scala @@ -20,6 +20,15 @@ case class DefaultRTDomainCfgTag( ) extends DFTag case object ResizeTag extends DFTag type ResizeTag = ResizeTag.type +// Width-adjustment PERMISSIONS carried by a value into a context that decides its width. Each +// permits one direction and contributes nothing in the other, where the context's ordinary +// width rules decide, error included: a tag is never a claim that an adjustment happens, only +// that one may. Which operand carries the tag is what an operation with no designated target +// (a comparison, unlike an assignment) has no other way of saying. +case object ExtendTag extends DFTag +type ExtendTag = ExtendTag.type +case object TruncateTag extends DFTag +type TruncateTag = TruncateTag.type case object SyntheticDefaultTag extends DFTag type SyntheticDefaultTag = SyntheticDefaultTag.type case object ImplicitlyFromIntTag extends DFTag diff --git a/core/src/main/scala/dfhdl/core/DFDecimal.scala b/core/src/main/scala/dfhdl/core/DFDecimal.scala index e29c3eb0b..cd46437a8 100644 --- a/core/src/main/scala/dfhdl/core/DFDecimal.scala +++ b/core/src/main/scala/dfhdl/core/DFDecimal.scala @@ -1474,6 +1474,12 @@ object DFXInt: @targetName("resizeDFXIntAuto") def resize(using DFCG): DFValTP[DFXInt[S, Int, N], P] = lhs.tag(ir.ResizeTag).asValTP[DFXInt[S, Int, N], P] + // permission to adjust the width in ONE direction, taken up by the context that + // decides the width; in the other direction it contributes nothing (see `ir.ExtendTag`) + def extend(using DFCG): DFValTP[DFXInt[S, Int, N], P] = + lhs.tag(ir.ExtendTag).asValTP[DFXInt[S, Int, N], P] + def truncate(using DFCG): DFValTP[DFXInt[S, Int, N], P] = + lhs.tag(ir.TruncateTag).asValTP[DFXInt[S, Int, N], P] @targetName("resizeDFXInt") def resize[RW <: IntP]( updatedWidth: IntParam[RW] From 4610e449eb787c5d481873011b31763bab08ef57 Mon Sep 17 00:00:00 2001 From: Oron Port Date: Tue, 11 Aug 2026 22:51:55 +0300 Subject: [PATCH 22/40] wip: auto constraints for undecidable wildcard fits, messages via `Check.message` Checkpoint of the auto-constraint work carried over from the previous session, not yet reviewed. `Checked` gains `Check1.message`/`Check2.message`, so one rule has one message text whichever of its three halves reports it: the compile-time reduction, the check's own runtime test over `Int`s, or a symbolic decision over width EXPRESSIONS. `DFDecimal`'s width checks are refactored onto a shared `fitCheck` three-way (provably fits, provably violated, undecidable) with `widthFitCheck` and the new `wildcardFitCheck` as its two callers, and the undecidable arms are wired at the arithmetic, TC-conversion, wildcard-fit and comparison sites. Specs move with it: the flipped elaboration checks, the auto constraints pinned in the `Blinker` and `named text output` printouts, and the "The " message prefixes. Co-Authored-By: Claude Opus 5 (1M context) --- .../StagesSpec/PrintCodeStringSpec.scala | 4 +- .../scala/dfhdl/core/AutoConstraint.scala | 80 +++++++------ .../src/main/scala/dfhdl/core/DFDecimal.scala | 108 +++++++++++++++--- .../test/scala/CoreSpec/DFDecimalSpec.scala | 8 +- .../main/scala/dfhdl/internals/Checked.scala | 54 +++++++++ lib/src/test/scala/ContextWidenSpec.scala | 1 + .../test/scala/ElaborationChecksSpec.scala | 37 ++++++ 7 files changed, 234 insertions(+), 58 deletions(-) diff --git a/compiler/stages/src/test/scala/StagesSpec/PrintCodeStringSpec.scala b/compiler/stages/src/test/scala/StagesSpec/PrintCodeStringSpec.scala index b88d634a5..3572130f6 100644 --- a/compiler/stages/src/test/scala/StagesSpec/PrintCodeStringSpec.scala +++ b/compiler/stages/src/test/scala/StagesSpec/PrintCodeStringSpec.scala @@ -854,6 +854,7 @@ class PrintCodeStringSpec extends StageSpec(stageCreatesUnrefAnons = true): | led.din := !led | else cnt.din := cnt + d"1'1".resize(clog2(maxCnt + 1)) | end if + | val constraint = assert(clog2(maxCnt + 1) >= 23, s"Design parameter violation found. Expected: clog2(maxCnt + 1) >= 23", Severity.Fatal) |end Blinker |""".stripMargin ) @@ -3295,7 +3296,7 @@ class PrintCodeStringSpec extends StageSpec(stageCreatesUnrefAnons = true): val o = UInt(W) <> OUT val posW = assert(W > 0, s"W must be positive, got $W") process(all): - val inRange = assert(i < d"8'200", s"i too large: $i") + val inRange = assert(i < 200, s"i too large: $i") val trace = println(s"i: $i") o <> i end Named @@ -3309,6 +3310,7 @@ class PrintCodeStringSpec extends StageSpec(stageCreatesUnrefAnons = true): | val inRange = assert(i < d"8'200".resize(W), s"i too large: ${i}") | val trace = println(s"i: ${i}") | o <> i + | val constraint = assert(W >= 8, s"Design parameter violation found. Expected: W >= 8", Severity.Fatal) |end Named |""".stripMargin ) diff --git a/core/src/main/scala/dfhdl/core/AutoConstraint.scala b/core/src/main/scala/dfhdl/core/AutoConstraint.scala index 4ab0f7f94..de69aadbf 100644 --- a/core/src/main/scala/dfhdl/core/AutoConstraint.scala +++ b/core/src/main/scala/dfhdl/core/AutoConstraint.scala @@ -43,10 +43,15 @@ object AutoConstraint: */ type Guard = DFValOf[DFBool] - /** The condition `lhs >= rhs`, built as a value instead of decided. */ - def ge(lhs: IntParam[Int], rhs: IntParam[Int])(using dfc: DFC): Guard = + private def condition(op: FuncOp, lhs: IntParam[Int], rhs: IntParam[Int])(using + dfc: DFC + ): Guard = given DFC = dfc.anonymize - DFVal.Func[DFBool, Any](DFBool, FuncOp.>=, List(lhs.toDFConst.asIR, rhs.toDFConst.asIR)) + DFVal.Func[DFBool, Any](DFBool, op, List(lhs.toDFConst.asIR, rhs.toDFConst.asIR)) + + /** The condition `lhs >= rhs`, built as a value instead of decided. */ + def ge(lhs: IntParam[Int], rhs: IntParam[Int])(using DFC): Guard = + condition(FuncOp.>=, lhs, rhs) /** Decides the width fit `lhs >= rhs`, or `None` when it holds for some parameter assignments and * not others. The undecided answer is what [[raise]] exists for. @@ -77,32 +82,39 @@ object AutoConstraint: /** What a guard requires, canonically: the relation it states, as `linear >= 0`. */ private type Requirement = ir.IntExprCalc.Linear - /** The requirement a guard states, or `None` for a guard that is not a comparison of two integer - * expressions. Nothing generates such a guard, but a user's own assertion may well be one, and - * it then simply takes no part in minimization. + /** What a guard requires, as the conjunction of one or more `linear >= 0` relations. Empty for a + * guard that is not a comparison of two integer expressions: nothing generates such a guard, but + * a user's own assertion may well be one, and it then simply takes no part in minimization. * * Every comparison normalizes onto the same shape, so a user's `W <= 8` is comparable with a * generated `16 >= W` without either being rewritten. A strict comparison is the non-strict one - * over integers, one tighter. + * over integers, one tighter; an equality is the two directions at once, which is what lets a + * user's `W == 8` cover a generated `W >= 8`. */ - private def requirementOf(guard: ir.DFVal)(using ir.MemberGetSet): Option[Requirement] = + private def requirementsOf(guard: ir.DFVal)(using ir.MemberGetSet): List[Requirement] = def diff(a: ir.DFVal, b: ir.DFVal): Requirement = ir.IntExprCalc.linearDiff(a, b) def tighter(req: Requirement): Requirement = req.copy(offset = req.offset - 1) guard match case ir.DFVal.Func(op = op, args = List(lhs, rhs)) => op match - case FuncOp.>= => Some(diff(lhs.get, rhs.get)) - case FuncOp.> => Some(tighter(diff(lhs.get, rhs.get))) - case FuncOp.<= => Some(diff(rhs.get, lhs.get)) - case FuncOp.< => Some(tighter(diff(rhs.get, lhs.get))) - case _ => None - case _ => None + case FuncOp.>= => List(diff(lhs.get, rhs.get)) + case FuncOp.> => List(tighter(diff(lhs.get, rhs.get))) + case FuncOp.<= => List(diff(rhs.get, lhs.get)) + case FuncOp.< => List(tighter(diff(rhs.get, lhs.get))) + case FuncOp.=== => List(diff(lhs.get, rhs.get), diff(rhs.get, lhs.get)) + case _ => Nil + case _ => Nil - /** Whether `stronger` leaves `weaker` with nothing to say: they constrain the same expression, - * and satisfying `stronger` satisfies `weaker`. + /** Whether `stronger` leaves `weaker` with nothing to say: every relation `weaker` states is + * already implied by one of `stronger`'s. Two relations compare only when their symbolic terms + * cancel, and then a non-negative difference means satisfying the one satisfies the other. */ - private def implies(stronger: Requirement, weaker: Requirement)(using ir.MemberGetSet): Boolean = - ir.IntExprCalc.constOffsetDiff(weaker, stronger).exists(_ >= 0) + private def implies(stronger: List[Requirement], weaker: List[Requirement])(using + ir.MemberGetSet + ): Boolean = + weaker.nonEmpty && weaker.forall(w => + stronger.exists(s => ir.IntExprCalc.constOffsetDiff(w, s).exists(_ >= 0)) + ) /** The design's own contract, as the body stated it: the requirements of its static assertions * whose severity makes them requirements at all. `Info` and `Warning` report, they do not @@ -113,16 +125,16 @@ object AutoConstraint: * a GENERATED constraint redundant, and having written `assert(W <= 8, ...)` the user should not * then be shown a generated `16 >= W` next to it. */ - private def userRequirements(ctx: DesignContext)(using dfc: DFC): List[Requirement] = + private def userRequirements(ctx: DesignContext)(using dfc: DFC): List[List[Requirement]] = import dfc.getSet import dfhdl.compiler.analysis.isStaticAssert ctx.getImmutableMemberList.view.collect { case textOut: ir.TextOut if textOut.isStaticAssert => textOut.op match case ir.TextOut.Op.Assert(assertionRef, Severity.Error | Severity.Fatal) => - requirementOf(assertionRef.get) - case _ => None - }.flatten.toList + requirementsOf(assertionRef.get) + case _ => Nil + }.filter(_.nonEmpty).toList /** The condition as the design states it, which is the whole of what a violation has to report. */ @@ -156,23 +168,23 @@ object AutoConstraint: val ctx = dfc.mutableDB.DesignContext.current if (!dfc.inMetaProgramming) val pending = ctx.autoConstraintGuards.map(_.setTags(_.removeTagOf[ir.AutoConstraint])) - val kept = mutable.ListBuffer.empty[(ir.DFVal, Option[Requirement])] + val kept = mutable.ListBuffer.empty[(ir.DFVal, List[Requirement])] if (pending.nonEmpty) val userReqs = userRequirements(ctx) pending.foreach { guard => - val reqOpt = requirementOf(guard) - val alreadyStated = reqOpt match - case Some(req) => - userReqs.exists(implies(_, req)) || - kept.exists((_, keptOpt) => keptOpt.exists(implies(_, req))) - // a guard with no comparable form takes no part: kept unless structurally repeated - case None => kept.exists((keptGuard, _) => keptGuard =~ guard) + val reqs = requirementsOf(guard) + val alreadyStated = + if (reqs.isEmpty) + // a guard with no comparable form takes no part: kept unless structurally repeated + kept.exists((keptGuard, _) => keptGuard =~ guard) + else + userReqs.exists(implies(_, reqs)) || kept.exists((_, keptReqs) => + implies(keptReqs, reqs) + ) if (!alreadyStated) // this one may in turn be the stronger statement of something already kept - reqOpt.foreach(req => - kept.filterInPlace((_, keptOpt) => !keptOpt.exists(implies(req, _))) - ) - kept += ((guard, reqOpt)) + if (reqs.nonEmpty) kept.filterInPlace((_, keptReqs) => !implies(reqs, keptReqs)) + kept += ((guard, reqs)) } end if val survivors = kept.map(_._1).toList diff --git a/core/src/main/scala/dfhdl/core/DFDecimal.scala b/core/src/main/scala/dfhdl/core/DFDecimal.scala index cd46437a8..f914c4bdc 100644 --- a/core/src/main/scala/dfhdl/core/DFDecimal.scala +++ b/core/src/main/scala/dfhdl/core/DFDecimal.scala @@ -136,10 +136,31 @@ object DFDecimal: )(using DFC): Unit = if (!lhs.hasProvablyEqualWidthTo(rhs)) throw new IllegalArgumentException( - s"""|Cannot apply this operation between a value of ${lhs.widthErrorString} bits width (LHS) and a value of ${rhs.widthErrorString} bits width (RHS). - |An explicit conversion must be applied.""".stripMargin + `LW == RW`.message(lhs.widthErrorString, rhs.widthErrorString) ) + /** [[equalWidthCheck]] over width EXPRESSIONS rather than over the types carrying them, for a + * caller that holds the widths but no `DFTypeW`. Same rule, same message, and the same OPAQUE + * treatment of design parameters (see `IntParamRef.isProvablyEqualTo`); decided on the values + * rather than through references to them, since a reference minted here would belong to no + * member (see `IntParam.errorString`). + */ + protected[core] def equalWidthCheck(lhs: IntParam[Int], rhs: IntParam[Int])(using + dfc: DFC + ): Unit = + import dfc.getSet + val provablyEqual = (lhs.toScalaIntOpt, rhs.toScalaIntOpt) match + case (Some(lhsInt), Some(rhsInt)) => lhsInt == rhsInt + case _ => + ir.IntExprCalc.constDiff( + lhs.toDFConst.asIR, + rhs.toDFConst.asIR, + resolveDesignParams = false + ).contains(0) + if (!provablyEqual) + throw new IllegalArgumentException(`LW == RW`.message(lhs.errorString, rhs.errorString)) + end equalWidthCheck + /** The elaboration half of [[`LW >= RW`]], for a width pair at least one of whose sides is not * statically known (a design parameter): decided on the two width expressions, and a pair that * is neither provably fitting nor provably violated is ACCEPTED, with the fit stated as a @@ -154,18 +175,32 @@ object DFDecimal: * states is the design's interface rather than this operation, so its own condition is what it * reports. Must be invoked wherever [[`LW >= RW`]] is, on the branch where a width is unknown. */ + // The message is the CHECK's, over the width expressions rather than over the `Int`s the + // check itself takes: one rule has one message, whichever of its three halves reports it + // (the compile-time reduction, the check's own runtime test, or this symbolic decision). + private def fitCheck(lhs: IntParam[Int], rhs: IntParam[Int])(violation: => String)(using + DFC + ): Unit = + AutoConstraint.widthFitGE(lhs, rhs) match + case Some(true) => // fits for every parameter assignment + case Some(false) => throw new IllegalArgumentException(violation) + case None => AutoConstraint.raise(AutoConstraint.ge(lhs, rhs)) + protected[core] def widthFitCheck( lhs: IntParam[Int], rhs: IntParam[Int] )(using DFC): Unit = - AutoConstraint.widthFitGE(lhs, rhs) match - case Some(true) => // fits for every parameter assignment - case Some(false) => - throw new IllegalArgumentException( - s"""The applied RHS value width (${rhs.errorString}) is larger than the LHS variable width (${lhs.errorString}).""" - ) - case None => AutoConstraint.raise(AutoConstraint.ge(lhs, rhs)) - end widthFitCheck + fitCheck(lhs, rhs)(`LW >= RW`.message(lhs.errorString, rhs.errorString)) + + /** [[widthFitCheck]] for the fit a WILDCARD `Int` needs to adapt to a bit-accurate value: the + * elaboration half of [[`BaW >= WcW`]], invoked where that check's arm cannot decide. + */ + protected[core] def wildcardFitCheck( + baWidth: IntParam[Int], + wcWidth: IntParam[Int] + )(using DFC): Unit = + fitCheck(baWidth, wcWidth)(`BaW >= WcW`.message(baWidth.errorString, wcWidth.errorString)) + object `LS >= RS` extends Check2[ Boolean, @@ -252,6 +287,7 @@ object DFDecimal: if (leftSigned != rightSigned) rightWidth + 1 else rightWidth ) end given + trait CompareCheck[ ValS <: Boolean, ValW <: IntP, @@ -1264,7 +1300,32 @@ object DFXInt: (dfType.widthIntOpt, rhsWidthOpt) match case (Some(dfTypeW), Some(rhsW)) => check(dfType.signed, dfTypeW, rhsSigned, rhsW) case _ => + // A width is parametric, and what the comparison requires depends on the + // argument, exactly as the compile-time half decides it. + // + // A wildcard `Int` ADAPTS to the receiver, so it states the fit it needs. Two + // BIT-ACCURATE operands are held to EQUAL widths and there is no adaptation to + // assume anything for: a pair that cannot be proven equal is rejected, as its + // resolved counterpart is. The resize the comparison would otherwise emit is + // not a semantics worth asserting, it is the silent truncation the equality + // rule exists to prevent. + import dfc.getSet + import DFXInt.Val.getActualWidthParam + val argIsWildcard = dfValArg.dfType.asIR.isDFInt32 || + CarryPromote.hasImplicitlyFromIntTag(dfValArg.asIR) + val dfTypeWidth = dfType.asIR.magnitudeWidthParamRef.get + val argWidth = dfValArg.getActualWidthParam(rhsWidthOpt) + if (argIsWildcard) + import IntParam.+ + // an unsigned wildcard gains the sign bit it needs under a signed receiver + val effectiveWidth = + if (dfType.signed.value && !rhsSigned) argWidth + 1 + else argWidth + wildcardFitCheck(dfTypeWidth, effectiveWidth) + else equalWidthCheck(dfTypeWidth, argWidth) + end if case None => + end match DFXInt.Val.Ops.toDFXIntOf(dfValArg)(dfType).asValTP[DFXInt[LS, LW, LN], RP] end conv // Check Verilog-semantics mismatch for comparisons: same trigger as @@ -1555,16 +1616,25 @@ object DFXInt: throw new IllegalArgumentException( s"Wildcard `Int` value is negative and cannot adapt to an unsigned bit-accurate value." ) - (baType.widthIntOpt, wcWidthIntOpt) match - case (Some(baWidth), Some(wcWidth)) => - // Unsigned wildcard adapting to signed bit-accurate value needs an extra bit - val effectiveWidth = - if (baType.signed && !wcSigned) wcWidth + 1 else wcWidth - if (effectiveWidth > baWidth) - throw new IllegalArgumentException( - s"Wildcard `Int` value width ($effectiveWidth) is larger than the bit-accurate value width ($baWidth)." + wcWidthIntOpt.foreach { wcWidth => + // Unsigned wildcard adapting to signed bit-accurate value needs an extra bit + val effectiveWidth = + if (baType.signed && !wcSigned) wcWidth + 1 else wcWidth + baType.widthIntOpt match + case Some(baWidth) => + if (effectiveWidth > baWidth) + throw new IllegalArgumentException( + `BaW >= WcW`.message(baWidth, effectiveWidth) + ) + case None => + // the bit-accurate width is parametric, so the fit the adaptation relies on is + // stated as a constraint of the design (an integer type: fraction 0, so the + // magnitude ref is the total-width ref) + wildcardFitCheck( + baType.magnitudeWidthParamRef.get, + IntParam.forced[Int](effectiveWidth) ) - case _ => + } case _ => end match end checkWildcardFit diff --git a/core/src/test/scala/CoreSpec/DFDecimalSpec.scala b/core/src/test/scala/CoreSpec/DFDecimalSpec.scala index 65eb70d42..631614582 100644 --- a/core/src/test/scala/CoreSpec/DFDecimalSpec.scala +++ b/core/src/test/scala/CoreSpec/DFDecimalSpec.scala @@ -784,7 +784,7 @@ class DFDecimalSpec extends DFSpec: // Elaboration-time errors for non-literal value-fit checking assertDSLErrorLog( - "Wildcard `Int` value width (10) is larger than the bit-accurate value width (8)." + "The wildcard `Int` value width (10) is larger than the bit-accurate value width (8)." )( "" ) { @@ -801,7 +801,7 @@ class DFDecimalSpec extends DFSpec: } // Unsigned wildcard adapting to signed bit-accurate value at elaboration time assertDSLErrorLog( - "Wildcard `Int` value width (9) is larger than the bit-accurate value width (8)." + "The wildcard `Int` value width (9) is larger than the bit-accurate value width (8)." )( "" ) { @@ -853,7 +853,7 @@ class DFDecimalSpec extends DFSpec: // The wildcard parameter must fit the bit-accurate operand assertRuntimeErrorLog( - "Wildcard `Int` value width (10) is larger than the bit-accurate value width (8)." + "The wildcard `Int` value width (10) is larger than the bit-accurate value width (8)." ) { val bigVal: Int <> CONST = 1000 u8 +^ bigVal @@ -1363,7 +1363,7 @@ class DFDecimalSpec extends DFSpec: val cnt = Bits[8] <> VAR val arg = 10000 val errMsg = - "Wildcard `Int` value width (14) is larger than the bit-accurate value width (8)." + "The wildcard `Int` value width (14) is larger than the bit-accurate value width (8)." assertRuntimeErrorLog(errMsg, 50, 59)(cnt := cnt + arg) assertRuntimeErrorLog(errMsg, 50, 66)(cnt := cnt + (cnt + arg)) assertRuntimeErrorLog(errMsg, 50, 65)(cnt := cnt + arg + cnt) diff --git a/internals/src/main/scala/dfhdl/internals/Checked.scala b/internals/src/main/scala/dfhdl/internals/Checked.scala index 03eb73e3e..1edc78bb2 100644 --- a/internals/src/main/scala/dfhdl/internals/Checked.scala +++ b/internals/src/main/scala/dfhdl/internals/Checked.scala @@ -98,6 +98,11 @@ private class MacroClass[Q <: Quotes](using val quotes: Q)( case ConstantType(BooleanConstant(cond)) => Some(cond) case _ => None + // The check's message alone, over operands that are not the `Int`s the check applies to. The + // caller has decided the check some other way and needs only the text it would have reported. + def messageExpr(argsTerm: List[Term]): Expr[String] = + lambdaTypeToTerm(argsTerm, msgTpe).asExprOf[String] + def applyExpr(argsTerm: List[Term]): Expr[Unit] = def condExpr = lambdaTypeToTerm(argsTerm, condTpe) .asExprOf[Boolean] @@ -138,6 +143,15 @@ trait Check1[ type Warn[T <: Wide] = Check1.Check[Wide, T, Cond, Msg, Cond[T], Msg[T], true] type WarnNUB[T] = Check1.CheckNUB[Wide, T, Cond, Msg, true] inline def apply(arg: Wide): Unit = compiletime.summonInline[Check[Wide]] + + /** The check's own message, for a caller that decided this check some other way because the + * operand is not the `Int` the check applies to (a parametric width, say). + * + * [[apply]] reports `Msg` itself when it can decide the check. Restating that text by hand next + * to such a caller is what this exists to prevent: a check has ONE message, and it is the + * check's. The operand renders through its `toString`. + */ + inline def message(arg: Any): String = ${ Check1.messageMacro[Wide, Cond, Msg]('arg) } end Check1 trait UBound[UB, T]: @@ -151,6 +165,20 @@ object UBound extends UBoundLP: type Out = T object Check1: + final def messageMacro[ + Wide, + Cond[T <: Wide] <: Boolean, + Msg[T <: Wide] <: String + ](arg: Expr[Any])(using Quotes, Type[Wide], Type[Cond], Type[Msg]): Expr[String] = + import quotes.reflect.* + new MacroClass[quotes.type](using quotes)( + TypeRepr.of[Cond], + TypeRepr.of[Msg], + TypeRepr.of[Nothing], + TypeRepr.of[Nothing], + false + ).messageExpr(List(arg.asTerm)) + trait CheckNUB[ Wide, T, @@ -293,9 +321,35 @@ trait Check2[ Check2.CheckNUB[Wide1, Wide2, T1, T2, Cond, Msg, true] inline def apply(arg1: Wide1, arg2: Wide2): Unit = compiletime.summonInline[Check[Wide1, Wide2]] + + /** The two-operand [[Check1.message]]. */ + inline def message(arg1: Any, arg2: Any): String = + ${ Check2.messageMacro[Wide1, Wide2, Cond, Msg]('arg1, 'arg2) } end Check2 object Check2: + final def messageMacro[ + Wide1, + Wide2, + Cond[T1 <: Wide1, T2 <: Wide2] <: Boolean, + Msg[T1 <: Wide1, T2 <: Wide2] <: String + ](arg1: Expr[Any], arg2: Expr[Any])(using + Quotes, + Type[Wide1], + Type[Wide2], + Type[Cond], + Type[Msg] + ): Expr[String] = + import quotes.reflect.* + new MacroClass[quotes.type](using quotes)( + TypeRepr.of[Cond], + TypeRepr.of[Msg], + TypeRepr.of[Nothing], + TypeRepr.of[Nothing], + false + ).messageExpr(List(arg1.asTerm, arg2.asTerm)) + end messageMacro + trait CheckNUB[ Wide1, Wide2, diff --git a/lib/src/test/scala/ContextWidenSpec.scala b/lib/src/test/scala/ContextWidenSpec.scala index 0d0daa13d..709382654 100644 --- a/lib/src/test/scala/ContextWidenSpec.scala +++ b/lib/src/test/scala/ContextWidenSpec.scala @@ -52,6 +52,7 @@ class ContextWidenSpec extends DesignSpec: | usub <> (ua -^ ub) | acc <> (a.eby(2) + b.eby(2)) | chain <> (a.eby(2) + b.eby(2) + sd"2'1".resize(W).eby(2)) + | val constraint = assert(W >= 2, s"Design parameter violation found. Expected: W >= 2", Severity.Fatal) |end ParamWiden |""".stripMargin ) diff --git a/lib/src/test/scala/ElaborationChecksSpec.scala b/lib/src/test/scala/ElaborationChecksSpec.scala index 49260fa5b..c93b3a085 100644 --- a/lib/src/test/scala/ElaborationChecksSpec.scala +++ b/lib/src/test/scala/ElaborationChecksSpec.scala @@ -1792,4 +1792,41 @@ class ElaborationChecksSpec extends DesignSpec: "an auto-constraint marker survived elaboration" ) + // A comparison holds two BIT-ACCURATE operands to EQUAL widths, which for a parametric pair + // means provably equal. An unprovable pair is rejected rather than accepted with a constraint: + // the comparison has no adaptation semantics to assume anything for, and the resize it would + // otherwise emit silently truncates the wider operand. A wildcard `Int` argument is the case + // that DOES adapt, and it states the fit it needs instead. + test("comparison between bit-accurate values of unprovable equal widths"): + object Test: + @top(false) class Unprovable(val W: Int <> CONST = 8) extends EDDesign: + val i = UInt(W) <> IN + val o = Bit <> OUT + o <> (i < d"8'200") + end Unprovable + @top(false) class WildcardArg(val W: Int <> CONST = 8) extends EDDesign: + val i = UInt(W) <> IN + val o = Bit <> OUT + o <> (i < 200) + end WildcardArg + @top(false) class ProvablyEqual(val W: Int <> CONST = 8) extends EDDesign: + val i, j = UInt(W) <> IN + val o = Bit <> OUT + o <> (i < j) + end ProvablyEqual + end Test + import Test.* + assertElaborationErrors(Unprovable())( + s"""|Elaboration errors found! + |DFiant HDL elaboration error! + |Position: ${currentFilePos}ElaborationChecksSpec.scala:1805:9 - 1805:27 + |Hierarchy: Unprovable + |Operation: `apply` + |Message: Cannot apply this operation between a value of W bits width (LHS) and a value of 8 bits width (RHS). + |An explicit conversion must be applied.""".stripMargin + ) + // the accepted species elaborate without error + val _ = WildcardArg() + val _ = ProvablyEqual() + end ElaborationChecksSpec From cc47f271e8141ad2a18d6879615d697b21ab646b Mon Sep 17 00:00:00 2001 From: Oron Port Date: Tue, 11 Aug 2026 23:03:54 +0300 Subject: [PATCH 23/40] core: `.extend` and `.truncate` for `Bits`, disambiguated by target name `hdl.scala` exports `DFBits.Val.Ops.*` and `DFDecimal.Val.Ops.*` into one namespace, so a method name defined on both types produces two export forwarders with the same erased name. Scalac does not report that as a double definition; it crashes in the JVM backend with `ClassBType.info not yet assigned`. `resize` never hit it because the `DFXInt` side has carried `@targetName` since it was written, which is the pattern followed here. Worth knowing when the next method is added to both types: the failure names an arbitrary unrelated class, survives a clean, and is invariant to placement, so it reads as anything but a name collision. Co-Authored-By: Claude Opus 5 (1M context) --- core/src/main/scala/dfhdl/core/DFBits.scala | 8 ++++++++ core/src/main/scala/dfhdl/core/DFDecimal.scala | 2 ++ 2 files changed, 10 insertions(+) diff --git a/core/src/main/scala/dfhdl/core/DFBits.scala b/core/src/main/scala/dfhdl/core/DFBits.scala index dcd72015b..cf090180d 100644 --- a/core/src/main/scala/dfhdl/core/DFBits.scala +++ b/core/src/main/scala/dfhdl/core/DFBits.scala @@ -744,6 +744,14 @@ object DFBits: DFVal.Alias.AsIs(DFBits(updatedWidth), lhs) def resize(using DFCG): DFValTP[DFBits[Int], P] = lhs.tag(ir.ResizeTag).asValTP[DFBits[Int], P] + // permission to adjust the width in ONE direction, taken up by the context that decides + // the width; in the other direction it contributes nothing (see `ir.ExtendTag`) + @targetName("extendDFBits") + def extend(using DFCG): DFValTP[DFBits[Int], P] = + lhs.tag(ir.ExtendTag).asValTP[DFBits[Int], P] + @targetName("truncateDFBits") + def truncate(using DFCG): DFValTP[DFBits[Int], P] = + lhs.tag(ir.TruncateTag).asValTP[DFBits[Int], P] def resize[RW <: IntP](updatedWidth: IntParam[RW])(using check: Arg.Width.CheckNUB[RW], dfc: DFCG diff --git a/core/src/main/scala/dfhdl/core/DFDecimal.scala b/core/src/main/scala/dfhdl/core/DFDecimal.scala index f914c4bdc..37265dba7 100644 --- a/core/src/main/scala/dfhdl/core/DFDecimal.scala +++ b/core/src/main/scala/dfhdl/core/DFDecimal.scala @@ -1537,8 +1537,10 @@ object DFXInt: lhs.tag(ir.ResizeTag).asValTP[DFXInt[S, Int, N], P] // permission to adjust the width in ONE direction, taken up by the context that // decides the width; in the other direction it contributes nothing (see `ir.ExtendTag`) + @targetName("extendDFXInt") def extend(using DFCG): DFValTP[DFXInt[S, Int, N], P] = lhs.tag(ir.ExtendTag).asValTP[DFXInt[S, Int, N], P] + @targetName("truncateDFXInt") def truncate(using DFCG): DFValTP[DFXInt[S, Int, N], P] = lhs.tag(ir.TruncateTag).asValTP[DFXInt[S, Int, N], P] @targetName("resizeDFXInt") From 2070a062fead25ee724ff5ce6c5c70c6e3f51d23 Mon Sep 17 00:00:00 2001 From: Oron Port Date: Tue, 11 Aug 2026 23:24:15 +0300 Subject: [PATCH 24/40] core: the width-adjustment permissions relax the checks they cover `AutoConstraint.permitsWidthAdjust` is the one decision, and the three sites that used to ask `hasTag[ResizeTag]` now ask it: the `Bits` and `DFXInt` assignment conversions, and the upper-bound argument of `UInt.until`-style indexing. A permission covers ONE direction, so where it does not apply it answers false and the site's own width rule decides and reports, unchanged. That is what makes `u8 := u4.truncate` elaborate to exactly what `u8 := u4` does, and `u4 := u8.extend` report the ordinary "RHS value width (8) is larger than the LHS variable width (4)". An undecidable pair is covered, stating the relation it relies on as a design constraint, on the same three-way as every other width decision over parameters. `ResizeTag` still short-circuits to true, so `.resize` behaves exactly as before. Co-Authored-By: Claude Opus 5 (1M context) --- .../scala/dfhdl/core/AutoConstraint.scala | 33 +++++++++++++ core/src/main/scala/dfhdl/core/DFBits.scala | 2 +- .../src/main/scala/dfhdl/core/DFDecimal.scala | 8 ++- core/src/test/scala/CoreSpec/DFBitsSpec.scala | 31 ++++++++++++ .../test/scala/CoreSpec/DFDecimalSpec.scala | 49 +++++++++++++++++++ 5 files changed, 120 insertions(+), 3 deletions(-) diff --git a/core/src/main/scala/dfhdl/core/AutoConstraint.scala b/core/src/main/scala/dfhdl/core/AutoConstraint.scala index de69aadbf..2d6968dfd 100644 --- a/core/src/main/scala/dfhdl/core/AutoConstraint.scala +++ b/core/src/main/scala/dfhdl/core/AutoConstraint.scala @@ -79,6 +79,39 @@ object AutoConstraint: guard.asIR.setTags(_.tag(ir.AutoConstraint)) () + /** Whether a width-adjustment permission carried by `value` (see `ir.ExtendTag`) covers adjusting + * it to `targetWidth`. + * + * A permission covers ONE direction. Where it does not apply it answers false, contributing + * nothing, and the caller's own width rule decides and reports, exactly as it would for an + * untagged value: a permission is never a claim that an adjustment happens, only that one may. + * + * An undecidable pair is covered, with the relation the permission relies on stated as a + * constraint of the design, on the same three-way as every other width decision made over + * parameters (see [[widthFitGE]]). + */ + def permitsWidthAdjust(value: DFValAny, targetWidth: IntParam[Int])(using dfc: DFC): Boolean = + import dfc.getSet + def covers(wider: IntParam[Int], narrower: IntParam[Int]): Boolean = + widthFitGE(wider, narrower) match + case Some(decided) => decided + case None => + raise(ge(wider, narrower)) + true + // only the types that carry these permissions are answered for; an integer decimal keeps its + // total width in the magnitude ref (fraction 0) + val sourceWidthOpt: Option[IntParam[Int]] = value.dfType.asIR match + case ir.DFBits(widthRef) => Some(widthRef.get) + case dt: ir.DFDecimal if dt.fractionWidth == 0 => Some(dt.magnitudeWidthParamRef.get) + case _ => None + if (value.hasTag[ir.ResizeTag]) true + else if (value.hasTag[ir.ExtendTag]) + sourceWidthOpt.exists(covers(targetWidth, _)) + else if (value.hasTag[ir.TruncateTag]) + sourceWidthOpt.exists(covers(_, targetWidth)) + else false + end permitsWidthAdjust + /** What a guard requires, canonically: the relation it states, as `linear >= 0`. */ private type Requirement = ir.IntExprCalc.Linear diff --git a/core/src/main/scala/dfhdl/core/DFBits.scala b/core/src/main/scala/dfhdl/core/DFBits.scala index cf090180d..a357bc53d 100644 --- a/core/src/main/scala/dfhdl/core/DFBits.scala +++ b/core/src/main/scala/dfhdl/core/DFBits.scala @@ -479,7 +479,7 @@ object DFBits: def conv(dfType: DFBits[LW], value: V)(using dfc: DFC): Out = import Ops.resizeBits val dfVal = ic(value) - if (dfVal.hasTag[ir.ResizeTag]) + if (AutoConstraint.permitsWidthAdjust(dfVal, dfType.widthIntParam)) dfVal.resizeBits(dfType.widthIntParam).asValTP[DFBits[LW], RP] else (dfType.widthIntOpt, dfVal.widthIntOpt) match diff --git a/core/src/main/scala/dfhdl/core/DFDecimal.scala b/core/src/main/scala/dfhdl/core/DFDecimal.scala index 37265dba7..70677dfa4 100644 --- a/core/src/main/scala/dfhdl/core/DFDecimal.scala +++ b/core/src/main/scala/dfhdl/core/DFDecimal.scala @@ -1226,7 +1226,11 @@ object DFXInt: val rhs = ic(value) rhs.getActualSignedWidthOpt match case Some(rhsSigned, rhsWidthOpt) => - if (!rhs.hasTag[ir.ResizeTag] || dfType.signed != rhsSigned) + // an assignment names its target, so it already extends a narrower value: the + // permission that does work here is the one covering a WIDER one + val permitted = + AutoConstraint.permitsWidthAdjust(rhs, dfType.asIR.magnitudeWidthParamRef.get) + if (!permitted || dfType.signed != rhsSigned) (dfType.widthIntOpt, rhsWidthOpt) match case (Some(dfTypeW), Some(rhsW)) => check(dfType.signed, dfTypeW, rhsSigned, rhsW) case _ => @@ -2226,7 +2230,7 @@ object DFUInt: // TODO: in the future, it's worth considering adding assertions if (argValIR.dfType != ir.DFInt32) unsignedCheck(argVal.dfType.signed) - if (argValIR.hasTagOf[ir.ResizeTag]) + if (AutoConstraint.permitsWidthAdjust(argVal, ub.clog2)) argVal.resize(ub.clog2).asIR else (ub.toScalaIntOpt, argVal.widthIntOpt) match diff --git a/core/src/test/scala/CoreSpec/DFBitsSpec.scala b/core/src/test/scala/CoreSpec/DFBitsSpec.scala index b30c25f44..dc3b75d84 100644 --- a/core/src/test/scala/CoreSpec/DFBitsSpec.scala +++ b/core/src/test/scala/CoreSpec/DFBitsSpec.scala @@ -405,4 +405,35 @@ class DFBitsSpec extends DFSpec: val acc: Bits[Int] <> VAL = bit.bits } } + // `.extend` and `.truncate` are PERMISSIONS to adjust a value's width in one direction (see + // `DFDecimalSpec`). `Bits` assignment is exact in both directions, so either permission is + // load-bearing here, and either can fall short. + test("Width adjustment permissions") { + val b8 = Bits(8) <> VAR + val b4 = Bits(4) <> VAR + assertCodeString { + """|b4 := b8.resize(4) + |b8 := b4.eby(4) + |""".stripMargin + } { + b4 := b8.truncate + b8 := b4.extend + } + assertRuntimeErrorLog( + """|The argument width (8) is different than the receiver width (4). + |Consider applying `.resize` to resolve this issue.""".stripMargin + ) { + val c8 = Bits(8) <> VAR + val c4 = Bits(4) <> VAR + c4 := c8.extend + } + assertRuntimeErrorLog( + """|The argument width (4) is different than the receiver width (8). + |Consider applying `.resize` to resolve this issue.""".stripMargin + ) { + val d8 = Bits(8) <> VAR + val d4 = Bits(4) <> VAR + d8 := d4.truncate + } + } end DFBitsSpec diff --git a/core/src/test/scala/CoreSpec/DFDecimalSpec.scala b/core/src/test/scala/CoreSpec/DFDecimalSpec.scala index 631614582..6b1fedbd9 100644 --- a/core/src/test/scala/CoreSpec/DFDecimalSpec.scala +++ b/core/src/test/scala/CoreSpec/DFDecimalSpec.scala @@ -1401,4 +1401,53 @@ class DFDecimalSpec extends DFSpec: c <> (if (sel) a else b) } + // `.extend` and `.truncate` are PERMISSIONS to adjust a value's width in one direction, taken up + // by whatever context decides that width. Where the permitted direction does not apply, the + // permission contributes nothing and the ordinary width rule decides and reports, exactly as it + // would for an untagged value. + test("Width adjustment permissions") { + val u8 = UInt(8) <> VAR + val u4 = UInt(4) <> VAR + val s8 = SInt(8) <> VAR + val s4 = SInt(4) <> VAR + // a widening prints in the relative `.eby` form, a narrowing in the absolute one + assertCodeString { + """|u4 := u8.resize(4) + |u8 := u4.eby(4) + |s4 := s8.resize(4) + |""".stripMargin + } { + u4 := u8.truncate + u8 := u4.extend + s4 := s8.truncate + } + // an assignment names its target, so it already extends a narrower value: both permissions + // elaborate to exactly what the untagged assignment does + assertCodeString { + """|u8 := u4.eby(4) + |u8 := u4.eby(4) + |u8 := u4.eby(4) + |u8 := u8 + |""".stripMargin + } { + u8 := u4 + u8 := u4.extend + u8 := u4.truncate + u8 := u8.extend + } + assertRuntimeErrorLog( + "The applied RHS value width (8) is larger than the LHS variable width (4)." + ) { + val v8 = UInt(8) <> VAR + val v4 = UInt(4) <> VAR + v4 := v8.extend + } + assertRuntimeErrorLog( + "The applied RHS value width (8) is larger than the LHS variable width (4)." + ) { + val w8 = SInt(8) <> VAR + val w4 = SInt(4) <> VAR + w4 := w8.extend + } + } end DFDecimalSpec From 447713cb69948ce36ec156f485ef3f0c5c90de6a Mon Sep 17 00:00:00 2001 From: Oron Port Date: Tue, 11 Aug 2026 23:27:58 +0300 Subject: [PATCH 25/40] test: a width-adjustment permission over a design parameter Pins the undecidable third of the permission decision, which the core specs cannot reach: the adjustment is covered, and the relation it relies on reaches the backend as the design's own static assertion. Co-Authored-By: Claude Opus 5 (1M context) --- .../StagesSpec/PrintCodeStringSpec.scala | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/compiler/stages/src/test/scala/StagesSpec/PrintCodeStringSpec.scala b/compiler/stages/src/test/scala/StagesSpec/PrintCodeStringSpec.scala index 3572130f6..f0fe51165 100644 --- a/compiler/stages/src/test/scala/StagesSpec/PrintCodeStringSpec.scala +++ b/compiler/stages/src/test/scala/StagesSpec/PrintCodeStringSpec.scala @@ -3470,4 +3470,27 @@ class PrintCodeStringSpec extends StageSpec(stageCreatesUnrefAnons = true): |""".stripMargin ) } + test("width adjustment permission over a design parameter") { + // a permission is decided on the two widths, so over a parameter it may be undecidable. It is + // covered either way, with the relation it relies on stated as a constraint of the design. + class ParamExtend(val W: Int <> CONST = 4) extends RTDesign: + val p = UInt(W) <> IN + val o = UInt(8) <> OUT + val q = UInt(W) <> OUT + o := p.extend + q := p + end ParamExtend + assertCodeString( + ParamExtend(), + """|class ParamExtend(val W: Int <> CONST = 4) extends RTDesign: + | val p = UInt(W) <> IN + | val o = UInt(8) <> OUT + | val q = UInt(W) <> OUT + | o := p.resize(8) + | q := p + | val constraint = assert(8 >= W, s"Design parameter violation found. Expected: 8 >= W", Severity.Fatal) + |end ParamExtend + |""".stripMargin + ) + } end PrintCodeStringSpec From 2b19e14e692fbafa400810fc6ebc0ab10ce515a6 Mon Sep 17 00:00:00 2001 From: Oron Port Date: Wed, 12 Aug 2026 07:33:42 +0300 Subject: [PATCH 26/40] core: a comparison lets its permission-carrying operand adapt, either side A comparison names no target, so nothing in `a < b` says which operand should move. The permission says it, and the other operand's width is what it adapts to, which makes `a > b.extend` and `b.extend < a` the same comparison written two ways. Two permissions in one comparison is an elaboration error: each would be adapting to a width the other is still free to change. `conv` is handed only the receiver's TYPE, and a permission sits on a VALUE, so the argument conversion moves into `convArg` and `apply` decides between the two operands with the argument materialized once. A wildcard `Int` argument keeps the right to adapt, having no width of its own for the receiver to adapt to. The anonymized context is passed explicitly to the four sites that build operands rather than given for the whole method, so the comparison itself still takes the enclosing context and keeps the name of the binding it feeds. Co-Authored-By: Claude Opus 5 (1M context) --- .../StagesSpec/PrintCodeStringSpec.scala | 28 +++++ .../scala/dfhdl/core/AutoConstraint.scala | 4 + .../src/main/scala/dfhdl/core/DFDecimal.scala | 116 +++++++++++++----- .../test/scala/CoreSpec/DFDecimalSpec.scala | 14 +++ 4 files changed, 130 insertions(+), 32 deletions(-) diff --git a/compiler/stages/src/test/scala/StagesSpec/PrintCodeStringSpec.scala b/compiler/stages/src/test/scala/StagesSpec/PrintCodeStringSpec.scala index f0fe51165..05e5c40b9 100644 --- a/compiler/stages/src/test/scala/StagesSpec/PrintCodeStringSpec.scala +++ b/compiler/stages/src/test/scala/StagesSpec/PrintCodeStringSpec.scala @@ -3493,4 +3493,32 @@ class PrintCodeStringSpec extends StageSpec(stageCreatesUnrefAnons = true): |""".stripMargin ) } + test("width adjustment permissions in a comparison") { + // the permission names which operand adapts, so the two spellings of one comparison elaborate + // alike, and either operand can be the one that moves + class CmpPermissions extends RTDesign: + val u8 = UInt(8) <> IN + val u4 = UInt(4) <> IN + val o1 = Bit <> OUT + val o2 = Bit <> OUT + val o3 = Bit <> OUT + o1 := u8 > u4.extend + o2 := u4.extend < u8 + o3 := u8.truncate < u4 + end CmpPermissions + assertCodeString( + CmpPermissions(), + """|class CmpPermissions extends RTDesign: + | val u8 = UInt(8) <> IN + | val u4 = UInt(4) <> IN + | val o1 = Bit <> OUT + | val o2 = Bit <> OUT + | val o3 = Bit <> OUT + | o1 := (u8 > u4.eby(4)).bit + | o2 := (u4.eby(4) < u8).bit + | o3 := (u8.resize(4) < u4).bit + |end CmpPermissions + |""".stripMargin + ) + } end PrintCodeStringSpec diff --git a/core/src/main/scala/dfhdl/core/AutoConstraint.scala b/core/src/main/scala/dfhdl/core/AutoConstraint.scala index 2d6968dfd..562a6aec7 100644 --- a/core/src/main/scala/dfhdl/core/AutoConstraint.scala +++ b/core/src/main/scala/dfhdl/core/AutoConstraint.scala @@ -79,6 +79,10 @@ object AutoConstraint: guard.asIR.setTags(_.tag(ir.AutoConstraint)) () + /** Whether `value` carries any width-adjustment permission at all, in either direction. */ + def hasWidthAdjustPermission(value: DFValAny)(using DFC): Boolean = + value.hasTag[ir.ResizeTag] || value.hasTag[ir.ExtendTag] || value.hasTag[ir.TruncateTag] + /** Whether a width-adjustment permission carried by `value` (see `ir.ExtendTag`) covers adjusting * it to `targetWidth`. * diff --git a/core/src/main/scala/dfhdl/core/DFDecimal.scala b/core/src/main/scala/dfhdl/core/DFDecimal.scala index 70677dfa4..bffd09bb0 100644 --- a/core/src/main/scala/dfhdl/core/DFDecimal.scala +++ b/core/src/main/scala/dfhdl/core/DFDecimal.scala @@ -1297,41 +1297,58 @@ object DFXInt: ): Compare[DFXInt[LS, LW, LN], R, Op, C] with type OutP = RP def conv(dfType: DFXInt[LS, LW, LN], arg: R)(using dfc: DFC): Out = + convArg(dfType, ic(arg)(using dfc.anonymize)) + + /** The argument converted to the receiver's type, with the checks the comparison makes of + * it. + * + * Split out of [[conv]] so that [[apply]] can materialize the argument ONCE and still + * decide which operand adapts: a width-adjustment permission sits on a VALUE, while `conv` + * is handed only the receiver's type. + */ + def convArg(dfType: DFXInt[LS, LW, LN], dfValArg: ic.Out)(using dfc: DFC): Out = given dfcAnon: DFC = dfc.anonymize - val dfValArg = ic(arg) + import dfc.getSet dfValArg.getActualSignedWidthOpt match case Some(rhsSigned, rhsWidthOpt) => - (dfType.widthIntOpt, rhsWidthOpt) match - case (Some(dfTypeW), Some(rhsW)) => check(dfType.signed, dfTypeW, rhsSigned, rhsW) - case _ => - // A width is parametric, and what the comparison requires depends on the - // argument, exactly as the compile-time half decides it. - // - // A wildcard `Int` ADAPTS to the receiver, so it states the fit it needs. Two - // BIT-ACCURATE operands are held to EQUAL widths and there is no adaptation to - // assume anything for: a pair that cannot be proven equal is rejected, as its - // resolved counterpart is. The resize the comparison would otherwise emit is - // not a semantics worth asserting, it is the silent truncation the equality - // rule exists to prevent. - import dfc.getSet - import DFXInt.Val.getActualWidthParam - val argIsWildcard = dfValArg.dfType.asIR.isDFInt32 || - CarryPromote.hasImplicitlyFromIntTag(dfValArg.asIR) - val dfTypeWidth = dfType.asIR.magnitudeWidthParamRef.get - val argWidth = dfValArg.getActualWidthParam(rhsWidthOpt) - if (argIsWildcard) - import IntParam.+ - // an unsigned wildcard gains the sign bit it needs under a signed receiver - val effectiveWidth = - if (dfType.signed.value && !rhsSigned) argWidth + 1 - else argWidth - wildcardFitCheck(dfTypeWidth, effectiveWidth) - else equalWidthCheck(dfTypeWidth, argWidth) - end if + // A permission on the argument covers the width relation, and only that: + // signedness is not a permission's to give, so it is still checked, by running + // the same check over widths that already agree. + val permitted = AutoConstraint.permitsWidthAdjust( + dfValArg, + dfType.asIR.magnitudeWidthParamRef.get + ) + if (permitted) + dfType.widthIntOpt.foreach(w => check(dfType.signed, w, rhsSigned, w)) + else + (dfType.widthIntOpt, rhsWidthOpt) match + case (Some(dfTypeW), Some(rhsW)) => + check(dfType.signed, dfTypeW, rhsSigned, rhsW) + case _ => + // A width is parametric, and what the comparison requires depends on the + // argument, exactly as the compile-time half decides it. A wildcard `Int` + // ADAPTS to the receiver, so it states the fit it needs; two bit-accurate + // operands are held to EQUAL widths, and a pair that cannot be proven equal + // is rejected, as its resolved counterpart is. + import DFXInt.Val.getActualWidthParam + val argIsWildcard = dfValArg.dfType.asIR.isDFInt32 || + CarryPromote.hasImplicitlyFromIntTag(dfValArg.asIR) + val dfTypeWidth = dfType.asIR.magnitudeWidthParamRef.get + val argWidth = dfValArg.getActualWidthParam(rhsWidthOpt) + if (argIsWildcard) + import IntParam.+ + // an unsigned wildcard gains the sign bit it needs under a signed receiver + val effectiveWidth = + if (dfType.signed.value && !rhsSigned) argWidth + 1 + else argWidth + wildcardFitCheck(dfTypeWidth, effectiveWidth) + else equalWidthCheck(dfTypeWidth, argWidth) + end if + end if case None => end match DFXInt.Val.Ops.toDFXIntOf(dfValArg)(dfType).asValTP[DFXInt[LS, LW, LN], RP] - end conv + end convArg // Check Verilog-semantics mismatch for comparisons: same trigger as // `/`, `%` -- a narrow non-carry chain mixed with an implicit Int on // either side widens to 32-bit in Verilog but not in DFHDL. @@ -1340,18 +1357,53 @@ object DFXInt: opv: ValueOf[Op], cv: ValueOf[C] ): DFValTP[DFBool, P | RP] = trydf: - val dfValArg = conv(dfVal.dfType, arg)(using dfc.anonymize) + // the operands are built anonymously, but the comparison itself is NOT: it takes the + // enclosing context, which is what names it after the binding it feeds + val anonDFC = dfc.anonymize import dfc.getSet + // A comparison names no target, so nothing about `a < b` says which operand adapts: the + // permission does, and the other operand's width is what it adapts to. That is why + // `a > b.extend` and `b.extend < a` are the same thing, and why two permissions in one + // comparison are a contradiction rather than a stronger request. + val argVal = ic(arg)(using anonDFC) + if ( + AutoConstraint.hasWidthAdjustPermission(dfVal) && + AutoConstraint.hasWidthAdjustPermission(argVal) + ) + throw new IllegalArgumentException( + """|Both operands of this operation carry a width adjustment permission. + |Only one operand may adapt, since the other supplies the width it adapts to.""".stripMargin + ) + // a wildcard `Int` argument has no width of its own for the receiver to adapt to, so it + // stays the operand that adapts + val argIsWildcard = argVal.dfType.asIR.isDFInt32 || + CarryPromote.hasImplicitlyFromIntTag(argVal.asIR) + val receiverAdapts = !argIsWildcard && + AutoConstraint.permitsWidthAdjust( + dfVal, + argVal.dfType.asIR.magnitudeWidthParamRef.get(using anonDFC) + )(using anonDFC) + val (lhsVal, dfValArg) = + if (receiverAdapts) + // the sign relation is still the comparison's to check, over widths that now agree + argVal.getActualSignedWidthOpt.foreach { (argSigned, _) => + argVal.widthIntOpt.foreach(w => check(dfVal.dfType.signed, w, argSigned, w)) + } + val adapted = DFXInt.Val.Ops.toDFXIntOf(dfVal)( + argVal.dfType.asInstanceOf[DFXInt[LS, Int, LN]] + )(using anonDFC) + (adapted.asValTP[DFXInt[LS, LW, LN], P], argVal.asValTP[DFXInt[LS, LW, LN], RP]) + else (dfVal, convArg(dfVal.dfType, argVal)(using anonDFC)) val op = opv.value op match case FuncOp.=== | FuncOp.=!= | FuncOp.< | FuncOp.> | FuncOp.<= | FuncOp.>= => - if CarryPromote.shouldWarnVerilogSemantics(dfVal.asIR, dfValArg.asIR) + if CarryPromote.shouldWarnVerilogSemantics(lhsVal.asIR, dfValArg.asIR) then dfc.logEvent( DFWarning(op.toString, CarryPromote.verilogSemanticsWarnMsg) ) case _ => - func(dfVal, dfValArg) + func(lhsVal, dfValArg) end apply end DFXIntCompare end Compare diff --git a/core/src/test/scala/CoreSpec/DFDecimalSpec.scala b/core/src/test/scala/CoreSpec/DFDecimalSpec.scala index 6b1fedbd9..16885e57b 100644 --- a/core/src/test/scala/CoreSpec/DFDecimalSpec.scala +++ b/core/src/test/scala/CoreSpec/DFDecimalSpec.scala @@ -1450,4 +1450,18 @@ class DFDecimalSpec extends DFSpec: w4 := w8.extend } } + // A comparison names no target, so nothing in `a < b` says which operand adapts. The permission + // says it, and the other operand's width is what it adapts to (the emitted forms are pinned in + // `PrintCodeStringSpec`). Two permissions in one comparison are a contradiction: each would be + // adapting to a width the other is still free to change. + test("Two width adjustment permissions in one comparison") { + assertRuntimeErrorLog( + """|Both operands of this operation carry a width adjustment permission. + |Only one operand may adapt, since the other supplies the width it adapts to.""".stripMargin + ) { + val v8 = UInt(8) <> VAR + val v4 = UInt(4) <> VAR + v8.truncate < v4.extend + } + } end DFDecimalSpec From ed3720a1b6ef5379b638f47033c7114c012baf1f Mon Sep 17 00:00:00 2001 From: Oron Port Date: Wed, 12 Aug 2026 07:57:29 +0300 Subject: [PATCH 27/40] core+docs: migrate the argument-less `.resize` to `.extend` and `.truncate` Every call site now names the direction it actually needs, chosen from the widths at that site rather than mechanically: a narrowing assignment or index becomes `.truncate`, a widening one `.extend`. The permission only covers its own direction, so a wrong choice would not compile, which makes the suite the check on all seventeen. `DFBits.Val.Ops` gains the candidate-based pair too, for the sites that adjust the width of something that is not yet a `Bits` value: the ALU resizes a comparison RESULT. The documentation follows, in the examples and in the Width Adjustment reference, which now describes the two as permissions rather than as an automatic adjustment, including what they say in a context with no designated target. `.resize` is untouched and still works; the one call site left on it is the test whose subject is that tag (issue #470). The diagnostics that recommend `.resize` are a separate change. Co-Authored-By: Claude Opus 5 (1M context) --- .../scala/StagesSpec/NamedSelectionSpec.scala | 8 +++---- .../StagesSpec/PrintCodeStringSpec.scala | 2 +- core/src/main/scala/dfhdl/core/DFBits.scala | 6 +++++ core/src/test/scala/CoreSpec/DFBitsSpec.scala | 8 +++---- .../test/scala/CoreSpec/DFDecimalSpec.scala | 4 ++-- .../test/scala/CoreSpec/DFVectorSpec.scala | 4 ++-- core/src/test/scala/RISCV/ALU.scala | 4 ++-- docs/transitioning/from-verilog/index.md | 6 ++--- docs/user-guide/loops/index.md | 2 +- docs/user-guide/type-system/index.md | 22 +++++++++---------- lib/src/test/scala/docExamples/ALU.scala | 4 ++-- 11 files changed, 38 insertions(+), 32 deletions(-) diff --git a/compiler/stages/src/test/scala/StagesSpec/NamedSelectionSpec.scala b/compiler/stages/src/test/scala/StagesSpec/NamedSelectionSpec.scala index 5d6c08a5b..a11ceca71 100644 --- a/compiler/stages/src/test/scala/StagesSpec/NamedSelectionSpec.scala +++ b/compiler/stages/src/test/scala/StagesSpec/NamedSelectionSpec.scala @@ -135,8 +135,8 @@ class NamedSelectionSpec extends StageSpec(stageCreatesUnrefAnons = true): test("Named selection with functions under system verilog") { class ID extends DFDesign: val x = UInt(6) <> IN - val y: UInt[5] <> VAL = (x min x).resize - val z: UInt[5] <> VAL = (x + x).resize + val y: UInt[5] <> VAL = (x min x).truncate + val z: UInt[5] <> VAL = (x + x).truncate val w: UInt[20] <> VAL = (x + x) + x val id = (new ID).verilogNamedSelection @@ -154,8 +154,8 @@ class NamedSelectionSpec extends StageSpec(stageCreatesUnrefAnons = true): given options.CompilerOptions.Backend = _.verilog.v95 class ID extends DFDesign: val x = UInt(6) <> IN - val y: UInt[5] <> VAL = (x min x).resize - val z: UInt[5] <> VAL = (x + x).resize + val y: UInt[5] <> VAL = (x min x).truncate + val z: UInt[5] <> VAL = (x + x).truncate val w: UInt[20] <> VAL = (x + x) + x val id = (new ID).verilogNamedSelection diff --git a/compiler/stages/src/test/scala/StagesSpec/PrintCodeStringSpec.scala b/compiler/stages/src/test/scala/StagesSpec/PrintCodeStringSpec.scala index 05e5c40b9..360e8c2cf 100644 --- a/compiler/stages/src/test/scala/StagesSpec/PrintCodeStringSpec.scala +++ b/compiler/stages/src/test/scala/StagesSpec/PrintCodeStringSpec.scala @@ -2164,7 +2164,7 @@ class PrintCodeStringSpec extends StageSpec(stageCreatesUnrefAnons = true): val font = Bar(data_width = data_width2) val col_index = UInt(8) <> VAR col_index := 0 - val x = font.dout(col_index.resize) + val x = font.dout(col_index.truncate) end Foo val top = (new Foo) assertCodeString( diff --git a/core/src/main/scala/dfhdl/core/DFBits.scala b/core/src/main/scala/dfhdl/core/DFBits.scala index a357bc53d..5300c57b5 100644 --- a/core/src/main/scala/dfhdl/core/DFBits.scala +++ b/core/src/main/scala/dfhdl/core/DFBits.scala @@ -780,6 +780,12 @@ object DFBits: extension [L <: DFValAny, LW <: IntP, LP](lhs: L)(using icL: Candidate.Aux[L, LW, LP]) def resize(using DFCG): DFValTP[DFBits[Int], icL.OutP] = icL(lhs).tag(ir.ResizeTag).asValTP[DFBits[Int], icL.OutP] + @targetName("extendDFBitsCandidate") + def extend(using DFCG): DFValTP[DFBits[Int], icL.OutP] = + icL(lhs).tag(ir.ExtendTag).asValTP[DFBits[Int], icL.OutP] + @targetName("truncateDFBitsCandidate") + def truncate(using DFCG): DFValTP[DFBits[Int], icL.OutP] = + icL(lhs).tag(ir.TruncateTag).asValTP[DFBits[Int], icL.OutP] def repeat[N <: IntP](num: IntParam[N])(using dfc: DFCG, check: Arg.Positive.CheckNUB[N] diff --git a/core/src/test/scala/CoreSpec/DFBitsSpec.scala b/core/src/test/scala/CoreSpec/DFBitsSpec.scala index dc3b75d84..2c11f0294 100644 --- a/core/src/test/scala/CoreSpec/DFBitsSpec.scala +++ b/core/src/test/scala/CoreSpec/DFBitsSpec.scala @@ -141,8 +141,8 @@ class DFBitsSpec extends DFSpec: b8 := ? b8 := u8 b8 := u8.bits - b8 := b3M.resize - b3M := b8.resize + b8 := b3M.extend + b3M := b8.truncate b8 := (h"1", 1, 0, b"11").toBits (b4M, b4L) := (h"1", 1, 0, b"11") (b3M, u5L) := (h"1", 1, 0, b"11") @@ -378,7 +378,7 @@ class DFBitsSpec extends DFSpec: )( """b8(u5)""" ) - val o5 = b8(u5.resize) + val o5 = b8(u5.truncate) val u2 = UInt(2) <> VAR assertCompileError( """|Expected argument width 3 but found: 2 @@ -387,7 +387,7 @@ class DFBitsSpec extends DFSpec: )( """b8(u2)""" ) - val o2 = b8(u2.resize) + val o2 = b8(u2.extend) } // A `Bit` value's `.bits` reaches an unbounded `Bits[Int] <> VAL` ascription (the accumulator // form of the elaboration-time concatenation idiom) through the implicit conversion. The diff --git a/core/src/test/scala/CoreSpec/DFDecimalSpec.scala b/core/src/test/scala/CoreSpec/DFDecimalSpec.scala index 16885e57b..22c047dc4 100644 --- a/core/src/test/scala/CoreSpec/DFDecimalSpec.scala +++ b/core/src/test/scala/CoreSpec/DFDecimalSpec.scala @@ -283,8 +283,8 @@ class DFDecimalSpec extends DFSpec: s8 := -127 s8 := u6 s8 := s6 - u6 := u8.resize - s6 := s8.resize + u6 := u8.truncate + s6 := s8.truncate u6 := u6 ^ u6 u6 := u6 & u6 u6 := u6 | u6 diff --git a/core/src/test/scala/CoreSpec/DFVectorSpec.scala b/core/src/test/scala/CoreSpec/DFVectorSpec.scala index fa35dff4a..e3a97d0b8 100644 --- a/core/src/test/scala/CoreSpec/DFVectorSpec.scala +++ b/core/src/test/scala/CoreSpec/DFVectorSpec.scala @@ -76,7 +76,7 @@ class DFVectorSpec extends DFSpec: )( """v1(i2)""" ) - val o2 = v1(i2.resize) + val o2 = v1(i2.extend) assertCompileError( """|Expected argument width 3 but found: 4 |To Fix: @@ -84,7 +84,7 @@ class DFVectorSpec extends DFSpec: )( """v1(i4)""" ) - val o4 = v1(i4.resize) + val o4 = v1(i4.truncate) assertCompileError( "The argument must be smaller than the upper-bound 5 but found: 5" )( diff --git a/core/src/test/scala/RISCV/ALU.scala b/core/src/test/scala/RISCV/ALU.scala index 2d6dee9a2..8887f38c4 100644 --- a/core/src/test/scala/RISCV/ALU.scala +++ b/core/src/test/scala/RISCV/ALU.scala @@ -17,8 +17,8 @@ class ALU extends DFDesign: case AND => op1 & op2 case OR => op1 | op2 case XOR => op1 ^ op2 - case SLT => (op1.sint < op2.sint).resize - case SLTU => (op1.uint < op2.uint).resize + case SLT => (op1.sint < op2.sint).extend + case SLTU => (op1.uint < op2.uint).extend case SLL => op1 << shamt case SRL => op1 >> shamt case SRA => (op1.sint >> shamt).bits diff --git a/docs/transitioning/from-verilog/index.md b/docs/transitioning/from-verilog/index.md index 6973704f8..7867fe69e 100644 --- a/docs/transitioning/from-verilog/index.md +++ b/docs/transitioning/from-verilog/index.md @@ -922,7 +922,7 @@ You do not have to apply the width and sign rules by hand while translating. Wri - Where DFHDL can reproduce Verilog's context-dependent width propagation, it does so silently. An anonymous `+`, `-`, `*`, unary `-`, `.sel`, `if`/`match` expression or shift feeding a wider target re-evaluates at that target's width, which is what the Verilog line does, so the transcription stands as written. - Where the two would disagree, DFHDL declines to guess. It reports a **compile-time or elaboration error** for a width or sign relation it cannot express (a narrower LHS under `-`, `/`, `%`, or a comparison between operands of different widths), and an **elaboration warning** where an implicit `Int` would have evaluated 32-bit in Verilog but is bit-accurate here. -So the loop is: transcribe, compile, apply whatever the diagnostic names (a carry operation, an explicit `d"W'V"` literal, or a `.resize`), and repeat until it is quiet. An expression that compiles and elaborates without warnings carries the original's semantics. +So the loop is: transcribe, compile, apply whatever the diagnostic names (a carry operation, an explicit `d"W'V"` literal, or a width adjustment), and repeat until it is quiet. An expression that compiles and elaborates without warnings carries the original's semantics. /// /// admonition | Arithmetic with Signed Values and Constants @@ -1056,7 +1056,7 @@ val result = UInt(8) <> OUT // Use carry ops to match Verilog's // overflow-free semantics -result <> ((a +^ b +^ c +^ d) / 4).resize +result <> ((a +^ b +^ c +^ d) / 4).truncate ``` @@ -1232,7 +1232,7 @@ end gate -**Difference from Verilog, and its limit:** Scala type-checks **both** branches, since both are ordinary Scala code. That only constrains you where the Scala type level actually tracks widths, which is when widths are **literal** (bounded): then both branches must be valid for every parameter value. When widths derive from `Int <> CONST` parameters the types are unbounded (`Bits[Int]`), Scala checks nothing about them, and the **elaboration-time** width check runs on the taken branch only. So a parameterized `generate if` whose branches are each valid only for their own parameter value translates directly, with no `.toScalaInt` and no `.resize` guard: +**Difference from Verilog, and its limit:** Scala type-checks **both** branches, since both are ordinary Scala code. That only constrains you where the Scala type level actually tracks widths, which is when widths are **literal** (bounded): then both branches must be valid for every parameter value. When widths derive from `Int <> CONST` parameters the types are unbounded (`Bits[Int]`), Scala checks nothing about them, and the **elaboration-time** width check runs on the taken branch only. So a parameterized `generate if` whose branches are each valid only for their own parameter value translates directly, with no `.toScalaInt` and no width-adjustment guard: ```scala class narrow( diff --git a/docs/user-guide/loops/index.md b/docs/user-guide/loops/index.md index 939db0840..a533dd708 100644 --- a/docs/user-guide/loops/index.md +++ b/docs/user-guide/loops/index.md @@ -88,7 +88,7 @@ class narrow_const extends EDDesign: dout <> din.msbits(2) // never elaborated, so never checked ``` -The same holds for a width that arrives as a design parameter (`class narrow(val WIDTH: Int <> CONST = 4)`), which is the usual case when translating a Verilog `parameter`. This is why a `generate if` whose branches are each valid only for their own parameter value translates directly, with no `.resize` guard and no `.toScalaInt`. If you do need both branches valid at the Scala level, use `.resize` or guard the index computations, as in the plain-`Int` example above. +The same holds for a width that arrives as a design parameter (`class narrow(val WIDTH: Int <> CONST = 4)`), which is the usual case when translating a Verilog `parameter`. This is why a `generate if` whose branches are each valid only for their own parameter value translates directly, with no `.resize` guard and no `.toScalaInt`. If you do need both branches valid at the Scala level, use a width adjustment (`.extend`, `.truncate` or `.resize(N)`) or guard the index computations, as in the plain-`Int` example above. The ascription has a second, visible consequence: an `Int <> CONST` survives into the generated HDL as a `localparam`, while a plain Scala `Int` is inlined away. See [`localparam`][localparam] for that side of the same distinction. diff --git a/docs/user-guide/type-system/index.md b/docs/user-guide/type-system/index.md index cf779fd00..0034b6f3e 100755 --- a/docs/user-guide/type-system/index.md +++ b/docs/user-guide/type-system/index.md @@ -1990,7 +1990,7 @@ val dynbit = b8(idx) // Bit at position idx /// admonition | Dynamic bit indexing type: tip -You can index into a bit-vector value using a `UInt` variable, not just integer literals. The index must be a `UInt` whose width equals `clog2(bits_width)`. For example, indexing into `Bits(8)` requires a `UInt(3)` index. If the width does not match, the compiler will report an error and suggest using `.resize` to automatically adjust the width. +You can index into a bit-vector value using a `UInt` variable, not just integer literals. The index must be a `UInt` whose width equals `clog2(bits_width)`. For example, indexing into `Bits(8)` requires a `UInt(3)` index. If the width does not match, the compiler reports an error and points at the width adjustment that would reconcile it. Dynamic indexing works for both reads and writes: ```scala @@ -2004,20 +2004,20 @@ process(clk): data(pos) :== din // dynamic write ``` -When the index variable is wider or narrower than needed, use `.resize` to automatically adjust it to the required width: +When the index variable is wider or narrower than needed, `.truncate` and `.extend` adjust it to the required width, each permitting the one direction it names: ```scala val data = Bits(8) <> VAR init all(0) val pos = UInt(4) <> VAR init 0 // 4-bit, but Bits(8) needs UInt(3) -val bit_out = data(pos.resize) // .resize adjusts to UInt(3) automatically +val bit_out = data(pos.truncate) // .truncate narrows the 4-bit index to UInt(3) ``` -The same `.resize` trick applies to **any** dynamic index, including writes into a memory/vector when the index comes from a wider source such as a slice of a larger `UInt`. The index width is checked against `clog2` of the indexed size, so let `.resize` reconcile it: +The same applies to **any** dynamic index, including writes into a memory/vector when the index comes from a wider source such as a slice of a larger `UInt`. The index width is checked against `clog2` of the indexed size, so let the permission reconcile it: ```scala val mem = Bits(8) X 16 <> VAR // 16-deep memory, needs a UInt(4) index val idx = UInt(8) <> IN // wider index source (e.g. a sliced address) process(clk): if (clk.rising) - mem(idx.resize) :== din // .resize adjusts idx to UInt(4) for the write + mem(idx.truncate) :== din // .truncate narrows idx to UInt(4) for the write ``` /// @@ -2026,23 +2026,23 @@ process(clk): Applies to: `Bits`, `UInt`, `SInt` - `.resize(N)` sets the width to exactly `N` bits. For `UInt` and `Bits`, widening zero-extends; for `SInt`, widening sign-extends. Narrowing truncates the most-significant bits. -- `.resize` (no argument) automatically adjusts the width to match the assignment or operation context; narrowing or widening as needed. +- `.extend` and `.truncate` take no width. They are PERMISSIONS to adjust the width to whatever the context decides, each covering ONE direction: `.extend` may widen, `.truncate` may narrow. Where the permitted direction does not apply the permission contributes nothing and the ordinary width rule decides, so it is never a silent adjustment in the direction you did not ask for. In a context with no designated target, such as a comparison, the permission is also what says WHICH operand adapts, the other operand's width being what it adapts to. - `.eby(K)` extends the width by `K` bits, *relative* to the current width; sugar for `.resize(width + K)`. `K` must be positive, so `.eby` always widens (zero-extension for `UInt` and `Bits`, sign-extension for `SInt`). This is the **canonical widening spelling**: elaboration prints any widening whose delta is a known number of bits in this relative form (a user-written `x.resize(9)` over an 8-bit `x` prints back as `x.eby(1)` -- the two produce identical designs). It is also the form that scales to parametric widths, where the absolute spelling would repeat the symbolic expression: `x.eby(1)` instead of `x.resize(W + 1)`. Absolute `.resize` remains the spelling for narrowing and for widths given by a named parameter. ```scala val b8 = Bits(8) <> VAR val b4 = Bits(4) <> VAR b4 := b8.resize(4) // explicit narrow to 4 bits -b8 := b4.resize // auto-widen to match b8's width +b8 := b4.extend // widen to match b8's width val u8 = UInt(8) <> VAR val u6 = UInt(6) <> VAR -u6 := u8.resize // auto-narrow to match u6's width +u6 := u8.truncate // narrow to match u6's width u8 := u6.resize(8) // explicit zero-extend to 8 bits val s8 = SInt(8) <> VAR val s4 = SInt(4) <> VAR -s8 := s4.resize // sign-extend to match s8's width +s8 := s4.extend // sign-extend to match s8's width s4 := s8.resize(4) // explicit narrow to 4 bits // relative widening, most useful with parametric widths @@ -2576,7 +2576,7 @@ u9 := sum // extended by 1: sum.eby(1) // SInt(W + 1) target: dx := c.sel(b -^ a, a -^ b) ``` -A parametric width relation is accepted when it holds for **every valid parameter assignment**, using the fact that widths are positive: `SInt(2 * W)` accepts a `W`-wide operation because `2 * W >= W` for any valid `W`. A relation that a valid assignment can violate is definitively rejected (`SInt(W)` never fits a `2 * W`-wide value), and an undecidable one (e.g. a literal target such as `SInt(16)` against a free `W`, which may exceed 16) is conservatively rejected as well; both still require an explicit carry op or `.resize` to state the intent. +A parametric width relation is accepted when it holds for **every valid parameter assignment**, using the fact that widths are positive: `SInt(2 * W)` accepts a `W`-wide operation because `2 * W >= W` for any valid `W`. A relation that a valid assignment can violate is definitively rejected (`SInt(W)` never fits a `2 * W`-wide value), and an undecidable one (e.g. a literal target such as `SInt(16)` against a free `W`, which may exceed 16) is conservatively rejected as well; both still require an explicit carry op, a `.resize(N)` or an `.extend` to state the intent. /// /// admonition | Implicit Scala `Int` and Verilog-semantics mismatch @@ -2781,7 +2781,7 @@ val c2 = s8 > s4.eby(4) // Boolean: s4 sign-extended to 8 bits val c3 = u8.eby(2) > 1000 // Boolean: 1000 needs 10 bits, so u8 widens to meet it ``` -Widening preserves the value in both signednesses (`.eby`/`.resize` zero-extend a `UInt` and sign-extend an `SInt`), so the comparison still asks what you meant. Narrowing the wider operand does not, and nothing flags it, because the resulting widths do match: +Widening preserves the value in both signednesses (`.eby`, `.extend` and `.resize` zero-extend a `UInt` and sign-extend an `SInt`), so the comparison still asks what you meant. Narrowing the wider operand does not, and nothing flags it, because the resulting widths do match: ```scala // compiles, but u8 is TRUNCATED to its low 4 bits, so this asks a different question: diff --git a/lib/src/test/scala/docExamples/ALU.scala b/lib/src/test/scala/docExamples/ALU.scala index 873edf7db..0dd0bec8c 100644 --- a/lib/src/test/scala/docExamples/ALU.scala +++ b/lib/src/test/scala/docExamples/ALU.scala @@ -20,8 +20,8 @@ class ALU extends DFDesign: case AND => op1 & op2 case OR => op1 | op2 case XOR => op1 ^ op2 - case SLT => (op1.sint < op2.sint).resize - case SLTU => (op1.uint < op2.uint).resize + case SLT => (op1.sint < op2.sint).extend + case SLTU => (op1.uint < op2.uint).extend case SLL => op1 << shamt case SRL => op1 >> shamt case SRA => (op1.sint >> shamt).bits From f00dc86c57eb6cf6352cbaefbb917cd59a7e26bd Mon Sep 17 00:00:00 2001 From: Oron Port Date: Wed, 12 Aug 2026 07:59:10 +0300 Subject: [PATCH 28/40] platforms: bump to the `.truncate` migration Picks up platforms e43dae7, which moves `Digit.scala`'s two memory-index adjustments off the argument-less `.resize`. Kept out of ed3720a1b because a submodule's contents cannot ride in a parent commit. Co-Authored-By: Claude Opus 5 (1M context) --- platforms | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/platforms b/platforms index 8ee7cfc1a..e43dae7f3 160000 --- a/platforms +++ b/platforms @@ -1 +1 @@ -Subproject commit 8ee7cfc1a62a588dab85a19398a34c6a8086cd29 +Subproject commit e43dae7f368bfd7e556595dc050e26c9db1e642a From c689fc81b3d66662098c969b33148cbe606bfc5b Mon Sep 17 00:00:00 2001 From: Oron Port Date: Wed, 12 Aug 2026 08:26:32 +0300 Subject: [PATCH 29/40] core+docs: width diagnostics name the adjustment that actually applies They all recommended `.resize`, which is now one of four spellings and the only one that says nothing about direction. A width check knows which way the mismatch goes, so it can say: The argument width (12) is different than the receiver width (8). Consider `.truncate` to narrow it to the receiver width, or `.resize(8)` to state the width explicitly. The two compile-time checks branch at the type level, having both widths as literals. Their elaboration-time twin cannot, since a parametric width leaves the direction open, so it offers both. The subtraction diagnostic names only `.truncate`: nothing there can be fixed by widening, and pointing at `.extend` would send the reader down a dead end. Fourteen pinned expectations follow. That the parametric cases in `ElaborationChecksSpec` all take the direction-agnostic form, and the literal ones do not, is itself the check that the two variants land where they should. Co-Authored-By: Claude Opus 5 (1M context) --- core/src/main/scala/dfhdl/core/DFBits.scala | 12 +++++++++--- core/src/main/scala/dfhdl/core/DFDecimal.scala | 9 +++++++-- core/src/test/scala/CoreSpec/DFBitsSpec.scala | 12 ++++++------ core/src/test/scala/CoreSpec/DFDecimalSpec.scala | 2 +- core/src/test/scala/CoreSpec/DFVectorSpec.scala | 4 ++-- docs/user-guide/loops/index.md | 2 +- lib/src/test/scala/ContextWidenSpec.scala | 2 +- lib/src/test/scala/ElaborationChecksSpec.scala | 10 +++++----- 8 files changed, 32 insertions(+), 21 deletions(-) diff --git a/core/src/main/scala/dfhdl/core/DFBits.scala b/core/src/main/scala/dfhdl/core/DFBits.scala index 5300c57b5..b607d59bb 100644 --- a/core/src/main/scala/dfhdl/core/DFBits.scala +++ b/core/src/main/scala/dfhdl/core/DFBits.scala @@ -467,8 +467,14 @@ object DFBits: Int, [LW <: Int, RW <: Int] =>> LW == RW, [LW <: Int, RW <: Int] =>> "The argument width (" + ToString[RW] + - ") is different than the receiver width (" + ToString[LW] + - ").\nConsider applying `.resize` to resolve this issue." + ") is different than the receiver width (" + ToString[LW] + ").\n" + + ITE[ + RW > LW, + "Consider `.truncate` to narrow it to the receiver width, or `.resize(" + + ToString[LW] + ")` to state the width explicitly.", + "Consider `.extend` to widen it to the receiver width, or `.resize(" + + ToString[LW] + ")` to state the width explicitly." + ] ] given DFBitsFromCandidate[LW <: IntP, V, RP, IC <: Candidate[V]](using ic: IC { type OutP = RP } @@ -488,7 +494,7 @@ object DFBits: if (dfType.compareWidths(dfVal.dfType)(_ != _).getOrElse(true)) throw new IllegalArgumentException( s"""|The argument width (${dfVal.dfType.widthErrorString}) is different than the receiver width (${dfType.widthErrorString}). - |Consider applying `.resize` to resolve this issue.""".stripMargin + |Consider `.extend` or `.truncate` to adjust it to the receiver width, or `.resize(width)` to state the width explicitly.""".stripMargin ) dfVal.nameInDFCPosition.asValTP[DFBits[LW], RP] end if diff --git a/core/src/main/scala/dfhdl/core/DFDecimal.scala b/core/src/main/scala/dfhdl/core/DFDecimal.scala index bffd09bb0..203b2b316 100644 --- a/core/src/main/scala/dfhdl/core/DFDecimal.scala +++ b/core/src/main/scala/dfhdl/core/DFDecimal.scala @@ -1975,7 +1975,7 @@ object DFXInt: throw new IllegalArgumentException( s"""|The RHS value width (${rhsIR.magnitudeWidthParamRef.refErrorString}) is not provably within the LHS variable width (${lhsIR.magnitudeWidthParamRef.refErrorString}). |Subtraction takes the LHS width, so the difference may not fit. - |Consider applying the carry subtraction `-^` or `.resize` to resolve this issue.""".stripMargin + |Consider the carry subtraction `-^`, or `.truncate` to narrow the RHS to the LHS width.""".stripMargin ) end if arithOp(lhsVal.dfType, op.value, lhsVal, rhsVal).asInstanceOf[Out] @@ -2219,7 +2219,12 @@ object DFUInt: Int, [UBW <: Int, RW <: Int] =>> UBW == RW, [UBW <: Int, RW <: Int] =>> "Expected argument width " + UBW + " but found: " + RW + - "\nTo Fix:\nUse `.resize` to match the width automatically." + "\nTo Fix:\n" + + ITE[ + RW > UBW, + "Use `.truncate` to narrow the argument to the expected width.", + "Use `.extend` to widen the argument to the expected width." + ] ] object Val: diff --git a/core/src/test/scala/CoreSpec/DFBitsSpec.scala b/core/src/test/scala/CoreSpec/DFBitsSpec.scala index 2c11f0294..7c7b8d839 100644 --- a/core/src/test/scala/CoreSpec/DFBitsSpec.scala +++ b/core/src/test/scala/CoreSpec/DFBitsSpec.scala @@ -154,7 +154,7 @@ class DFBitsSpec extends DFSpec: val v12 = Bits(twelve) <> VAR assertDSLErrorLog( """|The argument width (12) is different than the receiver width (8). - |Consider applying `.resize` to resolve this issue.""".stripMargin + |Consider `.truncate` to narrow it to the receiver width, or `.resize(8)` to state the width explicitly.""".stripMargin )( """b8 := h"123"""" ) { @@ -162,7 +162,7 @@ class DFBitsSpec extends DFSpec: } assertDSLErrorLog( """|The argument width (12) is different than the receiver width (8). - |Consider applying `.resize` to resolve this issue.""".stripMargin + |Consider `.truncate` to narrow it to the receiver width, or `.resize(8)` to state the width explicitly.""".stripMargin )( """val conv8: Bits[8] <> VAL = h"123"""" ) { @@ -374,7 +374,7 @@ class DFBitsSpec extends DFSpec: assertCompileError( """|Expected argument width 3 but found: 5 |To Fix: - |Use `.resize` to match the width automatically.""".stripMargin + |Use `.truncate` to narrow the argument to the expected width.""".stripMargin )( """b8(u5)""" ) @@ -383,7 +383,7 @@ class DFBitsSpec extends DFSpec: assertCompileError( """|Expected argument width 3 but found: 2 |To Fix: - |Use `.resize` to match the width automatically.""".stripMargin + |Use `.extend` to widen the argument to the expected width.""".stripMargin )( """b8(u2)""" ) @@ -421,7 +421,7 @@ class DFBitsSpec extends DFSpec: } assertRuntimeErrorLog( """|The argument width (8) is different than the receiver width (4). - |Consider applying `.resize` to resolve this issue.""".stripMargin + |Consider `.truncate` to narrow it to the receiver width, or `.resize(4)` to state the width explicitly.""".stripMargin ) { val c8 = Bits(8) <> VAR val c4 = Bits(4) <> VAR @@ -429,7 +429,7 @@ class DFBitsSpec extends DFSpec: } assertRuntimeErrorLog( """|The argument width (4) is different than the receiver width (8). - |Consider applying `.resize` to resolve this issue.""".stripMargin + |Consider `.extend` to widen it to the receiver width, or `.resize(8)` to state the width explicitly.""".stripMargin ) { val d8 = Bits(8) <> VAR val d4 = Bits(4) <> VAR diff --git a/core/src/test/scala/CoreSpec/DFDecimalSpec.scala b/core/src/test/scala/CoreSpec/DFDecimalSpec.scala index 22c047dc4..7f1da2360 100644 --- a/core/src/test/scala/CoreSpec/DFDecimalSpec.scala +++ b/core/src/test/scala/CoreSpec/DFDecimalSpec.scala @@ -1349,7 +1349,7 @@ class DFDecimalSpec extends DFSpec: val err4 = compiletime.testing.typeCheckErrors("val x: Bits[8] <> VAL = cnt + 10000").last val widthErr = """|The argument width (14) is different than the receiver width (8). - |Consider applying `.resize` to resolve this issue.""".stripMargin + |Consider `.truncate` to narrow it to the receiver width, or `.resize(8)` to state the width explicitly.""".stripMargin assertEquals(err1.message, widthErr) assertEquals(err1.column, 7) assertEquals(err2.message, widthErr) diff --git a/core/src/test/scala/CoreSpec/DFVectorSpec.scala b/core/src/test/scala/CoreSpec/DFVectorSpec.scala index e3a97d0b8..5d055a270 100644 --- a/core/src/test/scala/CoreSpec/DFVectorSpec.scala +++ b/core/src/test/scala/CoreSpec/DFVectorSpec.scala @@ -72,7 +72,7 @@ class DFVectorSpec extends DFSpec: assertCompileError( """|Expected argument width 3 but found: 2 |To Fix: - |Use `.resize` to match the width automatically.""".stripMargin + |Use `.extend` to widen the argument to the expected width.""".stripMargin )( """v1(i2)""" ) @@ -80,7 +80,7 @@ class DFVectorSpec extends DFSpec: assertCompileError( """|Expected argument width 3 but found: 4 |To Fix: - |Use `.resize` to match the width automatically.""".stripMargin + |Use `.truncate` to narrow the argument to the expected width.""".stripMargin )( """v1(i4)""" ) diff --git a/docs/user-guide/loops/index.md b/docs/user-guide/loops/index.md index a533dd708..94ca5118d 100644 --- a/docs/user-guide/loops/index.md +++ b/docs/user-guide/loops/index.md @@ -72,7 +72,7 @@ class narrow_lit extends EDDesign: ``` The argument width (2) is different than the receiver width (4). -Consider applying `.resize` to resolve this issue. +Consider `.extend` to widen it to the receiver width, or `.resize(4)` to state the width explicitly. ``` **An `Int <> CONST`** gives `Bits(WIDTH)` the unbounded type `Bits[Int]`, which the Scala type level does not track, so there is nothing for it to reject. The width check moves to elaboration, and elaboration only ever visits the taken branch: diff --git a/lib/src/test/scala/ContextWidenSpec.scala b/lib/src/test/scala/ContextWidenSpec.scala index 709382654..a384359f8 100644 --- a/lib/src/test/scala/ContextWidenSpec.scala +++ b/lib/src/test/scala/ContextWidenSpec.scala @@ -14,7 +14,7 @@ package dfhdl * The width-fit acceptance itself is proof-backed (issue dfhdl_by_agents#116): a parametric * relation such as `2 * W >= W` is accepted because it holds for every valid (positive) width * assignment, while a relation that a valid assignment can violate (e.g. `16 >= W`) still requires - * an explicit carry op or `.resize` (see `ElaborationChecksSpec` for the rejections). + * an explicit carry op or a width adjustment (see `ElaborationChecksSpec` for the rejections). */ class ContextWidenSpec extends DesignSpec: test("parametric target-context widening") { diff --git a/lib/src/test/scala/ElaborationChecksSpec.scala b/lib/src/test/scala/ElaborationChecksSpec.scala index c93b3a085..1c198f4df 100644 --- a/lib/src/test/scala/ElaborationChecksSpec.scala +++ b/lib/src/test/scala/ElaborationChecksSpec.scala @@ -578,7 +578,7 @@ class ElaborationChecksSpec extends DesignSpec: |Hierarchy: Foo |Operation: `apply` |Message: The argument width (WIDTH2) is different than the receiver width (WIDTH1). - |Consider applying `.resize` to resolve this issue. + |Consider `.extend` or `.truncate` to adjust it to the receiver width, or `.resize(width)` to state the width explicitly. | |DFiant HDL elaboration error! |Position: ${currentFilePos}ElaborationChecksSpec.scala:571:17 - 571:23 @@ -1415,14 +1415,14 @@ class ElaborationChecksSpec extends DesignSpec: |Hierarchy: Parent |Operation: `apply` |Message: The argument width (c.OUTPUT_WIDTH) is different than the receiver width (OUTPUT_WIDTH). - |Consider applying `.resize` to resolve this issue. + |Consider `.extend` or `.truncate` to adjust it to the receiver width, or `.resize(width)` to state the width explicitly. | |DFiant HDL elaboration error! |Position: ${currentFilePos}ElaborationChecksSpec.scala:1408:9 - 1408:17 |Hierarchy: Parent |Operation: `apply` |Message: The argument width (OUTPUT_WIDTH) is different than the receiver width (c.OUTPUT_WIDTH). - |Consider applying `.resize` to resolve this issue.""".stripMargin + |Consider `.extend` or `.truncate` to adjust it to the receiver width, or `.resize(width)` to state the width explicitly.""".stripMargin ) test("same-named design parameters are qualified in width errors"): @@ -1444,14 +1444,14 @@ class ElaborationChecksSpec extends DesignSpec: |Hierarchy: Parent |Operation: `apply` |Message: The argument width (c.W) is different than the receiver width (W). - |Consider applying `.resize` to resolve this issue. + |Consider `.extend` or `.truncate` to adjust it to the receiver width, or `.resize(width)` to state the width explicitly. | |DFiant HDL elaboration error! |Position: ${currentFilePos}ElaborationChecksSpec.scala:1437:9 - 1437:17 |Hierarchy: Parent |Operation: `apply` |Message: The argument width (W) is different than the receiver width (c.W). - |Consider applying `.resize` to resolve this issue.""".stripMargin + |Consider `.extend` or `.truncate` to adjust it to the receiver width, or `.resize(width)` to state the width explicitly.""".stripMargin ) test("Verilog-semantics warning with parametric widths"): From defb89cd0b1bec3da65bc449fe273f28823b5eff Mon Sep 17 00:00:00 2001 From: Oron Port Date: Wed, 12 Aug 2026 08:47:25 +0300 Subject: [PATCH 30/40] core: a width-adjustment permission survives the `Bits`/integer conversion Two candidate conversions carried `ResizeTag` across the conversion but not the two new tags, so a permission was silently dropped whenever a value crossed between `Bits` and an integer type. The failure was self-refuting rather than subtle: `b8 := u4.extend` reported the width mismatch and recommended `.extend`, which was already written. A permission is about the VALUE's width, and this conversion leaves that width alone, so it has to survive. `AutoConstraint.carryWidthAdjustPermission` is now the one place that says so, and both conversions use it. The reverse direction hid the same hole: a numeric assignment extends on its own, so a lost `.extend` there changes nothing, and only a narrowing exposes it. Both directions are pinned. Co-Authored-By: Claude Opus 5 (1M context) --- .../main/scala/dfhdl/core/AutoConstraint.scala | 16 ++++++++++++++++ core/src/main/scala/dfhdl/core/DFBits.scala | 3 +-- core/src/main/scala/dfhdl/core/DFDecimal.scala | 5 +---- core/src/test/scala/CoreSpec/DFBitsSpec.scala | 13 +++++++++++++ core/src/test/scala/CoreSpec/DFDecimalSpec.scala | 9 +++++++++ 5 files changed, 40 insertions(+), 6 deletions(-) diff --git a/core/src/main/scala/dfhdl/core/AutoConstraint.scala b/core/src/main/scala/dfhdl/core/AutoConstraint.scala index 562a6aec7..a30177497 100644 --- a/core/src/main/scala/dfhdl/core/AutoConstraint.scala +++ b/core/src/main/scala/dfhdl/core/AutoConstraint.scala @@ -83,6 +83,22 @@ object AutoConstraint: def hasWidthAdjustPermission(value: DFValAny)(using DFC): Boolean = value.hasTag[ir.ResizeTag] || value.hasTag[ir.ExtendTag] || value.hasTag[ir.TruncateTag] + /** Carries `from`'s width-adjustment permission, if it has one, onto `to`. + * + * A permission is about the VALUE's width, and converting between `Bits` and an integer type + * leaves that width alone, so the permission has to survive the conversion. Dropping it makes + * the diagnostic absurd rather than merely unhelpful: `b8 := u4.extend` would report the width + * mismatch and recommend the `.extend` that is already written. + */ + def carryWidthAdjustPermission[T <: DFTypeAny, M <: ModifierAny]( + from: DFValAny, + to: DFVal[T, M] + )(using DFC): DFVal[T, M] = + if (from.hasTag[ir.ResizeTag]) to.tag(ir.ResizeTag) + else if (from.hasTag[ir.ExtendTag]) to.tag(ir.ExtendTag) + else if (from.hasTag[ir.TruncateTag]) to.tag(ir.TruncateTag) + else to + /** Whether a width-adjustment permission carried by `value` (see `ir.ExtendTag`) covers adjusting * it to `targetWidth`. * diff --git a/core/src/main/scala/dfhdl/core/DFBits.scala b/core/src/main/scala/dfhdl/core/DFBits.scala index b607d59bb..499b041e2 100644 --- a/core/src/main/scala/dfhdl/core/DFBits.scala +++ b/core/src/main/scala/dfhdl/core/DFBits.scala @@ -398,8 +398,7 @@ object DFBits: type OutP = P def apply(value: R)(using DFC): Out = import DFVal.Ops.bits - if (value.hasTag[ir.ResizeTag]) value.bits.tag(ir.ResizeTag) - else value.bits + AutoConstraint.carryWidthAdjustPermission(value, value.bits) transparent inline given errDFEncoding[E <: DFEncoding]: Candidate[E] = compiletime.error( "Cannot apply an enum entry value to a bits variable." diff --git a/core/src/main/scala/dfhdl/core/DFDecimal.scala b/core/src/main/scala/dfhdl/core/DFDecimal.scala index 203b2b316..eebe5bf33 100644 --- a/core/src/main/scala/dfhdl/core/DFDecimal.scala +++ b/core/src/main/scala/dfhdl/core/DFDecimal.scala @@ -1099,10 +1099,7 @@ object DFXInt: def apply(arg: R)(using dfc: DFC): Out = import DFBits.Val.Ops.uint val dfVal = ic(arg)(using dfc.anonymize) - val ret = - if (dfVal.hasTag[ir.ResizeTag]) - dfVal.uint.tag(ir.ResizeTag) - else dfVal.uint + val ret = AutoConstraint.carryWidthAdjustPermission(dfVal, dfVal.uint) ret.asValTP[DFXInt[OutS, OutW, OutN], OutP] end fromDFBitsValCandidate end CandidateLP diff --git a/core/src/test/scala/CoreSpec/DFBitsSpec.scala b/core/src/test/scala/CoreSpec/DFBitsSpec.scala index 7c7b8d839..2059f937b 100644 --- a/core/src/test/scala/CoreSpec/DFBitsSpec.scala +++ b/core/src/test/scala/CoreSpec/DFBitsSpec.scala @@ -411,6 +411,8 @@ class DFBitsSpec extends DFSpec: test("Width adjustment permissions") { val b8 = Bits(8) <> VAR val b4 = Bits(4) <> VAR + val u8x = UInt(8) <> VAR + val u4x = UInt(4) <> VAR assertCodeString { """|b4 := b8.resize(4) |b8 := b4.eby(4) @@ -419,6 +421,17 @@ class DFBitsSpec extends DFSpec: b4 := b8.truncate b8 := b4.extend } + // a permission survives the conversion between `Bits` and an integer type: the conversion + // leaves the width it speaks about alone, so dropping it would report a width mismatch and + // then recommend the adjustment that is already written + assertCodeString { + """|b8 := u4x.bits.eby(4) + |b4 := u8x.bits.resize(4) + |""".stripMargin + } { + b8 := u4x.extend + b4 := u8x.truncate + } assertRuntimeErrorLog( """|The argument width (8) is different than the receiver width (4). |Consider `.truncate` to narrow it to the receiver width, or `.resize(4)` to state the width explicitly.""".stripMargin diff --git a/core/src/test/scala/CoreSpec/DFDecimalSpec.scala b/core/src/test/scala/CoreSpec/DFDecimalSpec.scala index 7f1da2360..605604be9 100644 --- a/core/src/test/scala/CoreSpec/DFDecimalSpec.scala +++ b/core/src/test/scala/CoreSpec/DFDecimalSpec.scala @@ -1410,6 +1410,7 @@ class DFDecimalSpec extends DFSpec: val u4 = UInt(4) <> VAR val s8 = SInt(8) <> VAR val s4 = SInt(4) <> VAR + val b8x = Bits(8) <> VAR // a widening prints in the relative `.eby` form, a narrowing in the absolute one assertCodeString { """|u4 := u8.resize(4) @@ -1421,6 +1422,14 @@ class DFDecimalSpec extends DFSpec: u8 := u4.extend s4 := s8.truncate } + // the permission survives the conversion from `Bits`, where it is load-bearing: a numeric + // assignment extends on its own, so only the narrowing needs saying + assertCodeString { + """|u4 := b8x.uint.resize(4) + |""".stripMargin + } { + u4 := b8x.truncate + } // an assignment names its target, so it already extends a narrower value: both permissions // elaborate to exactly what the untagged assignment does assertCodeString { From 4f3d3743f557dbd20b5a15f257d122a2d3c27372 Mon Sep 17 00:00:00 2001 From: Oron Port Date: Wed, 12 Aug 2026 09:15:37 +0300 Subject: [PATCH 31/40] core: deprecate the argument-less `.resize` It permits both widening and truncation under one spelling, so the code never records which was meant and no check can be made of the direction the author did not intend. `.extend` and `.truncate` each permit one, and `.resize(width)` states the width outright. Annotation only: the implementation is untouched and existing code keeps working, warned rather than broken. The elaboration error is a separate decision. The last caller moves with it. That test pins a permission landing on the local representative of a sub-design's port rather than on the foreign declaration (issue #470), which is a property of the tag mechanism and not of `.resize` in particular, so `.truncate` serves it just as well. With it migrated the whole build compiles free of deprecation warnings, which is the check that the earlier migration missed nothing. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/test/scala/StagesSpec/PrintCodeStringSpec.scala | 8 ++++---- core/src/main/scala/dfhdl/core/DFBits.scala | 8 ++++++++ core/src/main/scala/dfhdl/core/DFDecimal.scala | 4 ++++ 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/compiler/stages/src/test/scala/StagesSpec/PrintCodeStringSpec.scala b/compiler/stages/src/test/scala/StagesSpec/PrintCodeStringSpec.scala index 360e8c2cf..22c2fa3dd 100644 --- a/compiler/stages/src/test/scala/StagesSpec/PrintCodeStringSpec.scala +++ b/compiler/stages/src/test/scala/StagesSpec/PrintCodeStringSpec.scala @@ -3156,11 +3156,11 @@ class PrintCodeStringSpec extends StageSpec(stageCreatesUnrefAnons = true): // represents the port by a `PortByNameSelect`, which is what a reference to it materializes, // so an in-place member revision applied to the port from here has nowhere to land on the // foreign declaration and must target the representative instead. - // The argument-less `.resize` is such a revision: it marks its operand with a tag that the + // A width-adjustment permission is such a revision: it marks its operand with a tag that the // connection's width then resolves. This pins that the mark lands locally, giving the same // result as the explicit-width `.resize(16)` form. // See https://github.com/DFiantHDL/DFHDL/issues/470 - test("Argument-less resize of a sub-design instance's output port") { + test("Width adjustment permission on a sub-design instance's output port") { class SubDsn extends EDDesign: val WIDTH: Int <> CONST = 24 val ob = Bits(WIDTH) <> OUT @@ -3171,8 +3171,8 @@ class PrintCodeStringSpec extends StageSpec(stageCreatesUnrefAnons = true): val pb = Bits(16) <> OUT val pu = UInt(16) <> OUT val c = SubDsn() - pb <> c.ob.resize - pu <> c.ou.resize + pb <> c.ob.truncate + pu <> c.ou.truncate assertCodeString( Top(), """|class SubDsn extends EDDesign: diff --git a/core/src/main/scala/dfhdl/core/DFBits.scala b/core/src/main/scala/dfhdl/core/DFBits.scala index 499b041e2..c0f1daeea 100644 --- a/core/src/main/scala/dfhdl/core/DFBits.scala +++ b/core/src/main/scala/dfhdl/core/DFBits.scala @@ -747,6 +747,10 @@ object DFBits: // if (lhs.width == updatedWidth) lhs.asValOf[DFBits[RW]] // else DFVal.Alias.AsIs(DFBits(updatedWidth), lhs) + @deprecated( + "Permits both widening and truncation, so it does not say which was meant. Use `.extend` or `.truncate` for the direction you intend, or `.resize(width)` to state the width.", + "0.23.0" + ) def resize(using DFCG): DFValTP[DFBits[Int], P] = lhs.tag(ir.ResizeTag).asValTP[DFBits[Int], P] // permission to adjust the width in ONE direction, taken up by the context that decides @@ -783,6 +787,10 @@ object DFBits: iter.map(_.widthIntParam.asInstanceOf[IntParam[Int]]).reduce(_ + _) DFVal.Func(DFBits(width), FuncOp.++, iter.toList) extension [L <: DFValAny, LW <: IntP, LP](lhs: L)(using icL: Candidate.Aux[L, LW, LP]) + @deprecated( + "Permits both widening and truncation, so it does not say which was meant. Use `.extend` or `.truncate` for the direction you intend, or `.resize(width)` to state the width.", + "0.23.0" + ) def resize(using DFCG): DFValTP[DFBits[Int], icL.OutP] = icL(lhs).tag(ir.ResizeTag).asValTP[DFBits[Int], icL.OutP] @targetName("extendDFBitsCandidate") diff --git a/core/src/main/scala/dfhdl/core/DFDecimal.scala b/core/src/main/scala/dfhdl/core/DFDecimal.scala index eebe5bf33..29c4420df 100644 --- a/core/src/main/scala/dfhdl/core/DFDecimal.scala +++ b/core/src/main/scala/dfhdl/core/DFDecimal.scala @@ -1586,6 +1586,10 @@ object DFXInt: end extension extension [S <: Boolean, W <: IntP, N <: NativeType, P](lhs: DFValTP[DFXInt[S, W, N], P]) @targetName("resizeDFXIntAuto") + @deprecated( + "Permits both widening and truncation, so it does not say which was meant. Use `.extend` or `.truncate` for the direction you intend, or `.resize(width)` to state the width.", + "0.23.0" + ) def resize(using DFCG): DFValTP[DFXInt[S, Int, N], P] = lhs.tag(ir.ResizeTag).asValTP[DFXInt[S, Int, N], P] // permission to adjust the width in ONE direction, taken up by the context that From 99e7a0bcf4926a746909a16c97d3c7ce16ac74e8 Mon Sep 17 00:00:00 2001 From: Oron Port Date: Wed, 12 Aug 2026 09:47:48 +0300 Subject: [PATCH 32/40] update version dependencies --- .scalafmt.conf | 2 +- build.sbt | 4 ++-- docs/getting-started/hello-world/scala-project/.scalafmt.conf | 2 +- docs/requirements.txt | 2 +- project/build.properties | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.scalafmt.conf b/.scalafmt.conf index 9af6c6cea..6fe120c16 100755 --- a/.scalafmt.conf +++ b/.scalafmt.conf @@ -1,4 +1,4 @@ -version = 3.11.4 +version = 3.11.5 runner.dialect = scala3 maxColumn = 100 diff --git a/build.sbt b/build.sbt index 76bb09e88..f760b3742 100755 --- a/build.sbt +++ b/build.sbt @@ -33,8 +33,8 @@ val vgaMonitorVersion = "1.0.1" val interactiveSimVersion = "0.4.2" // dependency versions val scodecVersion = "1.2.5" -val munitVersion = "1.3.4" -val airframelogVersion = "2026.1.7" +val munitVersion = "1.3.5" +val airframelogVersion = "2026.2.2" val oslibVersion = "0.11.8" val scallopVersion = "6.0.0" val upickleVersion = "4.4.3" diff --git a/docs/getting-started/hello-world/scala-project/.scalafmt.conf b/docs/getting-started/hello-world/scala-project/.scalafmt.conf index 8cb1386e6..a77644948 100644 --- a/docs/getting-started/hello-world/scala-project/.scalafmt.conf +++ b/docs/getting-started/hello-world/scala-project/.scalafmt.conf @@ -1,4 +1,4 @@ -version = 3.11.4 +version = 3.11.5 runner.dialect = scala3 maxColumn = 100 diff --git a/docs/requirements.txt b/docs/requirements.txt index 2bea2cfbf..fbe6ed8b8 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -6,6 +6,6 @@ mkdocs-redirects==1.2.3 mkdocs-glightbox==0.5.2 mkdocs-autorefs==1.4.4 mkdocs-d2-plugin==1.7.0 -mkdocs-drawio==1.16.2 +mkdocs-drawio==1.16.3 schemdraw==0.23.0 pyhocon==0.3.63 diff --git a/project/build.properties b/project/build.properties index 71be851c8..5f6d607d4 100755 --- a/project/build.properties +++ b/project/build.properties @@ -1 +1 @@ -sbt.version = 1.12.14 \ No newline at end of file +sbt.version = 1.12.15 \ No newline at end of file From 6ee2c3e45788148df7ba75a7eabb83590fc4157b Mon Sep 17 00:00:00 2001 From: Oron Port Date: Wed, 12 Aug 2026 10:30:01 +0300 Subject: [PATCH 33/40] core+docs: a wildcard `Int` operand widens to the target, not to the other operand (#478) A wildcard `Int` has no width of its own, so it first adapts to the other operand's. That width is exactly what a wider target context replaces, and the adaptation was surviving the replacement: the literal evaluated at the other operand's width and only then widened, which truncates it when that width is the smaller and, when it is parametric, leaves the design constrained to hold a value nothing in the widened expression puts there. With a literal operand width the same expression already did the documented thing, which is what made this a defect rather than a design choice. The widening now looks through the adaptation and re-adapts the wildcard at the target, checking the fit THERE, and retracts the constraint the narrow adaptation assumed: a fit check answers its guard instead of raising it, and the guard is tied to the value that makes the assumption, so superseding that value drops it. Also always enumerates a materialized constraint from `constraint_0`. A design with a single constraint was naming it plain `constraint`, which is a SystemVerilog keyword and cannot label the generated elaboration block; nothing reached it before, since every existing case had two or more. Fixes #478 Co-Authored-By: Claude Opus 5 (1M context) --- .../StagesSpec/PrintCodeStringSpec.scala | 16 +++--- .../scala/dfhdl/core/AutoConstraint.scala | 44 +++++++++++---- .../main/scala/dfhdl/core/CarryPromote.scala | 36 +++++++++++-- .../src/main/scala/dfhdl/core/DFDecimal.scala | 54 +++++++++++++------ .../src/main/scala/dfhdl/core/MutableDB.scala | 7 +++ docs/user-guide/type-system/index.md | 4 +- lib/src/test/scala/ContextWidenSpec.scala | 36 +++++++++++-- 7 files changed, 155 insertions(+), 42 deletions(-) diff --git a/compiler/stages/src/test/scala/StagesSpec/PrintCodeStringSpec.scala b/compiler/stages/src/test/scala/StagesSpec/PrintCodeStringSpec.scala index 22c2fa3dd..244367d8c 100644 --- a/compiler/stages/src/test/scala/StagesSpec/PrintCodeStringSpec.scala +++ b/compiler/stages/src/test/scala/StagesSpec/PrintCodeStringSpec.scala @@ -854,7 +854,7 @@ class PrintCodeStringSpec extends StageSpec(stageCreatesUnrefAnons = true): | led.din := !led | else cnt.din := cnt + d"1'1".resize(clog2(maxCnt + 1)) | end if - | val constraint = assert(clog2(maxCnt + 1) >= 23, s"Design parameter violation found. Expected: clog2(maxCnt + 1) >= 23", Severity.Fatal) + | val constraint_0 = assert(clog2(maxCnt + 1) >= 23, s"Design parameter violation found. Expected: clog2(maxCnt + 1) >= 23", Severity.Fatal) |end Blinker |""".stripMargin ) @@ -3310,7 +3310,7 @@ class PrintCodeStringSpec extends StageSpec(stageCreatesUnrefAnons = true): | val inRange = assert(i < d"8'200".resize(W), s"i too large: ${i}") | val trace = println(s"i: ${i}") | o <> i - | val constraint = assert(W >= 8, s"Design parameter violation found. Expected: W >= 8", Severity.Fatal) + | val constraint_0 = assert(W >= 8, s"Design parameter violation found. Expected: W >= 8", Severity.Fatal) |end Named |""".stripMargin ) @@ -3339,7 +3339,7 @@ class PrintCodeStringSpec extends StageSpec(stageCreatesUnrefAnons = true): | val z = UInt(16) <> OUT | val n = UInt(8) <> OUT | z := x.resize(16) - | z := x.resize(16) + d"1'1".resize(W).resize(16) + | z := x.resize(16) + d"16'1" | n := y.resize(8) | val constraint_0 = assert(16 >= W, s"Design parameter violation found. Expected: 16 >= W", Severity.Fatal) | val constraint_1 = assert(8 >= V, s"Design parameter violation found. Expected: 8 >= V", Severity.Fatal) @@ -3368,7 +3368,7 @@ class PrintCodeStringSpec extends StageSpec(stageCreatesUnrefAnons = true): | process(all): | if (sel) z :== x.resize(16) | else z :== d"16'0" - | val constraint = assert(16 >= W, s"Design parameter violation found. Expected: 16 >= W", Severity.Fatal) + | val constraint_0 = assert(16 >= W, s"Design parameter violation found. Expected: 16 >= W", Severity.Fatal) |end Blocked |""".stripMargin ) @@ -3392,7 +3392,7 @@ class PrintCodeStringSpec extends StageSpec(stageCreatesUnrefAnons = true): | val n = UInt(8) <> OUT | z := x.resize(16) | n := x.resize(8) - | val constraint = assert(8 >= W, s"Design parameter violation found. Expected: 8 >= W", Severity.Fatal) + | val constraint_0 = assert(8 >= W, s"Design parameter violation found. Expected: 8 >= W", Severity.Fatal) |end Subsumed |""".stripMargin ) @@ -3431,7 +3431,7 @@ class PrintCodeStringSpec extends StageSpec(stageCreatesUnrefAnons = true): | val z = UInt(16) <> OUT | val note = assert(W <= 8, s"W is unusually large: ${W}", Severity.Warning) | z := x.resize(16) - | val constraint = assert(16 >= W, s"Design parameter violation found. Expected: 16 >= W", Severity.Fatal) + | val constraint_0 = assert(16 >= W, s"Design parameter violation found. Expected: 16 >= W", Severity.Fatal) |end UserReports |""".stripMargin ) @@ -3465,7 +3465,7 @@ class PrintCodeStringSpec extends StageSpec(stageCreatesUnrefAnons = true): | val c = WidthChild(W = 4) | val c_OUTPUT_WIDTH: Int <> CONST = 4 * 2 | o <> c.o.resize(OUTPUT_WIDTH) - | val constraint = assert(OUTPUT_WIDTH >= c_OUTPUT_WIDTH, s"Design parameter violation found. Expected: OUTPUT_WIDTH >= c_OUTPUT_WIDTH", Severity.Fatal) + | val constraint_0 = assert(OUTPUT_WIDTH >= c_OUTPUT_WIDTH, s"Design parameter violation found. Expected: OUTPUT_WIDTH >= c_OUTPUT_WIDTH", Severity.Fatal) |end WidthParent |""".stripMargin ) @@ -3488,7 +3488,7 @@ class PrintCodeStringSpec extends StageSpec(stageCreatesUnrefAnons = true): | val q = UInt(W) <> OUT | o := p.resize(8) | q := p - | val constraint = assert(8 >= W, s"Design parameter violation found. Expected: 8 >= W", Severity.Fatal) + | val constraint_0 = assert(8 >= W, s"Design parameter violation found. Expected: 8 >= W", Severity.Fatal) |end ParamExtend |""".stripMargin ) diff --git a/core/src/main/scala/dfhdl/core/AutoConstraint.scala b/core/src/main/scala/dfhdl/core/AutoConstraint.scala index a30177497..e6dc9b906 100644 --- a/core/src/main/scala/dfhdl/core/AutoConstraint.scala +++ b/core/src/main/scala/dfhdl/core/AutoConstraint.scala @@ -29,14 +29,16 @@ import scala.collection.mutable * where it belongs, on the elaboration error for a relation that is provably violated. */ object AutoConstraint: - /** The name a materialized constraint carries, enumerated when a design has more than one. It - * labels the statement in the generated HDL, where an unnamed SystemVerilog elaboration block is - * what a linter complains about. Enumerated HERE rather than left to `UniqueNames`, because the - * printed DFHDL is source: two `val constraint = ...` bindings in one body would not - * re-elaborate. The enumeration follows `UniqueNames`'s own, so it renames nothing further. + /** The name a materialized constraint carries. It labels the statement in the generated HDL, + * where an unnamed SystemVerilog elaboration block is what a linter complains about. Enumerated + * HERE rather than left to `UniqueNames`, because the printed DFHDL is source: two `val + * constraint = ...` bindings in one body would not re-elaborate. The enumeration follows + * `UniqueNames`'s own, so it renames nothing further, and it runs from the first constraint even + * when a design has only one: the bare `constraint` is a SystemVerilog keyword, which the + * generated elaboration block cannot be labelled with. */ private def constraintName(idx: Int, count: Int): String = - if (count == 1) "constraint" else s"constraint_${idx.toPaddedString(count)}" + s"constraint_${idx.toPaddedString(count)}" /** A constraint's condition. Constant, so the assertion it becomes is a contract checked at the * generated design's elaboration rather than a runtime test. @@ -66,18 +68,42 @@ object AutoConstraint: case _ => ir.IntExprCalc.widthFitCompare(lhs.toDFConst.asIR, rhs.toDFConst.asIR) /** Records `guard` as an assumption of the design being elaborated, to be materialized as a - * static assertion at the end of its body. + * static assertion at the end of its body. Answers the guard as recorded, for [[raiseFor]] to + * key on, and `None` where nothing was recorded. * * Nothing is recorded anywhere else: the guard IS the record, and its own meta is the position * of the operation that assumed it. */ - def raise(guard: Guard)(using dfc: DFC): Unit = + def raise(guard: Guard)(using dfc: DFC): Option[ir.DFVal] = // nothing states a constraint outside a design: global scope has no body to put it in, and a // stage's meta design transforms an already-elaborated one and assumes nothing of its own if (!dfc.inMetaProgramming && dfc.ownerOption.isDefined) import dfc.getSet - guard.asIR.setTags(_.tag(ir.AutoConstraint)) + Some(guard.asIR.setTags(_.tag(ir.AutoConstraint))) + else None + + /** [[raise]], for an assumption a specific VALUE makes: the constraint stands as long as that + * value does, and is [[retract]]ed when something supersedes it. + * + * An assumption is normally the design's for good, because the operation that made it is a + * statement of the body. An anonymous operand is not: target-context widening re-evaluates a + * whole expression at the target's width (see `CarryPromote.widenedOpt`), which discards the + * narrow form of every operand in it, and an assumption only that narrow form needed has to go + * with it rather than be left stating a requirement of the design nothing in it relies on. + */ + def raiseFor(guard: Guard, subject: ir.DFVal)(using dfc: DFC): Unit = + raise(guard).foreach(dfc.mutableDB.DesignContext.current.autoConstraintOf += subject -> _) + + /** Drops the assumption [[raiseFor]] recorded for `subject`, if any. The guard is left where it + * is, untagged: it is then read by nothing, and the end-of-design sweep collects it along with + * the superseded value itself. + */ + def retract(subject: ir.DFVal)(using dfc: DFC): Unit = + import dfc.getSet + dfc.mutableDB.DesignContext.current.autoConstraintOf.remove(subject).foreach { guard => + guard.setTags(_.removeTagOf[ir.AutoConstraint]) () + } /** Whether `value` carries any width-adjustment permission at all, in either direction. */ def hasWidthAdjustPermission(value: DFValAny)(using DFC): Boolean = diff --git a/core/src/main/scala/dfhdl/core/CarryPromote.scala b/core/src/main/scala/dfhdl/core/CarryPromote.scala index ad845eef5..f58b8d153 100644 --- a/core/src/main/scala/dfhdl/core/CarryPromote.scala +++ b/core/src/main/scala/dfhdl/core/CarryPromote.scala @@ -86,14 +86,25 @@ private[core] object CarryPromote: magnitudeWidthParamRef = dfType.widthIntParam.ref, nativeType = BitAccurate ) + def targetType = DFXInt(dfType.signed, dfType.widthIntParam, BitAccurate) // a nested value re-enters the full conversion, so nested cones widen and leaves // get their sign conversion / resize at the target type def widened(v: ir.DFVal): DFValAny = - DFXInt.Val.Ops.toDFXIntOf( - v.asValOf[DFXInt[Boolean, Int, NativeType]] - )(DFXInt(dfType.signed, dfType.widthIntParam, BitAccurate))(using - dfc.anonymize - ) + wildcardUnder(v) match + // A wildcard `Int` operand adapted to the OTHER operand's width: the wildcard + // re-adapts to the target, rather than its adaptation being widened. The other + // operand's width is precisely what the target context replaces, so evaluating + // the wildcard at it first is the narrow evaluation this rule exists to undo: + // it truncates a literal the target holds perfectly well, and, where that width + // is parametric, leaves the design constrained to hold a value nothing in the + // widened expression puts there. The fit at the TARGET is checked in its place. + case Some(wildcard) => + AutoConstraint.retract(v) + DFXInt.Val.Ops.adaptWildcard(wildcard.asValAny, targetType)(using dfc.anonymize) + case None => + DFXInt.Val.Ops.toDFXIntOf( + v.asValOf[DFXInt[Boolean, Int, NativeType]] + )(targetType)(using dfc.anonymize) def widenedArg(argRef: ir.DFVal.Ref): DFValAny = widened(argRef.get) // no MutableDB revision under meta-programming (matching `setMember`'s behavior // there): the retyped value is returned unregistered and the argument @@ -237,6 +248,21 @@ private[core] object CarryPromote: case alias: ir.DFVal.Alias => hasImplicitlyFromIntTag(alias.relValRef.get) case _ => false) + // The wildcard `Int` under a value that is nothing but that wildcard adapted to some other + // operand's width: an anonymous alias chain, as `toDFXIntOf` builds it, bottoming out either + // at the tagged constant a Scala `Int`'s candidate created (at the value's own minimum width, + // so a sign conversion and a resize may sit above it) or at a DFHDL `Int`, which has no width + // at all and takes one in a single conversion. `None` for everything else, the wildcard's own + // constant included: there is no adaptation there to look through. + private def wildcardUnder(dfVal: ir.DFVal)(using ir.MemberGetSet): Option[ir.DFVal] = + dfVal match + case alias: ir.DFVal.Alias.AsIs if alias.isAnonymous => + val relVal = alias.relValRef.get + val isWildcard = relVal.tags.hasTagOf[ir.ImplicitlyFromIntTag] || + relVal.dfType == ir.DFInt32 + if (isWildcard) Some(relVal) else wildcardUnder(relVal) + case _ => None + // A width reference resolved through design parameters: this runs during // elaboration, where a parameter's applied (or default) value is known, so a // parametric width like `CORDW + 1` resolves to its actual value. diff --git a/core/src/main/scala/dfhdl/core/DFDecimal.scala b/core/src/main/scala/dfhdl/core/DFDecimal.scala index 29c4420df..fd5c0f3f7 100644 --- a/core/src/main/scala/dfhdl/core/DFDecimal.scala +++ b/core/src/main/scala/dfhdl/core/DFDecimal.scala @@ -178,27 +178,32 @@ object DFDecimal: // The message is the CHECK's, over the width expressions rather than over the `Int`s the // check itself takes: one rule has one message, whichever of its three halves reports it // (the compile-time reduction, the check's own runtime test, or this symbolic decision). + // Answers the constraint the undecidable arm raised, for a caller that has a value to tie it + // to (see `AutoConstraint.raiseFor`); the other two answers have nothing to record. private def fitCheck(lhs: IntParam[Int], rhs: IntParam[Int])(violation: => String)(using DFC - ): Unit = + ): Option[AutoConstraint.Guard] = AutoConstraint.widthFitGE(lhs, rhs) match - case Some(true) => // fits for every parameter assignment + case Some(true) => None // fits for every parameter assignment case Some(false) => throw new IllegalArgumentException(violation) - case None => AutoConstraint.raise(AutoConstraint.ge(lhs, rhs)) + case None => Some(AutoConstraint.ge(lhs, rhs)) protected[core] def widthFitCheck( lhs: IntParam[Int], rhs: IntParam[Int] )(using DFC): Unit = fitCheck(lhs, rhs)(`LW >= RW`.message(lhs.errorString, rhs.errorString)) + .foreach(AutoConstraint.raise) /** [[widthFitCheck]] for the fit a WILDCARD `Int` needs to adapt to a bit-accurate value: the - * elaboration half of [[`BaW >= WcW`]], invoked where that check's arm cannot decide. + * elaboration half of [[`BaW >= WcW`]], invoked where that check's arm cannot decide. Answers + * the constraint it needs rather than raising it, since the value that will make the + * assumption does not exist until the adaptation itself is built. */ protected[core] def wildcardFitCheck( baWidth: IntParam[Int], wcWidth: IntParam[Int] - )(using DFC): Unit = + )(using DFC): Option[AutoConstraint.Guard] = fitCheck(baWidth, wcWidth)(`BaW >= WcW`.message(baWidth.errorString, wcWidth.errorString)) object `LS >= RS` @@ -1338,7 +1343,7 @@ object DFXInt: val effectiveWidth = if (dfType.signed.value && !rhsSigned) argWidth + 1 else argWidth - wildcardFitCheck(dfTypeWidth, effectiveWidth) + wildcardFitCheck(dfTypeWidth, effectiveWidth).foreach(AutoConstraint.raise) else equalWidthCheck(dfTypeWidth, argWidth) end if end if @@ -1665,7 +1670,7 @@ object DFXInt: private def checkWildcardFit( wildcard: DFValOf[DFInt32], bitAccurateType: DFTypeAny - )(using dfc: DFC): Unit = + )(using dfc: DFC): Option[AutoConstraint.Guard] = val baType = bitAccurateType.asIR.asInstanceOf[ir.DFDecimal] import dfc.getSet import DFXInt.Val.getActualSignedWidthOpt @@ -1675,7 +1680,7 @@ object DFXInt: throw new IllegalArgumentException( s"Wildcard `Int` value is negative and cannot adapt to an unsigned bit-accurate value." ) - wcWidthIntOpt.foreach { wcWidth => + wcWidthIntOpt.flatMap { wcWidth => // Unsigned wildcard adapting to signed bit-accurate value needs an extra bit val effectiveWidth = if (baType.signed && !wcSigned) wcWidth + 1 else wcWidth @@ -1685,6 +1690,7 @@ object DFXInt: throw new IllegalArgumentException( `BaW >= WcW`.message(baWidth, effectiveWidth) ) + None case None => // the bit-accurate width is parametric, so the fit the adaptation relies on is // stated as a constraint of the design (an integer type: fraction 0, so the @@ -1693,11 +1699,31 @@ object DFXInt: baType.magnitudeWidthParamRef.get, IntParam.forced[Int](effectiveWidth) ) + end match } - case _ => + case _ => None end match end checkWildcardFit + /** A wildcard `Int` operand adapted to a bit-accurate type: the conversion, with the fit it + * needs checked, and, where that fit is undecidable and becomes a constraint of the design, + * the constraint tied to the adapted value ([[AutoConstraint.raiseFor]]). + * + * The type is the OTHER operand's, which nothing in the expression itself says is the width + * the wildcard should take: a wider target context replaces it, and the adaptation and its + * assumption are then both superseded ([[CarryPromote.widenedOpt]]). + */ + private[core] def adaptWildcard[RS <: Boolean, RW <: IntP, RN <: NativeType]( + wildcard: DFValAny, + dfType: DFXInt[RS, RW, RN] + )(using dfc: DFC): DFValOf[DFXInt[RS, RW, RN]] = + val pending = checkWildcardFit(wildcard.asValOf[DFInt32], dfType) + val adapted = + wildcard.asValOf[DFXInt[Boolean, Int, NativeType]].toDFXIntOf(dfType) + pending.foreach(AutoConstraint.raiseFor(_, adapted.asIR)) + adapted + end adaptWildcard + private def arithOp[ OS <: Boolean, OW <: IntP, @@ -1822,13 +1848,12 @@ object DFXInt: val retVal = if (lhsIsWildcard && !rhsIsWildcard) // LHS is wildcard: adapt to RHS type, keeping the written operand order - checkWildcardFit(lhsVal.asValOf[DFInt32], rhsVal.dfType) - val lhsFix = lhsVal.toDFXIntOf(rhsVal.dfType)(using dfcAnon) + val lhsFix = adaptWildcard(lhsVal, rhsVal.dfType)(using dfcAnon) DFVal.Func(rhsVal.dfType, op.value, List(lhsFix, rhsVal)) else if (rhsIsWildcard) // LHS may be wildcard or concrete // RHS is wildcard: adapt to LHS type - checkWildcardFit(rhsVal.asValOf[DFInt32], lhsVal.dfType) - arithOp(lhsVal.dfType, op.value, lhsVal, rhsVal) + val rhsFix = adaptWildcard(rhsVal, lhsVal.dfType)(using dfcAnon) + arithOp(lhsVal.dfType, op.value, lhsVal, rhsFix) else // Both concrete: use max width, max signed val lhsSFix = @@ -1928,8 +1953,7 @@ object DFXInt: if (lhsIsWildcard && !rhsIsWildcard) // LHS is an adapting wildcard, RHS is concrete: adapt LHS to RHS type, keep // operand order - checkWildcardFit(lhsVal.asValOf[DFInt32], rhsVal.dfType) - val lhsAdj = lhsVal.toDFXIntOf(rhsVal.dfType)(using dfcAnon) + val lhsAdj = adaptWildcard(lhsVal, rhsVal.dfType)(using dfcAnon) DFVal.Func(rhsVal.dfType, op.value, List(lhsAdj, rhsVal)).asInstanceOf[Out] else if (isWildcardL.value && !rhsIsWildcard) // LHS is a literal wildcard: both operands align at the common type, so the diff --git a/core/src/main/scala/dfhdl/core/MutableDB.scala b/core/src/main/scala/dfhdl/core/MutableDB.scala index 965acf35e..57c2d45a0 100644 --- a/core/src/main/scala/dfhdl/core/MutableDB.scala +++ b/core/src/main/scala/dfhdl/core/MutableDB.scala @@ -111,6 +111,13 @@ class DesignContext: dfVal }.toList + // The pending constraint a VALUE's own width adaptation assumed, keyed by that value. A + // constraint states what the finished design relies on, and an anonymous operand can still be + // superseded after it made its assumption, so the assumption needs an owner to be dropped with. + // `AutoConstraint.raiseFor`/`retract` are the only users; a guard nothing supersedes is simply + // never looked up. + val autoConstraintOf = mutable.Map.empty[DFVal, DFVal] + def setOriginRefs(member: DFMember): Unit = member.getRefs.foreach { r => originRefTable += r -> member } diff --git a/docs/user-guide/type-system/index.md b/docs/user-guide/type-system/index.md index 0034b6f3e..455c8ccfd 100755 --- a/docs/user-guide/type-system/index.md +++ b/docs/user-guide/type-system/index.md @@ -2430,7 +2430,7 @@ Both Scala `Int` values and DFHDL `Int` parameters (`Int <> CONST`) act as **wil The two kinds of wildcard differ in how much is known about that minimum. A Scala `Int` always has one, from the literal at compile time or from the value at elaboration. A DFHDL `Int` parameter may have none at elaboration, and an overridable one has none for any manifestation, so it can only ever adapt. -For `+`, `-` and `*`, a Scala `Int` **literal**'s minimum width counts as an actual width when the result width is computed, so the result is simply the wider of the two operands. A literal that fits is unchanged by this (the bit-accurate operand is the wider one), and a literal that does not fit widens the operation instead of being an error: `u8 + 1000` is `UInt[10]`. It takes the other operand's width at compile time too, so against a parametric width, or for an `Int` whose value is not a literal, the wildcard adapts and must fit as before. In [carry operations][carry-ops] a Scala `Int` operand always contributes its minimum width. +For `+`, `-` and `*`, a Scala `Int` **literal**'s minimum width counts as an actual width when the result width is computed, so the result is simply the wider of the two operands. A literal that fits is unchanged by this (the bit-accurate operand is the wider one), and a literal that does not fit widens the operation instead of being an error: `u8 + 1000` is `UInt[10]`. It takes the other operand's width at compile time too, so against a parametric width, or for an `Int` whose value is not a literal, the wildcard adapts and must fit as before. That fit is the one the expression itself needs; where a wider target re-evaluates the expression (see the automatic target-context widening above), the wildcard takes the target's width instead, and the fit it must meet is that one. In [carry operations][carry-ops] a Scala `Int` operand always contributes its minimum width. ```scala val u8 = UInt(8) <> VAR @@ -2533,7 +2533,7 @@ val r14 = d1 / d2 // Double type: warning Standard arithmetic operations wrap on overflow. For example, `d"8'255" + d"8'1"` produces `d"8'0"`. Use the carry variants (`+^`, `-^`, `*^`) described below to get a wider result that preserves the full value. -However, an **anonymous** arithmetic expression (`+`, `-`, `*`, unary `-`) that is assigned or connected to a variable **wider** than the operation's result is re-evaluated at the target's width and sign, exactly like Verilog's assignment-context width propagation: every operand, recursively through the anonymous expression, is widened to the target type, and the operations stay modular at that width. The carry operators are themselves shorthand for exactly this operand-widened evaluation (`x +^ y` is `x.eby(1) + y.eby(1)` with the operands first aligned to a common width), so when a widening lands exactly on a carry shape it prints back as the carry operator. +However, an **anonymous** arithmetic expression (`+`, `-`, `*`, unary `-`) that is assigned or connected to a variable **wider** than the operation's result is re-evaluated at the target's width and sign, exactly like Verilog's assignment-context width propagation: every operand, recursively through the anonymous expression, is widened to the target type, and the operations stay modular at that width. A [wildcard `Int`][wildcard-ops] operand takes the target width directly rather than the width it adapted to: it has no width of its own, and the other operand's width is precisely what the target replaces. The carry operators are themselves shorthand for exactly this operand-widened evaluation (`x +^ y` is `x.eby(1) + y.eby(1)` with the operands first aligned to a common width), so when a widening lands exactly on a carry shape it prints back as the carry operator. The widening context also crosses an anonymous `.sel` (matching Verilog's `?:`, whose branch operands are context-determined) and anonymous `if`/`match` **expressions** (matching the per-branch assignments they lower to): each branch re-evaluates at the target, while the selection condition or match selector is unaffected. A **shift**'s left operand is likewise context-determined (matching Verilog; the shift amount is self-determined), as long as the target keeps the operand's signedness: a shift evaluates at its operand's own signedness (an arithmetic-vs-logical `>>` difference), so a sign-crossing shift context is a boundary and the shifted result converts as a plain value there. The context stops at exactly three kinds of boundaries: a **named value** (a `val`-bound expression evaluates at its own declared width and only its result extends), a **carry operation** (its widened result is already exact), and any **other operation** (bitwise logic, comparisons, rotations), whose result converts as a plain value. diff --git a/lib/src/test/scala/ContextWidenSpec.scala b/lib/src/test/scala/ContextWidenSpec.scala index a384359f8..523d780b3 100644 --- a/lib/src/test/scala/ContextWidenSpec.scala +++ b/lib/src/test/scala/ContextWidenSpec.scala @@ -34,7 +34,8 @@ class ContextWidenSpec extends DesignSpec: // modular there, exactly like Verilog's assignment context acc <> a + b // a merged (non-binary) chain always evaluates at the target width; the implicit - // Int operand adapts at the operand width and widens along + // Int operand takes that width DIRECTLY rather than the operand width it first + // adapted to, so the fit it states is the one the target needs (issue #478) chain <> a + b + 1 end ParamWiden @@ -51,13 +52,42 @@ class ContextWidenSpec extends DesignSpec: | sum <> (a +^ b) | usub <> (ua -^ ub) | acc <> (a.eby(2) + b.eby(2)) - | chain <> (a.eby(2) + b.eby(2) + sd"2'1".resize(W).eby(2)) - | val constraint = assert(W >= 2, s"Design parameter violation found. Expected: W >= 2", Severity.Fatal) + | chain <> (a.eby(2) + b.eby(2) + sd"2'1".resize(W + 2)) + | val constraint_0 = assert((W + 2) >= 2, s"Design parameter violation found. Expected: (W + 2) >= 2", Severity.Fatal) |end ParamWiden |""".stripMargin ) } + test("wildcard `Int` operand takes the target width") { + // A wildcard `Int` has no width of its own and first adapts to the OTHER operand's, which is + // precisely the width the target context then replaces. So it takes the target width + // directly: adapting to a parametric operand width first would evaluate the literal there, + // truncating it when that width turns out to be the smaller, and would leave the design + // constrained to hold a value nothing in the widened expression puts at that width (#478). + @top(false) class WcWiden(val N: Int <> CONST = 4) extends EDDesign: + val p = UInt.until(N) <> IN + val wide = UInt(16) <> OUT + val narrow = UInt.until(N) <> OUT + wide <> 5 * p + // the control: with no wider target the adaptation stands, and so does the fit it needs + narrow <> 5 * p + end WcWiden + + WcWiden().assertCodeString( + """|class WcWiden(val N: Int <> CONST = 4) extends EDDesign: + | val p = UInt(clog2(N)) <> IN + | val wide = UInt(16) <> OUT + | val narrow = UInt(clog2(N)) <> OUT + | wide <> (d"16'5" * p.resize(16)) + | narrow <> (d"3'5".resize(clog2(N)) * p) + | val constraint_0 = assert(16 >= clog2(N), s"Design parameter violation found. Expected: 16 >= clog2(N)", Severity.Fatal) + | val constraint_1 = assert(clog2(N) >= 3, s"Design parameter violation found. Expected: clog2(N) >= 3", Severity.Fatal) + |end WcWiden + |""".stripMargin + ) + } + test("parametric mul target-context widening (width-fit proofs)") { @top(false) class ParamMul(val W: Int <> CONST = 8) extends EDDesign: val a, b = SInt(W) <> IN From eb5bc0336493163de26e322978f3be5962c09ec8 Mon Sep 17 00:00:00 2001 From: Oron Port Date: Wed, 12 Aug 2026 11:55:59 +0300 Subject: [PATCH 34/40] core: a wildcard `Int` parameter states the value bound its adaptation relies on A wildcard `Int` adapts, and every check bounding that adaptation compares two WIDTHS, which presumes the wildcard has one. A Scala `Int` always does. An overridable design parameter has none, for this elaboration or any other, so its width AND its sign are both unknown and every arm simply fell through: `x(UInt(16)) <> V` emitted `assign x = 16'(V);` with no contract at all, silently truncating whatever the instantiation supplies. What the adaptation relies on there is a bound on the VALUE, stated as the width that value needs: `clog2(v + 1)` bits hold an unsigned `v`, and `clog2(max(v + 1, -v)) + 1` a signed one, both exactly and for every `v`. Through `clog2` rather than as `v <= 2 ** width - 1` deliberately, since a 64-bit target would overflow the 32-bit integer arithmetic the generated HDL evaluates the contract in. The sign being unknown too, an unsigned target requires non-negativity as well, in the same constraint: the halves are what one adaptation needs together, and reporting a violation of one without the other names half a contract. The fit half discharges like any other width relation, and usually does, a value derived from the parameter the target's width is derived from needing exactly that width. The sign half must not go through the same discharge, whose premise is that both sides are widths. Nothing is stated for a target that is itself a wildcard, which adapts to nothing, nor for a wildcard the design body cannot read: a `for` iterator has no value at elaboration for the same reason a parameter has none, but unlike a parameter it has none in the finished design either. `IntParam` gains the negation its algebra was missing, so the guard reads `-V` rather than `0 - V`. Co-Authored-By: Claude Opus 5 (1M context) --- .../StagesSpec/PrintCodeStringSpec.scala | 41 ++++++++++ .../scala/StagesSpec/PrintVHDLCodeSpec.scala | 6 +- .../StagesSpec/PrintVerilogCodeSpec.scala | 8 +- .../scala/dfhdl/core/AutoConstraint.scala | 13 +++- .../src/main/scala/dfhdl/core/DFDecimal.scala | 78 ++++++++++++++++++- core/src/main/scala/dfhdl/core/IntParam.scala | 2 + .../verilog.sv2009/hdl/Blinker.sv | 3 + .../verilog.v2001/hdl/Blinker.v | 6 ++ .../verilog.v95/hdl/Blinker.v | 6 ++ .../vhdl.v2008/hdl/Blinker.vhd | 2 + .../vhdl.v93/hdl/Blinker.vhd | 2 + .../verilog.sv2009/hdl/UART_Tx.sv | 3 + .../verilog.v2001/hdl/UART_Tx.v | 6 ++ .../verilog.v95/hdl/UART_Tx.v | 6 ++ .../vhdl.v2008/hdl/UART_Tx.vhd | 2 + .../vhdl.v93/hdl/UART_Tx.vhd | 2 + 16 files changed, 179 insertions(+), 7 deletions(-) diff --git a/compiler/stages/src/test/scala/StagesSpec/PrintCodeStringSpec.scala b/compiler/stages/src/test/scala/StagesSpec/PrintCodeStringSpec.scala index 244367d8c..dfe8402dd 100644 --- a/compiler/stages/src/test/scala/StagesSpec/PrintCodeStringSpec.scala +++ b/compiler/stages/src/test/scala/StagesSpec/PrintCodeStringSpec.scala @@ -1046,6 +1046,7 @@ class PrintCodeStringSpec extends StageSpec(stageCreatesUnrefAnons = true): | led.din := !led | else cnt.din := cnt + d"1'1".resize(clog2(HALF_PERIOD)) | end if + | val constraint_0 = assert((HALF_PERIOD - 1) >= 0, s"Design parameter violation found. Expected: (HALF_PERIOD - 1) >= 0", Severity.Fatal) |end Blinker |""".stripMargin ) @@ -3470,6 +3471,46 @@ class PrintCodeStringSpec extends StageSpec(stageCreatesUnrefAnons = true): |""".stripMargin ) } + test("auto constraint from a wildcard `Int` parameter's value") { + // A wildcard `Int` adapts, and a Scala `Int`'s minimum WIDTH is what bounds the adaptation. An + // overridable parameter has no width at all, for this elaboration or any other, so the bound + // is on its VALUE, stated as the width that value needs. The sign is unknown too, and no + // width makes an unsigned type hold a negative value, so an unsigned target says that as + // well, in the same constraint. + class WcValue(val V: Int <> CONST = 4, val N: Int <> CONST = 8) extends EDDesign: + val u = UInt(16) <> OUT + val s = SInt(8) <> OUT + val p = UInt.until(N) <> OUT + val q = UInt.until(N) <> OUT + u <> V + s <> V + p <> V + // the fit DISCHARGES when the value is derived from the very parameter the target's width + // is: `N - 1` needs exactly the width `UInt.until(N)` has, leaving only the sign half + q <> N - 1 + end WcValue + assertCodeString( + WcValue(), + """|class WcValue( + | val V: Int <> CONST = 4, + | val N: Int <> CONST = 8 + |) extends EDDesign: + | val u = UInt(16) <> OUT + | val s = SInt(8) <> OUT + | val p = UInt(clog2(N)) <> OUT + | val q = UInt(clog2(N)) <> OUT + | u <> d"16'${V}" + | s <> sd"8'${V}" + | p <> d"${clog2(N)}'${V}" + | q <> d"${clog2(N)}'${(N - 1)}" + | val constraint_0 = assert((V >= 0) && (16 >= clog2(V + 1)), s"Design parameter violation found. Expected: (V >= 0) && (16 >= clog2(V + 1))", Severity.Fatal) + | val constraint_1 = assert(8 >= (clog2((V + 1) max (-V)) + 1), s"Design parameter violation found. Expected: 8 >= (clog2((V + 1) max (-V)) + 1)", Severity.Fatal) + | val constraint_2 = assert((V >= 0) && (clog2(N) >= clog2(V + 1)), s"Design parameter violation found. Expected: (V >= 0) && (clog2(N) >= clog2(V + 1))", Severity.Fatal) + | val constraint_3 = assert((N - 1) >= 0, s"Design parameter violation found. Expected: (N - 1) >= 0", Severity.Fatal) + |end WcValue + |""".stripMargin + ) + } test("width adjustment permission over a design parameter") { // a permission is decided on the two widths, so over a parameter it may be undecidable. It is // covered either way, with the relation it relies on stated as a constraint of the design. diff --git a/compiler/stages/src/test/scala/StagesSpec/PrintVHDLCodeSpec.scala b/compiler/stages/src/test/scala/StagesSpec/PrintVHDLCodeSpec.scala index b87a02a05..39f3479ee 100644 --- a/compiler/stages/src/test/scala/StagesSpec/PrintVHDLCodeSpec.scala +++ b/compiler/stages/src/test/scala/StagesSpec/PrintVHDLCodeSpec.scala @@ -372,6 +372,8 @@ class PrintVHDLCodeSpec extends StageSpec: | constant HALF_PERIOD : integer := (CLK_FREQ_KHz * 1000) / (LED_FREQ_Hz * 2); | signal cnt : unsigned(clog2(HALF_PERIOD) - 1 downto 0); |begin + | constraint_0: assert (HALF_PERIOD - 1) >= 0 + | report "Design parameter violation found. Expected: (HALF_PERIOD - 1) >= 0" severity FAILURE; | process (clk) | begin | if rising_edge(clk) then @@ -1213,7 +1215,7 @@ class PrintVHDLCodeSpec extends StageSpec: | println("These are the values: " & to_string(param3) & ", " & to_string(param4) & ", " & to_string(param5) & ", " & to_string(param6) & ", " & to_string(param7) & ", " & to_string(param8) & ", " & to_string(param9) & ", " & t_enum_MyEnum'image(param10) & ""); | report | "Debug at Foo" & LF & - | "compiler/stages/src/test/scala/StagesSpec/PrintVHDLCodeSpec.scala:1160:9" & LF & + | "compiler/stages/src/test/scala/StagesSpec/PrintVHDLCodeSpec.scala:1162:9" & LF & | "param3 = " & to_string(param3) & LF & | "param4 = " & to_string(param4) & LF & | "param5 = " & to_string(param5) & LF & @@ -1274,7 +1276,7 @@ class PrintVHDLCodeSpec extends StageSpec: | println("These are the values: " & to_string(param3) & ", " & to_string(param4) & ", " & to_string(param5) & ", " & to_string(param6) & ", " & to_string(param7) & ", " & to_string(param8) & ", " & to_string(param9) & ", " & t_enum_MyEnum'image(param10) & ""); | report | "Debug at Foo" & LF & - | "compiler/stages/src/test/scala/StagesSpec/PrintVHDLCodeSpec.scala:1160:9" & LF & + | "compiler/stages/src/test/scala/StagesSpec/PrintVHDLCodeSpec.scala:1162:9" & LF & | "param3 = " & to_string(param3) & LF & | "param4 = " & to_string(param4) & LF & | "param5 = " & to_string(param5) & LF & diff --git a/compiler/stages/src/test/scala/StagesSpec/PrintVerilogCodeSpec.scala b/compiler/stages/src/test/scala/StagesSpec/PrintVerilogCodeSpec.scala index 9d94ee914..810b4d669 100644 --- a/compiler/stages/src/test/scala/StagesSpec/PrintVerilogCodeSpec.scala +++ b/compiler/stages/src/test/scala/StagesSpec/PrintVerilogCodeSpec.scala @@ -584,6 +584,10 @@ class PrintVerilogCodeSpec extends StageSpec: | /* Half-count of the toggle for 50% duty cycle */ | localparam int HALF_PERIOD = (CLK_FREQ_KHz * 1000) / (LED_FREQ_Hz * 2); | logic [$clog2(HALF_PERIOD) - 1:0] cnt; + | initial begin : constraint_0 + | assert ((HALF_PERIOD - 1) >= 0) + | else $fatal(1, "Design parameter violation found. Expected: (HALF_PERIOD - 1) >= 0"); + | end | always_ff @(posedge clk) | begin | if (rst == 1'b1) begin @@ -1137,7 +1141,7 @@ class PrintVerilogCodeSpec extends StageSpec: | $display("These are the values: %d, %d, %h, %h, %d, %b, %s, %s", param3, param4, param5, param6, param7, param8, param9 ? "true" : "false", param10.name()); | $info( | "Debug at Foo\n", - | "compiler/stages/src/test/scala/StagesSpec/PrintVerilogCodeSpec.scala:1089:9\n", + | "compiler/stages/src/test/scala/StagesSpec/PrintVerilogCodeSpec.scala:1093:9\n", | "param3 = %d\n", param3, | "param4 = %d\n", param4, | "param5 = %h\n", param5, @@ -1208,7 +1212,7 @@ class PrintVerilogCodeSpec extends StageSpec: | $display("These are the values: %d, %d, %h, %h, %d, %b, %s, %s", param3, param4, param5, param6, param7, param8, param9 ? "true" : "false", MyEnum_to_string(param10)); | $display( | "INFO: Debug at Foo\n", - | "compiler/stages/src/test/scala/StagesSpec/PrintVerilogCodeSpec.scala:1089:9\n", + | "compiler/stages/src/test/scala/StagesSpec/PrintVerilogCodeSpec.scala:1093:9\n", | "param3 = %d\n", param3, | "param4 = %d\n", param4, | "param5 = %h\n", param5, diff --git a/core/src/main/scala/dfhdl/core/AutoConstraint.scala b/core/src/main/scala/dfhdl/core/AutoConstraint.scala index e6dc9b906..a0c5160d1 100644 --- a/core/src/main/scala/dfhdl/core/AutoConstraint.scala +++ b/core/src/main/scala/dfhdl/core/AutoConstraint.scala @@ -55,6 +55,14 @@ object AutoConstraint: def ge(lhs: IntParam[Int], rhs: IntParam[Int])(using DFC): Guard = condition(FuncOp.>=, lhs, rhs) + /** Both conditions, for a requirement that takes more than one relation to state. It stays ONE + * constraint: the parts are what the design must satisfy together, and reporting a violation of + * one without showing the other names half a contract. + */ + def and(lhs: Guard, rhs: Guard)(using dfc: DFC): Guard = + given DFC = dfc.anonymize + DFVal.Func[DFBool, Any](DFBool, FuncOp.&, List(lhs.asIR, rhs.asIR)) + /** Decides the width fit `lhs >= rhs`, or `None` when it holds for some parameter assignments and * not others. The undecided answer is what [[raise]] exists for. */ @@ -162,8 +170,9 @@ object AutoConstraint: private type Requirement = ir.IntExprCalc.Linear /** What a guard requires, as the conjunction of one or more `linear >= 0` relations. Empty for a - * guard that is not a comparison of two integer expressions: nothing generates such a guard, but - * a user's own assertion may well be one, and it then simply takes no part in minimization. + * guard that is not a comparison of two integer expressions, which then simply takes no part in + * minimization: a user's own assertion may be anything at all, and a generated multi-part + * requirement (see [[and]]) is a conjunction rather than a relation. * * Every comparison normalizes onto the same shape, so a user's `W <= 8` is comparable with a * generated `16 >= W` without either being rewritten. A strict comparison is the non-strict one diff --git a/core/src/main/scala/dfhdl/core/DFDecimal.scala b/core/src/main/scala/dfhdl/core/DFDecimal.scala index fd5c0f3f7..5ec5eb580 100644 --- a/core/src/main/scala/dfhdl/core/DFDecimal.scala +++ b/core/src/main/scala/dfhdl/core/DFDecimal.scala @@ -206,6 +206,70 @@ object DFDecimal: )(using DFC): Option[AutoConstraint.Guard] = fitCheck(baWidth, wcWidth)(`BaW >= WcW`.message(baWidth.errorString, wcWidth.errorString)) + /** [[wildcardFitCheck]] for a wildcard `Int` whose own VALUE does not resolve, which is every + * manifestation of an overridable design parameter. + * + * A wildcard adapts, so something has to bound what it adapts to, and the sibling above bounds + * its minimum WIDTH. A parameter has none: neither its width nor its sign is known, for this + * elaboration or any other, so there is nothing to compare and the bound has to be on the + * VALUE itself. It is the same relation all the same, stated as the width that value needs, + * which is `clog2(v + 1)` bits for an unsigned `v` and `clog2(max(v + 1, -v)) + 1` for a + * signed one, both exactly and for every `v`. Through `clog2` rather than as + * `v <= 2 ** width - 1` deliberately: a 64-bit target would overflow the 32-bit integer + * arithmetic the generated HDL evaluates the contract in. + * + * The sign is unknown too, and no width makes an unsigned type hold a negative value, so an + * unsigned target requires that as well, in the same constraint: the two halves are what one + * adaptation needs, not two things to satisfy separately. Answers the constraint rather than + * raising it, for the same reason the sibling does. + * + * Answers none in two cases. A target that is itself a wildcard `Int` adapts to nothing and so + * assumes nothing. And a wildcard the design BODY cannot read is not something the body can + * state a contract over: a `for` iterator has no value at elaboration for the same reason a + * parameter has none, but unlike a parameter it has none in the finished design either, and a + * guard reading it would be a block-local read from the body (`DB.blockScopeCheck`). + */ + protected[core] def wildcardValueFitCheck( + wildcard: DFValAny, + baType: ir.DFDecimal + )(using dfc: DFC): Option[AutoConstraint.Guard] = + import dfc.getSet + // a named value belongs to the body only when a design or domain owns it directly (an + // owner-less global is readable from everywhere); an anonymous one is its operands' + def bodyReadable(dfVal: ir.DFVal): Boolean = + dfVal match + case dcl: ir.DFVal.Dcl if dcl.isIterator => false + case _ if dfVal.isAnonymous => + dfVal.getRefs.forall(_.get match + case operand: ir.DFVal => bodyReadable(operand) + case _ => true) + case _ => + dfVal.ownerRef.get match + case _: ir.DFDomainOwner => true + case _: ir.DFMember.Empty => true + case _ => false + Option.when(!baType.isDFInt32 && wildcard.asIR.isConst && bodyReadable(wildcard.asIR)) { + import IntParam.{+, max, clog2, unary_-} + val value = IntParam.forced[Int](wildcard.asIR.asConstOf[DFInt32]) + val zero = IntParam.forced[Int](0) + val one = IntParam.forced[Int](1) + val neededWidth = + if (baType.signed) ((value + one).max(-value)).clog2 + one + else (value + one).clog2 + // The fit is a relation between two WIDTHS, so it discharges like any other, and often + // does: a value derived from the very parameter the target's width is derived from needs + // exactly that width. The non-negativity is a relation over a VALUE, where the discharge's + // premise that both sides are widths (`>= 1` on the valid domain) is precisely what must + // not be assumed, and it is undecidable here by construction: a wildcard whose value folds + // took the sibling's path instead. + val fitOpt = wildcardFitCheck(baType.magnitudeWidthParamRef.get, neededWidth) + if (baType.signed) fitOpt + else + val nonNeg = AutoConstraint.ge(value, zero) + Some(fitOpt.fold(nonNeg)(AutoConstraint.and(nonNeg, _))) + }.flatten + end wildcardValueFitCheck + object `LS >= RS` extends Check2[ Boolean, @@ -420,6 +484,10 @@ object DFDecimal: else rhsWidthParam widthFitCheck(lhsWidthParam, rhsSignedWidthParam) end match + // the RHS is a wildcard `Int` whose value does not resolve (the LHS cannot be one + // here), so it adapts to the LHS type with no width of its own to compare + case (Some(_, _), None) => + wildcardValueFitCheck(rhs, lhs.dfType.asIR).foreach(AutoConstraint.raise) case _ => end match end apply @@ -1253,7 +1321,10 @@ object DFXInt: // `16 >= W`) becomes a constraint of the design widthFitCheck(dfTypeWidthRef.get, rhsWidthRef.get) end if + // the RHS is a wildcard `Int` whose value does not resolve, so it adapts to the + // target with no width of its own to compare case None => + wildcardValueFitCheck(rhs, dfType.asIR).foreach(AutoConstraint.raise) end match // a widened cone lands exactly at the target type with the original func's // (anonymous) meta, so a named-val binding must be applied here, like the @@ -1347,7 +1418,10 @@ object DFXInt: else equalWidthCheck(dfTypeWidth, argWidth) end if end if + // the argument is a wildcard `Int` whose value does not resolve, so it adapts to the + // receiver with no width of its own to compare case None => + wildcardValueFitCheck(dfValArg, dfType.asIR).foreach(AutoConstraint.raise) end match DFXInt.Val.Ops.toDFXIntOf(dfValArg)(dfType).asValTP[DFXInt[LS, LW, LN], RP] end convArg @@ -1701,7 +1775,9 @@ object DFXInt: ) end match } - case _ => None + // neither the wildcard's width nor its sign resolves, so the bound it must meet is on + // its VALUE instead + case _ => wildcardValueFitCheck(wildcard, baType) end match end checkWildcardFit diff --git a/core/src/main/scala/dfhdl/core/IntParam.scala b/core/src/main/scala/dfhdl/core/IntParam.scala index 69ea7f293..86904ea96 100644 --- a/core/src/main/scala/dfhdl/core/IntParam.scala +++ b/core/src/main/scala/dfhdl/core/IntParam.scala @@ -280,6 +280,8 @@ object IntParam extends IntParamLP: infix def min[R <: IntP](rhs: IntParam[R]): IntParam[IntP.Min[L, R]] = import scala.runtime.RichInt calc(FuncOp.min, lhs, rhs)((x, y) => RichInt(x) min y) + def unary_- : IntParam[Int] = + calc(FuncOp.unary_-, lhs)(-_) def clog2: IntParam[IntP.CLog2[L]] = calc(FuncOp.clog2, lhs)(dfhdl.internals.clog2) def =~[R <: IntP](that: IntParam[R]): Boolean = diff --git a/lib/src/test/resources/ref/docExamples.BlinkerSpec/verilog.sv2009/hdl/Blinker.sv b/lib/src/test/resources/ref/docExamples.BlinkerSpec/verilog.sv2009/hdl/Blinker.sv index f664a13ad..4e2191f07 100644 --- a/lib/src/test/resources/ref/docExamples.BlinkerSpec/verilog.sv2009/hdl/Blinker.sv +++ b/lib/src/test/resources/ref/docExamples.BlinkerSpec/verilog.sv2009/hdl/Blinker.sv @@ -15,6 +15,9 @@ module Blinker#( /* Half-count of the toggle for 50% duty cycle */ localparam int HALF_PERIOD = (CLK_FREQ_KHz * 1000) / (LED_FREQ_Hz * 2); logic [$clog2(HALF_PERIOD) - 1:0] cnt; + if (!((HALF_PERIOD - 1) >= 0)) begin : constraint_0 + $fatal(1, "Design parameter violation found. Expected: (HALF_PERIOD - 1) >= 0"); + end always_ff @(posedge clk) begin if (rst == 1'b1) begin diff --git a/lib/src/test/resources/ref/docExamples.BlinkerSpec/verilog.v2001/hdl/Blinker.v b/lib/src/test/resources/ref/docExamples.BlinkerSpec/verilog.v2001/hdl/Blinker.v index 56bbe1117..3f804298e 100644 --- a/lib/src/test/resources/ref/docExamples.BlinkerSpec/verilog.v2001/hdl/Blinker.v +++ b/lib/src/test/resources/ref/docExamples.BlinkerSpec/verilog.v2001/hdl/Blinker.v @@ -15,6 +15,12 @@ module Blinker#( /* Half-count of the toggle for 50% duty cycle */ parameter integer HALF_PERIOD = (CLK_FREQ_KHz * 1000) / (LED_FREQ_Hz * 2); reg [clog2(HALF_PERIOD) - 1:0] cnt; + initial begin : constraint_0 + if (!((HALF_PERIOD - 1) >= 0)) begin + $display("FATAL: Design parameter violation found. Expected: (HALF_PERIOD - 1) >= 0"); + $finish; + end + end always @(posedge clk) begin if (rst == 1'b1) begin diff --git a/lib/src/test/resources/ref/docExamples.BlinkerSpec/verilog.v95/hdl/Blinker.v b/lib/src/test/resources/ref/docExamples.BlinkerSpec/verilog.v95/hdl/Blinker.v index 9f610875a..e41f72e54 100644 --- a/lib/src/test/resources/ref/docExamples.BlinkerSpec/verilog.v95/hdl/Blinker.v +++ b/lib/src/test/resources/ref/docExamples.BlinkerSpec/verilog.v95/hdl/Blinker.v @@ -17,6 +17,12 @@ module Blinker( /* LED output */ output reg led; reg [clog2(HALF_PERIOD) - 1:0] cnt; + initial begin : constraint_0 + if (!((HALF_PERIOD - 1) >= 0)) begin + $display("FATAL: Design parameter violation found. Expected: (HALF_PERIOD - 1) >= 0"); + $finish; + end + end always @(posedge clk) begin if (rst == 1'b1) begin diff --git a/lib/src/test/resources/ref/docExamples.BlinkerSpec/vhdl.v2008/hdl/Blinker.vhd b/lib/src/test/resources/ref/docExamples.BlinkerSpec/vhdl.v2008/hdl/Blinker.vhd index 966e66e1c..54546737d 100644 --- a/lib/src/test/resources/ref/docExamples.BlinkerSpec/vhdl.v2008/hdl/Blinker.vhd +++ b/lib/src/test/resources/ref/docExamples.BlinkerSpec/vhdl.v2008/hdl/Blinker.vhd @@ -22,6 +22,8 @@ architecture Blinker_arch of Blinker is constant HALF_PERIOD : integer := (CLK_FREQ_KHz * 1000) / (LED_FREQ_Hz * 2); signal cnt : unsigned(clog2(HALF_PERIOD) - 1 downto 0); begin + constraint_0: assert (HALF_PERIOD - 1) >= 0 + report "Design parameter violation found. Expected: (HALF_PERIOD - 1) >= 0" severity FAILURE; process (clk) begin if rising_edge(clk) then diff --git a/lib/src/test/resources/ref/docExamples.BlinkerSpec/vhdl.v93/hdl/Blinker.vhd b/lib/src/test/resources/ref/docExamples.BlinkerSpec/vhdl.v93/hdl/Blinker.vhd index 2bb469f06..b6a07f40b 100644 --- a/lib/src/test/resources/ref/docExamples.BlinkerSpec/vhdl.v93/hdl/Blinker.vhd +++ b/lib/src/test/resources/ref/docExamples.BlinkerSpec/vhdl.v93/hdl/Blinker.vhd @@ -23,6 +23,8 @@ architecture Blinker_arch of Blinker is signal led_sig : std_logic; signal cnt : unsigned(clog2(HALF_PERIOD) - 1 downto 0); begin + constraint_0: assert (HALF_PERIOD - 1) >= 0 + report "Design parameter violation found. Expected: (HALF_PERIOD - 1) >= 0" severity FAILURE; led <= led_sig; process (clk) begin diff --git a/lib/src/test/resources/ref/docExamples.UART_TxSpec/verilog.sv2009/hdl/UART_Tx.sv b/lib/src/test/resources/ref/docExamples.UART_TxSpec/verilog.sv2009/hdl/UART_Tx.sv index cb0d17516..11f0ca75e 100644 --- a/lib/src/test/resources/ref/docExamples.UART_TxSpec/verilog.sv2009/hdl/UART_Tx.sv +++ b/lib/src/test/resources/ref/docExamples.UART_TxSpec/verilog.sv2009/hdl/UART_Tx.sv @@ -26,6 +26,9 @@ module UART_Tx#( logic [$clog2(BIT_CLOCKS) - 1:0] bitClkCnt; logic [2:0] dataBitCnt; logic [7:0] shiftData; + if (!((BIT_CLOCKS - 1) >= 0)) begin : constraint_0 + $fatal(1, "Design parameter violation found. Expected: (BIT_CLOCKS - 1) >= 0"); + end always_ff @(posedge clk) begin if (rst == 1'b1) begin diff --git a/lib/src/test/resources/ref/docExamples.UART_TxSpec/verilog.v2001/hdl/UART_Tx.v b/lib/src/test/resources/ref/docExamples.UART_TxSpec/verilog.v2001/hdl/UART_Tx.v index 485af195f..c131099ab 100644 --- a/lib/src/test/resources/ref/docExamples.UART_TxSpec/verilog.v2001/hdl/UART_Tx.v +++ b/lib/src/test/resources/ref/docExamples.UART_TxSpec/verilog.v2001/hdl/UART_Tx.v @@ -37,6 +37,12 @@ module UART_Tx#( reg [clog2(BIT_CLOCKS) - 1:0] bitClkCnt; reg [2:0] dataBitCnt; reg [7:0] shiftData; + initial begin : constraint_0 + if (!((BIT_CLOCKS - 1) >= 0)) begin + $display("FATAL: Design parameter violation found. Expected: (BIT_CLOCKS - 1) >= 0"); + $finish; + end + end always @(posedge clk) begin if (rst == 1'b1) begin diff --git a/lib/src/test/resources/ref/docExamples.UART_TxSpec/verilog.v95/hdl/UART_Tx.v b/lib/src/test/resources/ref/docExamples.UART_TxSpec/verilog.v95/hdl/UART_Tx.v index cf034ab6c..0eb6a02c1 100644 --- a/lib/src/test/resources/ref/docExamples.UART_TxSpec/verilog.v95/hdl/UART_Tx.v +++ b/lib/src/test/resources/ref/docExamples.UART_TxSpec/verilog.v95/hdl/UART_Tx.v @@ -43,6 +43,12 @@ module UART_Tx( reg [clog2(BIT_CLOCKS) - 1:0] bitClkCnt; reg [2:0] dataBitCnt; reg [7:0] shiftData; + initial begin : constraint_0 + if (!((BIT_CLOCKS - 1) >= 0)) begin + $display("FATAL: Design parameter violation found. Expected: (BIT_CLOCKS - 1) >= 0"); + $finish; + end + end always @(posedge clk) begin if (rst == 1'b1) begin diff --git a/lib/src/test/resources/ref/docExamples.UART_TxSpec/vhdl.v2008/hdl/UART_Tx.vhd b/lib/src/test/resources/ref/docExamples.UART_TxSpec/vhdl.v2008/hdl/UART_Tx.vhd index 01a7626db..24ab5edf9 100644 --- a/lib/src/test/resources/ref/docExamples.UART_TxSpec/vhdl.v2008/hdl/UART_Tx.vhd +++ b/lib/src/test/resources/ref/docExamples.UART_TxSpec/vhdl.v2008/hdl/UART_Tx.vhd @@ -29,6 +29,8 @@ architecture UART_Tx_arch of UART_Tx is signal dataBitCnt : unsigned(2 downto 0); signal shiftData : std_logic_vector(7 downto 0); begin + constraint_0: assert (BIT_CLOCKS - 1) >= 0 + report "Design parameter violation found. Expected: (BIT_CLOCKS - 1) >= 0" severity FAILURE; process (clk) begin if rising_edge(clk) then diff --git a/lib/src/test/resources/ref/docExamples.UART_TxSpec/vhdl.v93/hdl/UART_Tx.vhd b/lib/src/test/resources/ref/docExamples.UART_TxSpec/vhdl.v93/hdl/UART_Tx.vhd index dc1546f6c..8f27d75db 100644 --- a/lib/src/test/resources/ref/docExamples.UART_TxSpec/vhdl.v93/hdl/UART_Tx.vhd +++ b/lib/src/test/resources/ref/docExamples.UART_TxSpec/vhdl.v93/hdl/UART_Tx.vhd @@ -29,6 +29,8 @@ architecture UART_Tx_arch of UART_Tx is signal dataBitCnt : unsigned(2 downto 0); signal shiftData : std_logic_vector(7 downto 0); begin + constraint_0: assert (BIT_CLOCKS - 1) >= 0 + report "Design parameter violation found. Expected: (BIT_CLOCKS - 1) >= 0" severity FAILURE; process (clk) begin if rising_edge(clk) then From 22ddf0092912bd25b9898738ffc335f266371520 Mon Sep 17 00:00:00 2001 From: Oron Port Date: Wed, 12 Aug 2026 12:25:26 +0300 Subject: [PATCH 35/40] git: line endings are LF in the working tree, not just the index The index has only ever held LF, but a Windows clone with core.autocrlf=true expands that to CRLF on checkout while much of the tooling writes LF back, so files drift into mixed endings and git announces the round-trip every time it touches one. Of the 911 tracked files here, 369 had CRLF and 28 had both within a single file. Attributes override whatever core.autocrlf each developer happens to have, which puts the policy in the repository rather than in every clone's configuration and makes a checkout byte-identical on every platform. Batch files are the exception and keep CRLF, since cmd.exe wants it. The binary formats are listed explicitly so that no text heuristic can misfire and corrupt one with an EOL conversion, even though git detects them on its own today. The working tree was converted in place to match, which changes no file's content. The same policy landed in the platforms, ips and benchmarks submodules, whose indexes were likewise already LF. Co-Authored-By: Claude Opus 5 (1M context) --- .gitattributes | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..15825c5b7 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,18 @@ +# Line endings are LF everywhere: in the repository and in the working tree. +# These rules override each developer's core.autocrlf / core.eol settings, so +# checkouts are byte-identical on every platform. +* text=auto eol=lf + +# Windows batch scripts are the exception: cmd.exe wants CRLF. +*.bat text eol=crlf +*.cmd text eol=crlf + +# Binary formats. Git auto-detects these, but be explicit so no heuristic can +# misfire and corrupt them with an EOL conversion. +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.pdf binary +*.zip binary +*.jar binary From 42cac8f86ae2bc9d14f4379a43a80f321cde24d5 Mon Sep 17 00:00:00 2001 From: Oron Port Date: Wed, 12 Aug 2026 12:27:09 +0300 Subject: [PATCH 36/40] submodules: bump to the LF line-ending policy Each of platforms, ips and benchmarks now carries the same .gitattributes, so their recorded revisions move to it. That is the only change in all three. Co-Authored-By: Claude Opus 5 (1M context) --- benchmarks | 2 +- ips | 2 +- platforms | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/benchmarks b/benchmarks index 433a8f071..a97522630 160000 --- a/benchmarks +++ b/benchmarks @@ -1 +1 @@ -Subproject commit 433a8f071483a7f29ef7c6672d3693034933e9da +Subproject commit a97522630f4960adabe6b4241b6dc0b124700c6b diff --git a/ips b/ips index 561170bcc..e680cf1de 160000 --- a/ips +++ b/ips @@ -1 +1 @@ -Subproject commit 561170bcccbd2b2e228450996edccccbd178d3fd +Subproject commit e680cf1de90c79e306c56e44e82a0cdb9e7933e2 diff --git a/platforms b/platforms index e43dae7f3..caf254d2c 160000 --- a/platforms +++ b/platforms @@ -1 +1 @@ -Subproject commit e43dae7f368bfd7e556595dc050e26c9db1e642a +Subproject commit caf254d2c86303281578a7678f785dede3a944fd From ad5423fa212a42ac7646ae8048a5fc10d6d2c6af Mon Sep 17 00:00:00 2001 From: Oron Port Date: Wed, 12 Aug 2026 12:39:28 +0300 Subject: [PATCH 37/40] scalafmt update --- .../src/main/scala/dfhdl/compiler/ir/SubDesignEntry.scala | 4 ++-- .../stages/src/main/scala/dfhdl/sim/Interpreter.scala | 3 ++- compiler/stages/src/main/scala/dfhdl/sim/SimKernel.scala | 4 ++-- plugin/src/main/scala/plugin/MethodsPhase.scala | 8 ++++++-- 4 files changed, 12 insertions(+), 7 deletions(-) diff --git a/compiler/ir/src/main/scala/dfhdl/compiler/ir/SubDesignEntry.scala b/compiler/ir/src/main/scala/dfhdl/compiler/ir/SubDesignEntry.scala index 122bc3d1f..3b381c0f6 100644 --- a/compiler/ir/src/main/scala/dfhdl/compiler/ir/SubDesignEntry.scala +++ b/compiler/ir/src/main/scala/dfhdl/compiler/ir/SubDesignEntry.scala @@ -22,8 +22,8 @@ final case class SubDesignEntry( children: List[(StaticRef, SubDesignRef)] ) derives ReadWriter: - /** SANITY-LEVEL contract check, for tests and debugging only: deliberately NOT evaluated on - * the production store/lookup path (an O(members + refs) walk per entry would tax every cache + /** SANITY-LEVEL contract check, for tests and debugging only: deliberately NOT evaluated on the + * production store/lookup path (an O(members + refs) walk per entry would tax every cache * interaction to defend against states only a DFHDL bug or a dirty dev loop can produce; a * ghost-free elaboration is guaranteed by construction, and stale entries from other DFHDL * builds retire through the code digest's version fold, with `clearDFHDL` covering diff --git a/compiler/stages/src/main/scala/dfhdl/sim/Interpreter.scala b/compiler/stages/src/main/scala/dfhdl/sim/Interpreter.scala index 3a37f2c84..e6ebad0de 100644 --- a/compiler/stages/src/main/scala/dfhdl/sim/Interpreter.scala +++ b/compiler/stages/src/main/scala/dfhdl/sim/Interpreter.scala @@ -58,7 +58,8 @@ object Interpreter: private val commitTmp = new Array[Long](regOut.length) // kernel-owned memory backing store: one long per word (masked at write). Reads (Op.MEMRD) // observe the pre-commit contents; write ports apply after the sweep, like registers. - private val mem: Array[Array[Long]] = Array.tabulate(memDepth.length)(k => new Array[Long](memDepth(k))) + private val mem: Array[Array[Long]] = + Array.tabulate(memDepth.length)(k => new Array[Long](memDepth(k))) // memory-less designs skip the commit entirely: a final-false branch the JIT folds out, so the // per-cycle path is identical to before the memory node existed (no added call or loop) private val hasMem = memWrites.length > 0 diff --git a/compiler/stages/src/main/scala/dfhdl/sim/SimKernel.scala b/compiler/stages/src/main/scala/dfhdl/sim/SimKernel.scala index 13cb463a3..59c5ed94e 100644 --- a/compiler/stages/src/main/scala/dfhdl/sim/SimKernel.scala +++ b/compiler/stages/src/main/scala/dfhdl/sim/SimKernel.scala @@ -8,8 +8,8 @@ package dfhdl.sim */ trait SimKernel: /** Install the reset image of each memory (indexed by memory id) into the kernel's backing store. - * Called once after construction; memory-less kernels ignore it. The kernel owns the working copy - * that writes mutate; `mems` is the reset image and is not retained. + * Called once after construction; memory-less kernels ignore it. The kernel owns the working + * copy that writes mutate; `mems` is the reset image and is not retained. */ def initMem(mems: Array[Array[Long]]): Unit = () diff --git a/plugin/src/main/scala/plugin/MethodsPhase.scala b/plugin/src/main/scala/plugin/MethodsPhase.scala index 1d3c4430d..28fa84224 100644 --- a/plugin/src/main/scala/plugin/MethodsPhase.scala +++ b/plugin/src/main/scala/plugin/MethodsPhase.scala @@ -181,6 +181,7 @@ class MethodsPhase(setting: Setting) extends CapturePhase: ) hasHDLMethodErrors = true } + end if if (isStatic) // The inverse of the ED rule: a static function is a region in which every value is // constant, so a non-constant argument has no meaning in it. Its const args become @@ -210,7 +211,10 @@ class MethodsPhase(setting: Setting) extends CapturePhase: // those parameter symbols are exempt from the "no `:==` in an ED method" body rule val nbArgSyms = dfValArgs.view.filter(_.tpt.tpe.isDFPortOUTNB).map(_.symbol).toSet checkHDLMethodContent( - anonDef, isStatic, isEDMethod && hasUnitRet(anonDef), nbArgSyms + anonDef, + isStatic, + isEDMethod && hasUnitRet(anonDef), + nbArgSyms ) { (msg, pos) => report.error(msg, pos) @@ -459,7 +463,7 @@ class MethodsPhase(setting: Setting) extends CapturePhase: // sits several `Apply`/`TypeApply` layers down the curried `:==` spine (extension // receiver, then rhs, then `using DFC`), so gather every argument along the spine. def spineArgs(t: Tree): List[Tree] = t match - case Apply(fun, as) => as ++ spineArgs(fun) + case Apply(fun, as) => as ++ spineArgs(fun) case TypeApply(fun, _) => spineArgs(fun) case _ => Nil val targetsNBArg = spineArgs(ap).exists(a => nbArgSyms.contains(a.symbol)) From ce316f4c5eba5d080d3a2e3a66590878a153cf17 Mon Sep 17 00:00:00 2001 From: Oron Port Date: Wed, 12 Aug 2026 13:50:54 +0300 Subject: [PATCH 38/40] core: a `.truncate` decides against target-context widening, and a widened `>>` states what it assumed Target-context widening re-evaluates an anonymous cone at the target width on the strength of agreeing with the narrow evaluation whatever the target turns out to be. That holds for `+`/`-`/`*`, which commute with truncation, and for `<<`, which is a multiplication and commutes with it too. It does not hold for `>>`, which drops the bits a narrow evaluation brings down, so the agreement rests on the target really being at least as wide as the operand. Where the two widths cannot be compared the rule assumes it, and the design now states what was assumed. That assumption is the same relation the assignment's own width fit already states about the same cone, a shift's type being its left operand's type, and the two dedup. The one place it is the sole statement of it is under a width-adjustment permission, which is what makes the assignment skip its fit. Which is how the permission turned out to be fighting the widening. `.truncate` states that the target is NARROWER, the exact contradiction of what an undecided comparison optimistically assumes, and the widening was winning: the cone was re-evaluated at the target, which for `>>` computes something else than what was written, leaving the design to state both directions at once and hold for a single width. An undecided comparison now reads the permission, so `.truncate` is what decides there and the cone keeps its own width to narrow as asked. Only there: a permission whose direction does not apply contributes nothing, so a provably wider target widens as it always did. Co-Authored-By: Claude Opus 5 (1M context) --- .../StagesSpec/PrintCodeStringSpec.scala | 46 +++++++++++++++++++ .../scala/dfhdl/core/AutoConstraint.scala | 8 ++++ .../main/scala/dfhdl/core/CarryPromote.scala | 20 +++++++- 3 files changed, 72 insertions(+), 2 deletions(-) diff --git a/compiler/stages/src/test/scala/StagesSpec/PrintCodeStringSpec.scala b/compiler/stages/src/test/scala/StagesSpec/PrintCodeStringSpec.scala index dfe8402dd..3b121517c 100644 --- a/compiler/stages/src/test/scala/StagesSpec/PrintCodeStringSpec.scala +++ b/compiler/stages/src/test/scala/StagesSpec/PrintCodeStringSpec.scala @@ -3471,6 +3471,52 @@ class PrintCodeStringSpec extends StageSpec(stageCreatesUnrefAnons = true): |""".stripMargin ) } + test("a widened `>>` states its assumption, and a `.truncate` decides against widening") { + // Target-context widening re-evaluates a cone at the target on the strength of agreeing with + // the narrow evaluation whatever the target turns out to be. That holds for `+`/`-`/`*`, and + // for `<<`, all of which commute with truncation. It does not hold for `>>`, which drops the + // bits a narrow evaluation brings down, so a widened `>>` states the relation it assumed. + // Normally the assignment's own width fit states the same relation and the two dedup. + class ShiftExtend(val W: Int <> CONST = 8) extends EDDesign: + val a, b = UInt(W) <> IN + val shr = UInt(16) <> OUT + shr <> ((a + b) >> 1).extend + end ShiftExtend + // `.truncate` states the opposite of what an undecided comparison assumes, so it is what + // decides there: the cone keeps its own width and narrows as asked, rather than the widening + // firing optimistically and the design being left to state both directions at once. + class ShiftTruncate(val W: Int <> CONST = 8) extends EDDesign: + val a, b = UInt(W) <> IN + val shr = UInt(16) <> OUT + val shl = UInt(16) <> OUT + shr <> ((a + b) >> 1).truncate + shl <> ((a + b) << 1).truncate + end ShiftTruncate + assertCodeString( + ShiftExtend(), + """|class ShiftExtend(val W: Int <> CONST = 8) extends EDDesign: + | val a = UInt(W) <> IN + | val b = UInt(W) <> IN + | val shr = UInt(16) <> OUT + | shr <> ((a.resize(16) + b.resize(16)) >> 1) + | val constraint_0 = assert(16 >= W, s"Design parameter violation found. Expected: 16 >= W", Severity.Fatal) + |end ShiftExtend + |""".stripMargin + ) + assertCodeString( + ShiftTruncate(), + """|class ShiftTruncate(val W: Int <> CONST = 8) extends EDDesign: + | val a = UInt(W) <> IN + | val b = UInt(W) <> IN + | val shr = UInt(16) <> OUT + | val shl = UInt(16) <> OUT + | shr <> ((a + b) >> 1).resize(16) + | shl <> ((a + b) << 1).resize(16) + | val constraint_0 = assert(W >= 16, s"Design parameter violation found. Expected: W >= 16", Severity.Fatal) + |end ShiftTruncate + |""".stripMargin + ) + } test("auto constraint from a wildcard `Int` parameter's value") { // A wildcard `Int` adapts, and a Scala `Int`'s minimum WIDTH is what bounds the adaptation. An // overridable parameter has no width at all, for this elaboration or any other, so the bound diff --git a/core/src/main/scala/dfhdl/core/AutoConstraint.scala b/core/src/main/scala/dfhdl/core/AutoConstraint.scala index a0c5160d1..184e54359 100644 --- a/core/src/main/scala/dfhdl/core/AutoConstraint.scala +++ b/core/src/main/scala/dfhdl/core/AutoConstraint.scala @@ -90,6 +90,14 @@ object AutoConstraint: Some(guard.asIR.setTags(_.tag(ir.AutoConstraint))) else None + /** [[raise]]s `lhs >= rhs`, unless the relation is decided either way, which is the same + * three-way every width relation takes: a decided one either holds for every parameter + * assignment or was the caller's to reject, and neither is an assumption to state. + */ + def raiseUndecidedFit(lhs: IntParam[Int], rhs: IntParam[Int])(using DFC): Unit = + if (widthFitGE(lhs, rhs).isEmpty) raise(ge(lhs, rhs)) + () + /** [[raise]], for an assumption a specific VALUE makes: the constraint stands as long as that * value does, and is [[retract]]ed when something supersedes it. * diff --git a/core/src/main/scala/dfhdl/core/CarryPromote.scala b/core/src/main/scala/dfhdl/core/CarryPromote.scala index f58b8d153..bf89020b6 100644 --- a/core/src/main/scala/dfhdl/core/CarryPromote.scala +++ b/core/src/main/scala/dfhdl/core/CarryPromote.scala @@ -68,11 +68,17 @@ private[core] object CarryPromote: // the TC conversion: `16 > WIDTH max 16` decides as `16 > 16` (no widening), so the // anonymous form resolves exactly like a named intermediate value; if still // undecidable, optimistically assume the target is wider. + // A `.truncate` permission states that the target is NARROWER, which is the exact + // contradiction of what an undecided comparison optimistically assumes, so where the + // widths cannot be compared the author's statement is the one that decides and the + // value keeps its own width to narrow as asked. Only there: a permission whose + // direction does not apply contributes nothing, so a provably wider target widens as + // it always did, `.truncate` or no `.truncate`. def contextWidenCheck(valDFType: ir.DFType): Boolean = widthRefOpt(valDFType).exists { valWidthRef => dfType.asIR.magnitudeWidthParamRef .compare(valWidthRef, elimSymbolicMaxMin = true)(_ > _) - .getOrElse(true) + .getOrElse(!lhsIR.tags.hasTagOf[ir.TruncateTag]) } // The widened Func is BUILT FRESH rather than revised in place (an anonymous @@ -142,11 +148,21 @@ private[core] object CarryPromote: // conversion cannot move to the operands; the explicit spelling states the // intent there. case func @ ir.DFVal.Func( - dfType = ir.DFDecimal(funcSigned, _, 0, BitAccurate), + dfType = ir.DFDecimal(funcSigned, opWidthRef, 0, BitAccurate), op = FuncOp.>> | FuncOp.<< ) if func.isAnonymous && funcSigned == dfType.asIR.signed && contextWidenCheck(func.dfType) => + // A `>>` is the one widening that CONSUMES an assumption. The rule re-evaluates a cone + // at the target width on the strength of agreeing with the narrow evaluation whatever + // the target turns out to be, which holds for `+`/`-`/`*` because truncation commutes + // with them, and for `<<` because it is a multiplication and commutes too. It does not + // hold for `>>`: `(x mod 2^t) >> k` drops the bits above `t` that `(x >> k) mod 2^t` + // brings down. So the agreement rests on the target really being at least as wide as + // the operand, which the decision above assumes where it cannot prove it, and the + // design states what was assumed. + if (func.op == FuncOp.>>) + AutoConstraint.raiseUndecidedFit(dfType.widthIntParam, opWidthRef.get) Some(rebuilt(func, widenedArg(func.args.head).asIR :: func.args.tail.map(_.get))) case func @ ir.DFVal.Func( dfType = ir.DFUInt(_) | ir.DFSInt(_), From ed7b3572073fb98fa7bdac69914f5c5c62d04daf Mon Sep 17 00:00:00 2001 From: Oron Port Date: Wed, 12 Aug 2026 14:13:28 +0300 Subject: [PATCH 39/40] internals+options+tools: a console reset and ANSI color need a terminal to land on (#463) `clearConsole` defaulted to true whenever scala-cli or Metals was detected, so every run printed `ESC c` no matter where the output went: on a real terminal that discarded the scrollback above it, and in captured output it left a stray `c` glued to the banner. Detecting the launcher says nothing about whether anything can meaningfully be cleared. `isTTY` answers that question, and `isColorTerminal` answers the adjacent one for the printer's ANSI codes, which had the same defect of being emitted into pipes and log files. `System.console() != null` is not the test on every JDK: since JDK 22 a Console can also be returned for redirected streams, and only `Console.isTerminal()` tells the two apart. We emit Java 17 bytecode, so that method is looked up reflectively, and its absence (JDK 17-21) means a non-null Console already implies a terminal. Color additionally keeps the launchers that capture our streams and forward them to a console of their own: sbt (whose server JVM owns no terminal at all under `sbtn`), IntelliJ IDEA and Metals all render ANSI on the other end, so gating color on `isTTY` alone would have turned the entire sbt dev flow monochrome. `NO_COLOR` and `TERM=dumb` opt out, and `FORCE_COLOR` opts back in for a terminal we cannot detect as one, such as MinTTY under Git Bash on Windows. The tool runner's `os.Inherit` choice moves to `isTTY` as well, which is the same decision it already made on any JDK below 22 and the correct one above it. Co-Authored-By: Claude Opus 5 (1M context) --- .../scala/dfhdl/options/PrinterOptions.scala | 8 +++-- .../main/scala/dfhdl/internals/helpers.scala | 36 +++++++++++++++++++ .../main/scala/dfhdl/options/AppOptions.scala | 7 ++-- .../scala/dfhdl/tools/toolsCore/Tool.scala | 6 ++-- 4 files changed, 49 insertions(+), 8 deletions(-) diff --git a/compiler/ir/src/main/scala/dfhdl/options/PrinterOptions.scala b/compiler/ir/src/main/scala/dfhdl/options/PrinterOptions.scala index 715137eac..22994a0cb 100644 --- a/compiler/ir/src/main/scala/dfhdl/options/PrinterOptions.scala +++ b/compiler/ir/src/main/scala/dfhdl/options/PrinterOptions.scala @@ -2,7 +2,7 @@ package dfhdl.options import dfhdl.compiler.ir import dfhdl.internals.simplePattenToRegex import dfhdl.options.PrinterOptions.* -import dfhdl.internals.scastieIsRunning +import dfhdl.internals.{scastieIsRunning, isColorTerminal} final case class PrinterOptions( align: Align, @@ -35,8 +35,10 @@ object PrinterOptions: into opaque type Color <: Boolean = Boolean object Color: - // disabling color if in Scastie because of https://github.com/scalacenter/scastie/issues/492 - given Color = !scastieIsRunning + // disabling color if in Scastie because of https://github.com/scalacenter/scastie/issues/492, + // and wherever the output is not going somewhere that renders ANSI (a pipe, a redirect, a log + // file), which would otherwise litter the captured text with escape sequences + given Color = !scastieIsRunning && isColorTerminal given Conversion[Boolean, Color] = identity into opaque type ShowGlobals <: Boolean = Boolean diff --git a/internals/src/main/scala/dfhdl/internals/helpers.scala b/internals/src/main/scala/dfhdl/internals/helpers.scala index d6dcf1f33..386c19975 100644 --- a/internals/src/main/scala/dfhdl/internals/helpers.scala +++ b/internals/src/main/scala/dfhdl/internals/helpers.scala @@ -430,6 +430,42 @@ lazy val metalsIsRunning: Boolean = lazy val ideaIsRunning: Boolean = getShellCommand.exists(cmd => cmd.contains("idea_rt.jar=")) +// `java.io.Console.isTerminal()`, or None on a JDK older than 22, where it does not exist. The +// lookup goes through the (public) `Console` class rather than the instance's runtime class, which +// since JDK 22 may be a non-public proxy that reflection refuses to invoke through. +private lazy val consoleIsTerminalMethod: Option[java.lang.reflect.Method] = + try Some(classOf[java.io.Console].getMethod("isTerminal")) + catch case _: Exception => None + +// detecting if our standard streams are attached to an interactive terminal. +// `System.console() != null` is not enough on every JDK: since JDK 22 a Console can also be +// returned for redirected streams, and only `Console.isTerminal()` (added in that same release) +// tells the two apart. We emit Java 17 bytecode, so that method is invoked reflectively; where it +// is missing (JDK 17-21) a non-null Console already implies a terminal. +// Note this answers for OUR streams, and for both of them: the JDK requires stdin AND stdout to be +// a terminal, so redirecting either one makes this false. A launcher that captures our streams and +// forwards what we print to a terminal of its own therefore reports false here while still +// rendering color, which is what `isColorTerminal` accounts for. +lazy val isTTY: Boolean = + val console = System.console() + console != null && consoleIsTerminalMethod.forall { isTerminal => + try isTerminal.invoke(console).asInstanceOf[Boolean] + catch case _: Exception => true + } + +// detecting if the ANSI color we print will actually be rendered as color. +// A terminal we own (`isTTY`) qualifies, and so does a launcher that captured our streams and +// forwards our output to its own console: the sbt shell, the detached `sbtn` server, IntelliJ IDEA +// and Metals all render ANSI on the other end. Anything else (a pipe, a redirect, a log file) gets +// plain text. `NO_COLOR` (https://no-color.org) and `TERM=dumb` opt out either way, and +// `FORCE_COLOR` opts back in for a terminal we cannot detect as one, e.g. MinTTY under Git Bash on +// Windows, which the JDK sees as a pair of pipes rather than as a console. +lazy val isColorTerminal: Boolean = + if (sys.env.contains("NO_COLOR")) false + else if (sys.env.get("FORCE_COLOR").exists(_ != "0")) true + else if (sys.env.get("TERM").contains("dumb")) false + else isTTY || sbtIsRunning || ideaIsRunning || metalsIsRunning + def getRelativePath(absolutePathStr: String): String = import java.nio.file.Paths val absolutePath = Paths.get(absolutePathStr).toAbsolutePath() diff --git a/lib/src/main/scala/dfhdl/options/AppOptions.scala b/lib/src/main/scala/dfhdl/options/AppOptions.scala index f3151de09..6f22cd0ee 100644 --- a/lib/src/main/scala/dfhdl/options/AppOptions.scala +++ b/lib/src/main/scala/dfhdl/options/AppOptions.scala @@ -1,5 +1,5 @@ package dfhdl.options -import dfhdl.internals.{metalsIsRunning, scala_cliIsRunning} +import dfhdl.internals.{metalsIsRunning, scala_cliIsRunning, isTTY} import dfhdl.core.Design import AppOptions.* import dfhdl.compiler.ir.ConfigN @@ -31,7 +31,10 @@ object AppOptions: into opaque type ClearConsole <: Boolean = Boolean object ClearConsole: - given ClearConsole = if (metalsIsRunning || scala_cliIsRunning) true else false + // the console reset (`ESC c`) is only ever meaningful on an interactive terminal. Without the + // `isTTY` gate it also fires when the output is captured, where it clears nothing and instead + // corrupts the first line of the log with a stray escape sequence. + given ClearConsole = if (metalsIsRunning || scala_cliIsRunning) isTTY else false given Conversion[Boolean, ClearConsole] = identity into opaque type CacheEnable <: Boolean = Boolean diff --git a/lib/src/main/scala/dfhdl/tools/toolsCore/Tool.scala b/lib/src/main/scala/dfhdl/tools/toolsCore/Tool.scala index 8b5d2a7ea..c99eeda11 100644 --- a/lib/src/main/scala/dfhdl/tools/toolsCore/Tool.scala +++ b/lib/src/main/scala/dfhdl/tools/toolsCore/Tool.scala @@ -471,8 +471,8 @@ trait Tool: // when no logger is set we would like to inherit the parent's stdout/stderr so the tool keeps // its TTY (colors, live progress). however, os.Inherit writes to the JVM's real file // descriptors, which under `sbtn` belong to the detached build server rather than the client - // terminal, so the tool's output becomes invisible. when there is no real console (the `sbtn` - // case, and CI), fall back to reading the tool's lines and re-emitting them through + // terminal, so the tool's output becomes invisible. when we are not on an interactive terminal + // (the `sbtn` case, and CI), fall back to reading the tool's lines and re-emitting them through // System.out, which sbt forwards to the client. // Set once cancellation begins so the output pumper stops forwarding the tool's backlog: a killed // tool can leave a large buffered backlog that would otherwise keep trickling to the console @@ -481,7 +481,7 @@ trait Tool: val processOutput = loggerOpt.map(logger => os.ProcessOutput.Readlines(line => if (!aborted) logger.out(line)) ).getOrElse( - if (System.console() != null) os.Inherit + if (isTTY) os.Inherit else os.ProcessOutput.Readlines(line => if (!aborted) Tool.outputThrottle.gate() From 0e75838af317ba148741fedf768ec0ce33eeaa14 Mon Sep 17 00:00:00 2001 From: Oron Port Date: Wed, 12 Aug 2026 14:32:09 +0300 Subject: [PATCH 40/40] core: an LHS-dominant operation states the fit it needs rather than rejecting what it cannot prove `-`, `/` and `%` all take the LHS width and convert the RHS to it, so all three need the RHS to fit, and the shared arithmetic check already decided that three ways: proven, proven violated, and undecided, the last stated as a constraint of the design like every other adaptation states its own. `-` carried a proof of its own on top which rejected the undecided answer outright, so one operation in three answered "cannot tell" with "no" while its two siblings answered it. The apparent inconsistency between literal and parametric widths was not one. `u8 - u16` and `u8 / u16` are both compile errors, and a parametric pair that is violated for every valid assignment is still an elaboration error. Those are the provably-violated answer, which stays a rejection everywhere; only the undecided middle moves, and it moves to where the rest of the language already had it. The rejection was also advertising a remedy it refused: the check never read the RHS's width-adjustment permission, so `a - b.truncate` failed with the message recommending `.truncate`. The permission is now read for the width relation it covers, as the assignment and the comparison already read it, with signedness still checked either way since that is not a permission's to give. Co-Authored-By: Claude Opus 5 (1M context) --- .../StagesSpec/PrintCodeStringSpec.scala | 52 ++++++++++++++ .../src/main/scala/dfhdl/core/DFDecimal.scala | 72 +++++++++---------- .../test/scala/ElaborationChecksSpec.scala | 22 ++++++ 3 files changed, 106 insertions(+), 40 deletions(-) diff --git a/compiler/stages/src/test/scala/StagesSpec/PrintCodeStringSpec.scala b/compiler/stages/src/test/scala/StagesSpec/PrintCodeStringSpec.scala index 3b121517c..df2076a56 100644 --- a/compiler/stages/src/test/scala/StagesSpec/PrintCodeStringSpec.scala +++ b/compiler/stages/src/test/scala/StagesSpec/PrintCodeStringSpec.scala @@ -3517,6 +3517,58 @@ class PrintCodeStringSpec extends StageSpec(stageCreatesUnrefAnons = true): |""".stripMargin ) } + test("auto constraint from an LHS-dominant operation") { + // `-`, `/` and `%` take the LHS width and convert the RHS to it, so each needs the RHS to + // fit. `-` used to answer the undecided case with an outright rejection while its two + // siblings stated the fit, which answered "cannot tell" with "no" for one operation out of + // three. All three now state it, and one statement covers them. + class SubFit(val W: Int <> CONST = 16, val V: Int <> CONST = 8) extends EDDesign: + val a = UInt(W) <> IN + val b = UInt(V) <> IN + val diff = UInt(W) <> OUT + val quot = UInt(W) <> OUT + diff <> a - b + quot <> a / b + end SubFit + // `.truncate` is the permission for exactly this conversion, so it covers the fit and + // states its own direction instead + class SubTruncate(val W: Int <> CONST = 16, val V: Int <> CONST = 8) extends EDDesign: + val a = UInt(W) <> IN + val b = UInt(V) <> IN + val diff = UInt(W) <> OUT + diff <> a - b.truncate + end SubTruncate + assertCodeString( + SubFit(), + """|class SubFit( + | val W: Int <> CONST = 16, + | val V: Int <> CONST = 8 + |) extends EDDesign: + | val a = UInt(W) <> IN + | val b = UInt(V) <> IN + | val diff = UInt(W) <> OUT + | val quot = UInt(W) <> OUT + | diff <> (a - b.resize(W)) + | quot <> (a / b.resize(W)) + | val constraint_0 = assert(W >= V, s"Design parameter violation found. Expected: W >= V", Severity.Fatal) + |end SubFit + |""".stripMargin + ) + assertCodeString( + SubTruncate(), + """|class SubTruncate( + | val W: Int <> CONST = 16, + | val V: Int <> CONST = 8 + |) extends EDDesign: + | val a = UInt(W) <> IN + | val b = UInt(V) <> IN + | val diff = UInt(W) <> OUT + | diff <> (a - b.resize(W)) + | val constraint_0 = assert(V >= W, s"Design parameter violation found. Expected: V >= W", Severity.Fatal) + |end SubTruncate + |""".stripMargin + ) + } test("auto constraint from a wildcard `Int` parameter's value") { // A wildcard `Int` adapts, and a Scala `Int`'s minimum WIDTH is what bounds the adaptation. An // overridable parameter has no width at all, for this elaboration or any other, so the bound diff --git a/core/src/main/scala/dfhdl/core/DFDecimal.scala b/core/src/main/scala/dfhdl/core/DFDecimal.scala index 5ec5eb580..d9918ab53 100644 --- a/core/src/main/scala/dfhdl/core/DFDecimal.scala +++ b/core/src/main/scala/dfhdl/core/DFDecimal.scala @@ -467,23 +467,30 @@ object DFDecimal: (lhs.getActualSignedWidthOpt, rhs.getActualSignedWidthOpt) match case (Some(lhsSigned, lhsWidthIntOpt), Some(rhsSigned, rhsWidthIntOpt)) => checkS(lhsSigned, rhsSigned) - (lhsWidthIntOpt, rhsWidthIntOpt) match - case (Some(lhsWidth), Some(rhsWidth)) => - val rhsSignedWidth: Int = - if (lhsSigned && !rhsSigned) rhsWidth + 1 - else rhsWidth - checkW(lhsWidth, rhsSignedWidth) - case _ => - // a width is parametric, so the same relation is decided on the width - // EXPRESSIONS instead, and stated as a design constraint when undecidable - import IntParam.+ - val lhsWidthParam = lhs.getActualWidthParam(lhsWidthIntOpt) - val rhsWidthParam = rhs.getActualWidthParam(rhsWidthIntOpt) - val rhsSignedWidthParam = - if (lhsSigned && !rhsSigned) rhsWidthParam + 1 - else rhsWidthParam - widthFitCheck(lhsWidthParam, rhsSignedWidthParam) - end match + // A width-adjustment permission on the RHS covers the width relation, and only + // that: signedness is not a permission's to give, so it is checked above either + // way. The operation adapts the RHS to the LHS's type, which is exactly what + // `.truncate` gives permission for, so it has to be read here rather than left as + // advice the check then refuses. + val permitted = + AutoConstraint.permitsWidthAdjust(rhs, lhs.dfType.asIR.magnitudeWidthParamRef.get) + if (!permitted) + (lhsWidthIntOpt, rhsWidthIntOpt) match + case (Some(lhsWidth), Some(rhsWidth)) => + val rhsSignedWidth: Int = + if (lhsSigned && !rhsSigned) rhsWidth + 1 + else rhsWidth + checkW(lhsWidth, rhsSignedWidth) + case _ => + // a width is parametric, so the same relation is decided on the width + // EXPRESSIONS instead, and stated as a design constraint when undecidable + import IntParam.+ + val lhsWidthParam = lhs.getActualWidthParam(lhsWidthIntOpt) + val rhsWidthParam = rhs.getActualWidthParam(rhsWidthIntOpt) + val rhsSignedWidthParam = + if (lhsSigned && !rhsSigned) rhsWidthParam + 1 + else rhsWidthParam + widthFitCheck(lhsWidthParam, rhsSignedWidthParam) // the RHS is a wildcard `Int` whose value does not resolve (the LHS cannot be one // here), so it adapts to the LHS type with no width of its own to compare case (Some(_, _), None) => @@ -2055,30 +2062,15 @@ object DFXInt: val rhsFix = rhsSFix.toDFXIntOf(commonType)(using dfcAnon) arithOp(commonType, op.value, lhsFix, rhsFix).asInstanceOf[Out] else - // Both concrete, both wildcards, or only RHS is wildcard: LHS-dominant + // Both concrete, both wildcards, or only RHS is wildcard: LHS-dominant. These + // operations convert the RHS to the LHS's type, so the fit that conversion needs + // is `check`'s to decide, all three ways: proven, proven violated (an error, at + // compile time over resolved widths and at elaboration over parametric ones), and + // undecided, which states the fit as a constraint of the design like every other + // adaptation does. `-` used to reject the undecided answer outright, which + // answered "cannot tell" with "no" while `/` and `%`, LHS-dominant in exactly the + // same way, stated it. check(lhsVal, rhsVal) - // Subtraction is LHS-dominant, so an RHS the LHS cannot hold silently drops - // the difference's high bits. `check` decides that on RESOLVED widths only; - // a parametric relation must hold for EVERY valid assignment, so it is - // decided by proof here and an undecidable one is rejected (the carry form - // `-^`, or a `.resize`, states the intent instead). - if (op.value == FuncOp.- && !lhsVal.dfType.asIR.isDFInt32) - import dfc.getSet - import IntParam.+ - val lhsIR = lhsVal.dfType.asIR - val rhsIR = rhsVal.dfType.asIR - if (lhsIR.widthIntOpt.isEmpty || rhsIR.widthIntOpt.isEmpty) - // an unsigned RHS gains the sign bit it needs under a signed LHS - val rhsEffWidthRef = - if (lhsIR.signed && !rhsIR.signed) (rhsVal.widthIntParam + 1).ref - else rhsVal.widthIntParam.ref - if (!lhsVal.widthIntParam.ref.widthFitGE(rhsEffWidthRef).getOrElse(false)) - throw new IllegalArgumentException( - s"""|The RHS value width (${rhsIR.magnitudeWidthParamRef.refErrorString}) is not provably within the LHS variable width (${lhsIR.magnitudeWidthParamRef.refErrorString}). - |Subtraction takes the LHS width, so the difference may not fit. - |Consider the carry subtraction `-^`, or `.truncate` to narrow the RHS to the LHS width.""".stripMargin - ) - end if arithOp(lhsVal.dfType, op.value, lhsVal, rhsVal).asInstanceOf[Out] end if }(using dfc, CTName(op.value.toString)) diff --git a/lib/src/test/scala/ElaborationChecksSpec.scala b/lib/src/test/scala/ElaborationChecksSpec.scala index 1c198f4df..4307f2401 100644 --- a/lib/src/test/scala/ElaborationChecksSpec.scala +++ b/lib/src/test/scala/ElaborationChecksSpec.scala @@ -1829,4 +1829,26 @@ class ElaborationChecksSpec extends DesignSpec: val _ = WildcardArg() val _ = ProvablyEqual() + test("LHS-dominant width-fit proof rejection"): + object Test: + @top(false) class ProvablyNarrowSub(val W: Int <> CONST = 8) extends RTDesign: + val a = UInt(W) <> IN + val b = UInt(2 * W) <> IN + val diff = UInt(W) <> OUT + diff := a - b + end ProvablyNarrowSub + import Test.* + // `-` takes the LHS width and converts the RHS to it, so it needs the same fit `:=` does and + // takes the same three answers. The undecided one is a constraint of the design; this one is + // violated for every valid W, so it stays the rejection it always was, now through the + // shared width check rather than a rule of its own. + assertElaborationErrors(ProvablyNarrowSub())( + s"""|Elaboration errors found! + |DFiant HDL elaboration error! + |Position: ${currentFilePos}ElaborationChecksSpec.scala:1838:17 - 1838:22 + |Hierarchy: ProvablyNarrowSub + |Operation: `-` + |Message: The applied RHS value width (2 * W) is larger than the LHS variable width (W).""".stripMargin + ) + end ElaborationChecksSpec