[otbn,dv] Alter "loopy" configuration to see maximal loop depth

This lets us hit the FullToEmpty_C cover assertion in the loop
interface, which wants to see a loop of maximal depth, followed by the
loop stack becoming completely empty again.

Signed-off-by: Rupert Swarbrick <rswarbrick@lowrisc.org>
diff --git a/hw/ip/otbn/dv/rig/rig/config.py b/hw/ip/otbn/dv/rig/rig/config.py
index 7847120..a543128 100644
--- a/hw/ip/otbn/dv/rig/rig/config.py
+++ b/hw/ip/otbn/dv/rig/rig/config.py
@@ -4,7 +4,7 @@
 
 import os
 import random
-from typing import Dict, List, Optional, Set
+from typing import Dict, List, Optional, Set, Tuple
 
 from shared.yaml_parse_helpers import check_str, check_keys, load_yaml
 
@@ -45,6 +45,85 @@
             self.values[key] = self.get(key) * other.get(key)
 
 
+class MinMaxes:
+    '''An object representing a dict of maximum int values, indexed by string
+
+    Each key starts with either "min-" or "max-" (and we need to know which, so
+    that we can merge dictionaries properly).
+
+    '''
+    def __init__(self, what: str, yml: object):
+        if not isinstance(yml, dict):
+            raise ValueError('{} is expected to be a dict, '
+                             'but was actually a {}.'
+                             .format(what, type(yml).__name__))
+
+        self.min_values = {}  # type: Dict[str, int]
+        self.max_values = {}  # type: Dict[str, int]
+
+        for key, value in yml.items():
+            if not isinstance(key, str):
+                raise ValueError('{} had key {!r}, which is not a string.'
+                                 .format(what, key))
+            is_min = key.startswith('min-')
+            if not is_min:
+                if not key.startswith('max-'):
+                    raise ValueError('{} had key {!r}, which does not start '
+                                     'with "min-" or "max-".'
+                                     .format(what, key))
+
+            try:
+                ival = int(value)
+            except ValueError:
+                raise ValueError('{} at key {!r} had value {!r}, '
+                                 'which is not an integer.'
+                                 .format(what, key, value)) from None
+
+            if is_min:
+                self.min_values[key[4:]] = ival
+            else:
+                self.max_values[key[4:]] = ival
+
+    def get_min(self, key: str, default: Optional[int] = None) -> Optional[int]:
+        '''Get a minimum from the dictionary'''
+        return self.min_values.get(key, default)
+
+    def get_max(self, key: str, default: Optional[int] = None) -> Optional[int]:
+        '''Get a maximum from the dictionary'''
+        return self.max_values.get(key, default)
+
+    def get_range(self,
+                  key: str,
+                  default_min: int,
+                  default_max: int) -> Tuple[int, int]:
+        '''Get a (min, max) pair from the dictionary'''
+        return (self.min_values.get(key, default_min),
+                self.max_values.get(key, default_max))
+
+    def _merge_key(self, is_min: bool, key: str, other: 'MinMaxes') -> int:
+        '''Compute a merged value for key.
+
+        This should appear in either self or other.
+
+        '''
+        a = self.min_values.get(key) if is_min else self.max_values.get(key)
+        b = other.min_values.get(key) if is_min else other.max_values.get(key)
+        if a is None:
+            assert b is not None
+            return b
+        if b is None:
+            assert a is not None
+            return a
+
+        return max(a, b) if is_min else min(a, b)
+
+    def merge(self, other: 'MinMaxes') -> None:
+        for key in set(self.min_values.keys()) | set(other.min_values.keys()):
+            self.min_values[key] = self._merge_key(True, key, other)
+        for key in set(self.max_values.keys()) | set(other.max_values.keys()):
+            self.max_values[key] = self._merge_key(False, key, other)
+
+
 class Inheritance:
     '''One or more named parents, plus a weight'''
     def __init__(self, item_num: int, yml: object):
@@ -101,7 +180,7 @@
                  yml: object):
         yd = check_keys(yml, 'top-level',
                         [],
-                        ['gen-weights', 'insn-weights', 'inherit'])
+                        ['gen-weights', 'insn-weights', 'inherit', 'ranges'])
 
         # The most general form for the inherit field is a list of dictionaries
         # that get parsed into Inheritance objects. As a shorthand, these
@@ -134,10 +213,9 @@
                                                      parents, known_names)
 
         self.path = path
-        self.gen_weights = Weights('gen-weights',
-                                   yd.get('gen-weights', {}))
-        self.insn_weights = Weights('insn-weights',
-                                    yd.get('insn-weights', {}))
+        self.gen_weights = Weights('gen-weights', yd.get('gen-weights', {}))
+        self.insn_weights = Weights('insn-weights', yd.get('insn-weights', {}))
+        self.ranges = MinMaxes('ranges', yd.get('ranges', {}))
 
         if merged_ancestors is not None:
             self.merge(merged_ancestors)
