Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions docs/minicroft.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,8 +134,7 @@ and so does a boot whose pipeline has no such subscriber β€” for example
`default_pipeline=M2V_PIPELINE` or an adapt-only pipeline β€” since nothing
will ever report training done. If an intent was registered, a subscriber
is present, and training never completes within `OVOSCOPE_TRAINED_TIMEOUT`
seconds (default: 180s when the `CI` environment variable is set, 5s
otherwise), `get_minicroft()` raises `RuntimeError` naming only the
seconds (default: 180s), `get_minicroft()` raises `RuntimeError` naming only the
skill(s) that registered an intent and never got a `mycroft.skills.trained`
reply β€” a stuck trainer in one skill never blames an unrelated, intentless
skill loaded alongside it. Pass `wait_for_trained=False` to opt out.
Expand Down Expand Up @@ -183,7 +182,7 @@ croft = get_minicroft(
)
```

When testing with N secondary languages, training overhead scales with the number of per-language containers β€” for example, a 17-locale suite may require 129 seconds for unconstrained training (on a system without resource limits). `max_wait` bounds only the wait for `READY`; it has no effect on the training wait that follows. The training wait is bounded by `OVOSCOPE_TRAINED_TIMEOUT`, and its default (180s under `CI`, 5s otherwise) is tuned for single-language loads. For multilingual suites, set `OVOSCOPE_TRAINED_TIMEOUT` large enough to accommodate all language engines, and raise `max_wait` too if reaching `READY` itself is slow with that many engines starting up:
When testing with N secondary languages, training overhead scales with the number of per-language containers β€” for example, a 17-locale suite may require 129 seconds for unconstrained training (on a system without resource limits). `max_wait` bounds only the wait for `READY`; it has no effect on the training wait that follows. The training wait is bounded by `OVOSCOPE_TRAINED_TIMEOUT`, and its default of 180s is tuned for single-language loads. For multilingual suites, set `OVOSCOPE_TRAINED_TIMEOUT` large enough to accommodate all language engines, and raise `max_wait` too if reaching `READY` itself is slow with that many engines starting up:

```python
import os
Expand Down
27 changes: 15 additions & 12 deletions ovoscope/__init__.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,18 @@
import inspect
import dataclasses
import gc
import json
import os
import threading
from copy import deepcopy
from time import sleep, time
from typing import Union, List, Dict, Any, Optional

Check failure on line 9 in ovoscope/__init__.py

View workflow job for this annotation

GitHub Actions / lint / lint

ruff (UP035)

ovoscope/__init__.py:9:1: UP035 `typing.Dict` is deprecated, use `dict` instead

Check failure on line 9 in ovoscope/__init__.py

View workflow job for this annotation

GitHub Actions / lint / lint

ruff (UP035)

ovoscope/__init__.py:9:1: UP035 `typing.List` is deprecated, use `list` instead

from ovos_bus_client.message import Message
from ovos_bus_client.session import SessionManager, Session
from ovos_config.config import Configuration
from ovos_config.models import LocalConf
from ovos_core.intent_services import IntentService

Check failure on line 15 in ovoscope/__init__.py

View workflow job for this annotation

GitHub Actions / lint / lint

ruff (F401)

ovoscope/__init__.py:15:39: F401 `ovos_core.intent_services.IntentService` imported but unused help: Remove unused import: `ovos_core.intent_services.IntentService`
from ovos_core.skill_manager import SkillManager
from ovos_plugin_manager.skills import find_skill_plugins
from ovos_utils.fakebus import FakeBus
Expand All @@ -23,8 +23,8 @@
from ovos_workshop.skills.api import SkillApi
from ovos_workshop.skills.ovos import OVOSSkill

Check failure on line 24 in ovoscope/__init__.py

View workflow job for this annotation

GitHub Actions / lint / lint

ruff (I001)

ovoscope/__init__.py:1:1: I001 Import block is un-sorted or un-formatted help: Organize imports

SerializedMessage = Dict[str, Union[str, Dict[str, Any]]]

Check failure on line 26 in ovoscope/__init__.py

View workflow job for this annotation

GitHub Actions / lint / lint

ruff (UP006)

ovoscope/__init__.py:26:42: UP006 Use `dict` instead of `Dict` for type annotation help: Replace with `dict`

Check failure on line 26 in ovoscope/__init__.py

View workflow job for this annotation

GitHub Actions / lint / lint

ruff (UP007)

ovoscope/__init__.py:26:31: UP007 Use `X | Y` for type annotations help: Convert to `X | Y`

Check failure on line 26 in ovoscope/__init__.py

View workflow job for this annotation

GitHub Actions / lint / lint

ruff (UP006)

ovoscope/__init__.py:26:21: UP006 Use `dict` instead of `Dict` for type annotation help: Replace with `dict`
SerializedTest = Dict[str, Union[str, bool, List[str], SerializedMessage]]

Check failure on line 27 in ovoscope/__init__.py

View workflow job for this annotation

GitHub Actions / lint / lint

ruff (UP006)

ovoscope/__init__.py:27:45: UP006 Use `list` instead of `List` for type annotation help: Replace with `list`

Check failure on line 27 in ovoscope/__init__.py

View workflow job for this annotation

GitHub Actions / lint / lint

ruff (UP007)

ovoscope/__init__.py:27:28: UP007 Use `X | Y` for type annotations help: Convert to `X | Y`

Check failure on line 27 in ovoscope/__init__.py

View workflow job for this annotation

GitHub Actions / lint / lint

ruff (UP006)

ovoscope/__init__.py:27:18: UP006 Use `dict` instead of `Dict` for type annotation help: Replace with `dict`

# Bus orchestration noise that is not part of any scenario's own message
# sequence: it fires from MiniCroft's boot/training machinery (see
Expand Down Expand Up @@ -182,6 +182,7 @@
"ovos-padacioso-pipeline-plugin-medium",
"ovos-fallback-pipeline-plugin-high",
"ovos-fallback-pipeline-plugin-medium",
"ovos-fallback-pipeline-plugin-low",
]

# Standard test pipeline β€” all standard built-in stages.
Expand Down Expand Up @@ -1182,18 +1183,20 @@
# for).
TRAINED_QUIET_WINDOW = 0.5

# The overall bound on the trained-wait is env-tunable so CI (slower, cold
# caches, contended runners) gets a generous default while local runs stay
# tight. Presence of the CI env var (not its value) selects the default.
# CI default is 180s: worst-case uninstrumented on taskset-2 was 16.8s, but
# fleet CI jobs run under coverage instrumentation on throttled 2-core shared
# VMs where a large single-skill intent set exceeded 60s in the field (weather:
# 262 trained-timeout failures at 60s; the alerts multilang fixture
# independently documents "under coverage instrumentation, booting reliably
# needs more than 60s"). 180s serves the real condition, costs nothing on
# healthy boots (quiet-window return), and the loud never-trained guard still
# fires.
_DEFAULT_TRAINED_TIMEOUT = 180.0 if os.environ.get("CI") else 5.0
# The overall bound on the trained-wait is env-tunable, but the default
# itself must be generous everywhere, not just under a CI env var: a 5s
# local default is shorter than plenty of real skills' training time, and a
# timeout here doesn't just fail the current test β€” it raises out of
# setUpClass, skipping tearDownClass, which leaves class-level monkeypatches
# and MiniCroft state leaked into later, unrelated test files. 180s: worst-case
# uninstrumented on taskset-2 was 16.8s, but fleet CI jobs run under coverage
# instrumentation on throttled 2-core shared VMs where a large single-skill
# intent set exceeded 60s in the field (weather: 262 trained-timeout failures
# at 60s; the alerts multilang fixture independently documents "under coverage
# instrumentation, booting reliably needs more than 60s"). 180s serves the
# real condition, costs nothing on healthy boots (quiet-window return), and
# the loud never-trained guard still fires.
_DEFAULT_TRAINED_TIMEOUT = 180.0


def get_minicroft(skill_ids: Union[List[str], str], *args,
Expand Down
64 changes: 34 additions & 30 deletions test/unittests/test_minicroft.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@

from ovoscope import (MiniCroft, get_minicroft, DEFAULT_TEST_PIPELINE,
LIGHT_TEST_PIPELINE, ADAPT_PIPELINE, LEAN_DEFAULT_PIPELINE,
M2V_PIPELINE, PERSONA_PIPELINE, is_pipeline_available)
M2V_PIPELINE, PERSONA_PIPELINE, is_pipeline_available,
_DEFAULT_TRAINED_TIMEOUT)

LEGACY_UTTERANCE = "recognizer_loop:utterance"
SPEC_UTTERANCE = str(SpecMessage.UTTERANCE) # ovos.utterance.handle
Expand Down Expand Up @@ -696,13 +697,20 @@ def tearDown(self):
LOG.set_level("CRITICAL")