@@ -175,3 +253,4 @@
     def merge(self, other: 'Config') -> None:
         self.gen_weights.merge(other.gen_weights)
         self.insn_weights.merge(other.insn_weights)
+        self.ranges.merge(other.ranges)
diff --git a/hw/ip/otbn/dv/rig/rig/configs/README.md b/hw/ip/otbn/dv/rig/rig/configs/README.md
index a2ae88c..029e33f 100644
--- a/hw/ip/otbn/dv/rig/rig/configs/README.md
+++ b/hw/ip/otbn/dv/rig/rig/configs/README.md
@@ -71,3 +71,16 @@
 
   As an even shorter shorthand, a one-item list whose only item is a
   string can be replaced by just that string.
+
+- ranges:
+
+  A dictionary of maximum or minimum values, keyed with names like
+  `max-FOO` or `min-FOO`, respectively.
+
+  Supported names:
+
+  - `max-loop-iters`: Used by the Loop generator to constrain the
+    maximum number of loop iterations that it will pick.
+  - `max-loop-tail-insns`: Used by the Loop generator to constrain the
+    maximum number of straight-line instructions it will generate as
+    part of a loop's tail.
diff --git a/hw/ip/otbn/dv/rig/rig/configs/loopy.yml b/hw/ip/otbn/dv/rig/rig/configs/loopy.yml
index 7048f1a..2a6e04d 100644
--- a/hw/ip/otbn/dv/rig/rig/configs/loopy.yml
+++ b/hw/ip/otbn/dv/rig/rig/configs/loopy.yml
@@ -3,10 +3,17 @@
 # SPDX-License-Identifier: Apache-2.0
 
 # An example custom configuration that generates lots of loops (100
-# times as many as the default config)
+# times as many as the default config), but constrains them not to
+# take too long. We also force the generator not to make long loop
+# tails (the straight-line code that appears up to and including the
+# last instruction of the loop). The idea is that we'll be much more
+# likely to get deeply nested loops this way.
 
 inherit: base
 
 gen-weights:
   Loop: 100
 
+ranges:
+  max-loop-iters: 2
+  max-loop-tail-insns: 4
diff --git a/hw/ip/otbn/dv/rig/rig/gens/loop.py b/hw/ip/otbn/dv/rig/rig/gens/loop.py
index dd55779..3736fb2 100644
--- a/hw/ip/otbn/dv/rig/rig/gens/loop.py
+++ b/hw/ip/otbn/dv/rig/rig/gens/loop.py
@@ -67,6 +67,18 @@
         else:
             self.loopi_prob = loopi_weight / sum_weights
 
+        self.cfg_max_iters = cfg.ranges.get_max('loop-iters')
+        if self.cfg_max_iters is not None and self.cfg_max_iters < 1:
+            raise RuntimeError(f'Invalid max-loop-iters value of '
+                               f'{self.cfg_max_iters}: this must be '
+                               f'at least 1.')
+
+        self.cfg_max_tail = cfg.ranges.get_max('loop-tail-insns')
+        if self.cfg_max_tail is not None and self.cfg_max_tail < 1:
+            raise RuntimeError(f'Invalid max-loop-tail-insns value of '
+                               f'{self.cfg_max_tail}: this must be '
+                               f'at least 1.')
+
     def _pick_loop_iterations(self,
                               max_iters: int,
                               model: Model) -> Optional[Tuple[int, int]]:
@@ -90,6 +102,9 @@
         # boring).
         max_iters = min(max_iters, 10)
 
+        if self.cfg_max_iters is not None:
+            max_iters = min(max_iters, self.cfg_max_iters)
+
         # Iterate over the known registers, trying to pick a weight
         poss_pairs = []  # type: List[Tuple[int, int]]
         weights = []  # type: List[float]
@@ -120,6 +135,12 @@
         assert iters_range is not None
         iters_lo, iters_hi = iters_range
 
+        # Constrain iters_hi if the max-loop-iters configuration value was set.
+        if self.cfg_max_iters is not None:
+            iters_hi = min(iters_hi, self.cfg_max_iters)
+            if iters_hi < iters_lo:
+                return None
+
         # Very occasionally, generate iters_hi iterations (the maximum number
         # representable) if we've got fuel for it. We don't do this often,
         # because the instruction sequence will end up just testing loop
@@ -324,6 +345,9 @@
         else:
             max_tail_len = min(bodysize, model.fuel - 1)
 
+        if self.cfg_max_tail is not None:
+            max_tail_len = min(max_tail_len, self.cfg_max_tail)
+
         # program.space gives another bound on the tail length. If the bodysize
         # is large enough that we'll need to jump to the tail, the tail can't
         # be more than program.space - 1 in length. If we don't need to