def test_lean_default_excludes_heavy_pipelines(self):
"""LEAN_DEFAULT_PIPELINE must not reference m2v/persona/common_query/OCP."""
"""LEAN_DEFAULT_PIPELINE must not reference m2v/persona/common_query/OCP.

The fallback -low tier is intentionally included: fallback skills
registered above priority 90 (e.g. fallback-unknown at 100) are only
reachable through ovos-fallback-pipeline-plugin-low, and it is not a
"heavy" pipeline plugin in the sense this test guards against.
"""
for stage in LEAN_DEFAULT_PIPELINE:
self.assertNotIn("m2v", stage, f"m2v stage found: {stage}")
self.assertNotIn("persona", stage, f"persona stage found: {stage}")
self.assertNotIn("common-query", stage, f"common_query stage found: {stage}")
self.assertNotIn("ocp", stage, f"OCP stage found: {stage}")
self.assertNotIn("-low", stage, f"-low tier stage found: {stage}")
self.assertIn("ovos-fallback-pipeline-plugin-low", LEAN_DEFAULT_PIPELINE,
"fallback-low tier must be reachable in the lean default")

def test_lean_default_boots_only_lean_plugins(self):
"""A lean-default MiniCroft must not instantiate heavy pipeline
Expand Down Expand Up @@ -767,41 +775,37 @@ def test_bogus_pipeline_id_raises_naming_it(self):


class TestTrainedTimeoutDefaults(unittest.TestCase):
"""Verify that the OVOSCOPE_TRAINED_TIMEOUT default is 60s in CI and 5s locally.

This is a regression test ensuring the timeout scales appropriately: CI
(slower, cold caches) gets a generous default, while local runs stay tight.
"""
"""Guard the trained-wait default and the environment override."""

def setUp(self):
LOG.set_level("ERROR")
import os as os_module
self.os_module = os_module

def tearDown(self):
LOG.set_level("CRITICAL")

def test_ci_default_timeout_is_180_seconds(self):
"""When CI=true, the default computed timeout must be 180s."""
# Test the logic: when CI env var is present, default should be 180s
with patch.dict("os.environ", {"CI": "true"}):
timeout = 180.0 if self.os_module.environ.get("CI") else 5.0
self.assertEqual(timeout, 180.0,
"CI default timeout must be 180s to accommodate cold caches, "
"coverage instrumentation, and contended runners")

def test_local_default_timeout_is_5_seconds(self):
"""When CI is not set, the default computed timeout must be 5s."""
# Test the logic: when CI is absent, default should be 5s
def test_default_timeout_is_180_seconds(self):
"""The trained-wait default is 180s, with no CI distinction."""
self.assertEqual(_DEFAULT_TRAINED_TIMEOUT, 180.0,
"Trained-wait default must be 180s to accommodate cold "
"caches, coverage instrumentation, and contended runners")

def test_default_timeout_does_not_depend_on_ci(self):
"""Clearing or setting CI does not change the default."""
with patch.dict("os.environ", {}, clear=True):
timeout = 60.0 if self.os_module.environ.get("CI") else 5.0
self.assertEqual(timeout, 5.0,
"Local default timeout must be 5s for fast iteration")
self.assertEqual(_DEFAULT_TRAINED_TIMEOUT, 180.0)
with patch.dict("os.environ", {"CI": "true"}):
self.assertEqual(_DEFAULT_TRAINED_TIMEOUT, 180.0)

def test_ovoscope_trained_timeout_honors_env_var(self):
"""The OVOSCOPE_TRAINED_TIMEOUT env var is honored over the computed default."""
"""The OVOSCOPE_TRAINED_TIMEOUT env var wins over the default."""
with patch.dict("os.environ", {"OVOSCOPE_TRAINED_TIMEOUT": "120"}):
timeout_str = self.os_module.environ.get("OVOSCOPE_TRAINED_TIMEOUT")
timeout = float(timeout_str) if timeout_str else None
self.assertEqual(timeout, 120.0,
"OVOSCOPE_TRAINED_TIMEOUT env var should be respected")
timeout = float(os.environ.get("OVOSCOPE_TRAINED_TIMEOUT",
_DEFAULT_TRAINED_TIMEOUT))
self.assertEqual(timeout, 120.0)

def test_default_used_when_env_var_absent(self):
"""Without the env var the module default is what gets read."""
with patch.dict("os.environ", {}, clear=True):
timeout = float(os.environ.get("OVOSCOPE_TRAINED_TIMEOUT",
_DEFAULT_TRAINED_TIMEOUT))
self.assertEqual(timeout, 180.0)
Loading