diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 00000000..c0084b41 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @NITISH-R-G \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/issue.yml b/.github/ISSUE_TEMPLATE/issue.yml new file mode 100644 index 00000000..5e8998d6 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/issue.yml @@ -0,0 +1,24 @@ +name: Standard Issue +description: Report a bug or request a feature +title: "[Issue]: " +labels: ["triage"] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to fill out this issue report! + - type: textarea + id: description + attributes: + label: Description + description: A clear and concise description of what the issue or feature request is. + placeholder: Tell us what you see, what you expect to see, or what you'd like to see added. + validations: + required: true + - type: textarea + id: steps + attributes: + label: Steps To Reproduce (if bug) + description: Steps to reproduce the behavior. + validations: + required: false diff --git a/.github/labeler.yml b/.github/labeler.yml new file mode 100644 index 00000000..9b920a62 --- /dev/null +++ b/.github/labeler.yml @@ -0,0 +1,15 @@ +python: + - all: + - changed-files: + - any-glob-to-any-file: '**/*.py' + +documentation: + - all: + - changed-files: + - any-glob-to-any-file: '**/*.md' + - any-glob-to-any-file: 'docs/**/*' + +frontend: + - all: + - changed-files: + - any-glob-to-any-file: 'web/**/*' diff --git a/.github/workflows/ai-insights.yml b/.github/workflows/ai-review.yml similarity index 72% rename from .github/workflows/ai-insights.yml rename to .github/workflows/ai-review.yml index 87f31e03..c1ff263a 100644 --- a/.github/workflows/ai-insights.yml +++ b/.github/workflows/ai-review.yml @@ -3,22 +3,20 @@ name: AI PR Agent on: pull_request: types: [opened, synchronize, reopened] - issue_comment: + pull_request_review_comment: types: [created] +permissions: + contents: read + pull-requests: write + jobs: pr_agent: runs-on: ubuntu-latest - permissions: - issues: write - pull-requests: write - contents: write name: Run PR Agent if: ${{ github.event.sender.type != 'Bot' }} steps: - - name: PR Agent action step - id: pragent - uses: Codium-ai/pr-agent@main + - uses: Codium-ai/pr-agent@main env: OPENAI_KEY: ${{ secrets.OPENAI_API_KEY }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 00000000..f68c44a0 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,38 @@ +name: "CodeQL Analysis" + +on: + push: + branches: [ "main", "master" ] + pull_request: + branches: [ "main", "master" ] + schedule: + - cron: '0 0 * * 0' # Weekly + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + + strategy: + fail-fast: false + matrix: + language: [ 'python', 'javascript' ] + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + + - name: Autobuild + uses: github/codeql-action/autobuild@v3 + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 diff --git a/.github/workflows/greetings.yml b/.github/workflows/greetings.yml new file mode 100644 index 00000000..2cef4478 --- /dev/null +++ b/.github/workflows/greetings.yml @@ -0,0 +1,20 @@ +name: Greetings + +on: + pull_request: + types: [opened] + issues: + types: [opened] + +jobs: + greeting: + runs-on: ubuntu-latest + permissions: + issues: write + pull-requests: write + steps: + - uses: actions/first-interaction@v1 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + issue-message: "Welcome to our repository! Thank you for raising an issue. A maintainer will check it out shortly." + pr-message: "Welcome to our repository! Thank you for your first pull request. Please make sure all checks pass and `./validate-submission.sh` was successful locally." diff --git a/.github/workflows/health-dashboard.yml b/.github/workflows/health-dashboard.yml index fb788a57..94b44724 100644 --- a/.github/workflows/health-dashboard.yml +++ b/.github/workflows/health-dashboard.yml @@ -45,10 +45,3 @@ jobs: with: name: health-dashboard path: dashboard_output/ - - - name: Deploy to GitHub Pages - if: github.ref == 'refs/heads/main' - uses: peaceiris/actions-gh-pages@v4 - with: - github_token: ${{ secrets.GITHUB_TOKEN }} - publish_dir: ./dashboard_output diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml new file mode 100644 index 00000000..860187aa --- /dev/null +++ b/.github/workflows/labeler.yml @@ -0,0 +1,16 @@ +name: "Pull Request Labeler" +on: + pull_request: + types: [opened, synchronize, reopened] + +jobs: + triage: + permissions: + contents: read + pull-requests: write + runs-on: ubuntu-latest + steps: + - uses: actions/labeler@v5 + with: + repo-token: "${{ secrets.GITHUB_TOKEN }}" + sync-labels: true diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 00000000..3b6d0a62 --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,33 @@ +name: Deploy GitHub Pages + +on: + workflow_run: + workflows: ["Repository Health Dashboard"] + types: + - completed + +permissions: + contents: write + pages: write + id-token: write + +jobs: + deploy: + runs-on: ubuntu-latest + if: ${{ github.event.workflow_run.conclusion == 'success' }} + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Download Dashboard Artifact + uses: dawidd6/action-download-artifact@v3 + with: + workflow: health-dashboard.yml + name: health-dashboard + path: ./dashboard_output + + - name: Deploy to GitHub Pages + uses: peaceiris/actions-gh-pages@v4 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ./dashboard_output diff --git a/.github/workflows/repo-maintenance.yml b/.github/workflows/repo-maintenance.yml new file mode 100644 index 00000000..d8f27372 --- /dev/null +++ b/.github/workflows/repo-maintenance.yml @@ -0,0 +1,63 @@ +name: Repository Maintenance + +on: + push: + branches: [ "main", "master" ] + schedule: + - cron: '0 0 * * *' # Daily at midnight UTC + +permissions: + contents: write + +jobs: + maintenance: + runs-on: ubuntu-latest + if: github.event_name == 'push' || github.event_name == 'schedule' || github.event.pull_request.head.repo.full_name == github.repository + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: ${{ github.head_ref || github.ref }} + lfs: true + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.10' + + - name: Install uv + run: pip install uv + + - name: Install dependencies + run: | + uv pip install --system -e ".[dev,demo]" + uv pip install --system cyclonedx-bom + + - name: Generate Knowledge Graph + run: | + mkdir -p artifacts + python tools/generate_knowledge_graph.py || echo "Knowledge graph generation failed, skipping." + + - name: Sync Documentation + run: | + mkdir -p docs + python tools/docs_sync.py || echo "Docs sync failed, skipping." + + - name: Generate Architecture Diagrams + run: | + mkdir -p artifacts + python tools/generate_architecture_diagrams.py || echo "Architecture diagram generation failed, skipping." + + - name: Generate SBOM + run: | + mkdir -p artifacts + cyclonedx-py environment -o artifacts/bom.json || echo "SBOM generation failed, skipping." + + - name: Commit and Push Changes + run: | + git config --global user.name 'github-actions[bot]' + git config --global user.email 'github-actions[bot]@users.noreply.github.com' + git add -A + git diff-index --quiet HEAD || git commit -m "chore(maintenance): automated repository updates" + git push diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml new file mode 100644 index 00000000..a415b7b3 --- /dev/null +++ b/.github/workflows/stale.yml @@ -0,0 +1,20 @@ +name: Close Stale Issues + +on: + schedule: + - cron: '30 1 * * *' + +permissions: + issues: write + pull-requests: write + +jobs: + stale: + runs-on: ubuntu-latest + steps: + - uses: actions/stale@v9 + with: + stale-issue-message: 'This issue is stale because it has been open 30 days with no activity. Remove stale label or comment or this will be closed in 7 days.' + stale-pr-message: 'This PR is stale because it has been open 45 days with no activity. Remove stale label or comment or this will be closed in 14 days.' + days-before-stale: 30 + days-before-close: 7 diff --git a/.gitignore b/.gitignore index 07ce3c76..87434467 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,6 @@ ev_grid_oracle.egg-info/ .coverage dashboard_output/ build/ + +.mypy_cache/ +.ruff_cache/ diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 00000000..6c85a138 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,49 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity +and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the + overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or + advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email + address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..aac2c221 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,25 @@ +# Contributing to the Repository + +First off, thank you for considering contributing to this repository. It's people like you that make open source such a great community. + +## 1. Where do I go from here? + +If you've noticed a bug or have a feature request, make one! It's generally best if you get confirmation of your bug or approval for your feature request this way before starting to code. + +## 2. Fork & create a branch + +If this is something you think you can fix, then fork and create a branch with a descriptive name. + +## 3. Implementation Guidelines + +- Strictly follow an Agile Scrum continuous improvement methodology. +- Maintain strong coding standards (SOLID, DRY). +- We use `ruff` for linting/formatting and `mypy` for 100% type-safety. +- Run local validation tools via `./validate-submission.sh`. No code is merged unless it passes formatting, type checking, and test suites. + +## 4. Make a Pull Request + +At this point, you should switch back to your master branch and make sure it's up to date with the main repository's master branch. +Then push your feature branch and create a Pull Request. + +Please describe what you did and why, and link to the relevant issue. diff --git a/ev_grid_oracle/bescom_feed.py b/ev_grid_oracle/bescom_feed.py index 470686ac..dc6945e2 100644 --- a/ev_grid_oracle/bescom_feed.py +++ b/ev_grid_oracle/bescom_feed.py @@ -85,7 +85,7 @@ def snapshot( return out def _stable_seed(self, *, seed: int, scenario: str, tick: int) -> int: - s = f"{seed}:{scenario}:{tick}".encode("utf-8") + s = f"{seed}:{scenario}:{tick}".encode() h = sha1(s, usedforsecurity=False).hexdigest()[:8] return int(h, 16) diff --git a/ev_grid_oracle/city_graph.py b/ev_grid_oracle/city_graph.py index a03a2a17..cc48984c 100644 --- a/ev_grid_oracle/city_graph.py +++ b/ev_grid_oracle/city_graph.py @@ -2,7 +2,6 @@ from dataclasses import dataclass from math import asin, cos, radians, sin, sqrt -from typing import Optional import networkx as nx @@ -266,7 +265,7 @@ def travel_time_minutes( from_station_id: str, to_station_id: str, *, - default_if_missing: Optional[float] = None, + default_if_missing: float | None = None, ) -> float: if from_station_id == to_station_id: return 0.0 diff --git a/ev_grid_oracle/env.py b/ev_grid_oracle/env.py index 149ead44..52c81ade 100644 --- a/ev_grid_oracle/env.py +++ b/ev_grid_oracle/env.py @@ -2,10 +2,10 @@ from dataclasses import dataclass, field from random import Random -from typing import Optional import networkx as nx +from .bescom_feed import BESCOMFeedAPI from .city_graph import STATIONS from .demand_sim import sample_arrivals_per_step from .grid_sim import update_grid_load @@ -19,10 +19,9 @@ PeakRisk, StationState, ) +from .personas import FleetMode, choose_persona from .reward import compute_reward from .reward_hack import RewardHackDetector -from .bescom_feed import BESCOMFeedAPI -from .personas import FleetMode, choose_persona from .scenarios import ( ScenarioEvent, ScenarioModifiers, @@ -46,7 +45,7 @@ class EVGridCore: max_steps: int = 48 step_minutes: int = 5 rng: Random = field(default_factory=Random) - _grid_state: Optional[GridState] = None + _grid_state: GridState | None = None scenario: ScenarioName = "baseline" _scenario_schedule: list[ScenarioEvent] = field(default_factory=list) _scenario_mods: ScenarioModifiers = field(default_factory=ScenarioModifiers) @@ -59,7 +58,7 @@ class EVGridCore: def reset( self, *, - seed: Optional[int] = None, + seed: int | None = None, scenario: ScenarioName = "baseline", fleet_mode: FleetMode = "mixed", ) -> EVGridObservation: @@ -180,8 +179,7 @@ def step(self, action: EVGridAction) -> EVGridObservation: new_total = self._scenario_mods.slot_derate.get(s.station_id) if new_total is not None and new_total < s.total_slots: s.total_slots = int(new_total) - if s.occupied_slots > s.total_slots: - s.occupied_slots = s.total_slots + s.occupied_slots = min(s.occupied_slots, s.total_slots) # 1) apply action (deterministic validation + state mutation) action_effect = _apply_action(prev_state, action) diff --git a/ev_grid_oracle/grid_sim.py b/ev_grid_oracle/grid_sim.py index 150ead29..373c29ce 100644 --- a/ev_grid_oracle/grid_sim.py +++ b/ev_grid_oracle/grid_sim.py @@ -15,7 +15,7 @@ class GridParams: def _clamp01(x: float) -> float: - return 0.0 if x < 0.0 else 1.0 if x > 1.0 else x + return 0.0 if x < 0.0 else min(x, 1.0) def baseline_grid_load( diff --git a/ev_grid_oracle/models.py b/ev_grid_oracle/models.py index 02201842..4af8e938 100644 --- a/ev_grid_oracle/models.py +++ b/ev_grid_oracle/models.py @@ -1,10 +1,9 @@ from __future__ import annotations from enum import Enum -from typing import Any, Literal, Optional +from typing import Any, Literal from openenv.core.env_server.types import Action, Observation - from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator @@ -110,12 +109,12 @@ class EVGridAction(Action): action_type: ActionType ev_id: str - station_id: Optional[str] = None + station_id: str | None = None charge_rate: ChargeRate = ChargeRate.fast defer_minutes: int = Field(0, ge=0) @model_validator(mode="after") - def _check_consistency(self) -> "EVGridAction": + def _check_consistency(self) -> EVGridAction: if self.action_type == ActionType.route: if not self.station_id: raise ValueError("station_id required when action_type='route'") diff --git a/ev_grid_oracle/oracle_agent.py b/ev_grid_oracle/oracle_agent.py index cc0503de..2b86eb72 100644 --- a/ev_grid_oracle/oracle_agent.py +++ b/ev_grid_oracle/oracle_agent.py @@ -1,14 +1,13 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Any, Optional, Tuple, cast +from typing import Any, cast from ev_grid_oracle.models import EVGridAction, GridState from ev_grid_oracle.parsing import parse_action from ev_grid_oracle.policies import baseline_policy - -_CACHE: dict[tuple[str, Optional[str]], tuple[Any, Any]] = {} +_CACHE: dict[tuple[str, str | None], tuple[Any, Any]] = {} _CACHE_LOCK = None @@ -69,7 +68,7 @@ class OracleAgent: Optional: load a trained LoRA adapter when `lora_repo_id` provided. """ - lora_repo_id: Optional[str] = None + lora_repo_id: str | None = None base_model_id: str = "unsloth/Qwen2.5-3B-Instruct" max_new_tokens: int = 140 @@ -129,7 +128,7 @@ def act(self, state: GridState, prompt: str, graph) -> EVGridAction: def act_with_text( self, state: GridState, prompt: str, graph - ) -> Tuple[EVGridAction, str]: + ) -> tuple[EVGridAction, str]: # choose target ev_id (matches env prompt builder v0) ev_id = state.pending_evs[0].ev_id if state.pending_evs else "EV-000" diff --git a/ev_grid_oracle/parsing.py b/ev_grid_oracle/parsing.py index daeaa81d..320ea1ec 100644 --- a/ev_grid_oracle/parsing.py +++ b/ev_grid_oracle/parsing.py @@ -1,17 +1,15 @@ from __future__ import annotations import re -from typing import Optional, Tuple from .models import ( ActionType, ChargeRate, EVGridAction, - SimulationPrediction, SimTopStation, + SimulationPrediction, ) - ACTION_RE = re.compile( r"ACTION:\s*(?Proute|defer|load_shift)\s*\n" r"STATION:\s*(?PBLR-\d\d|NONE)\s*\n" @@ -30,7 +28,7 @@ ) -def parse_simulation(text: str) -> Optional[SimulationPrediction]: +def parse_simulation(text: str) -> SimulationPrediction | None: m = SIM_RE.search(text) if not m: return None @@ -58,7 +56,7 @@ def parse_simulation(text: str) -> Optional[SimulationPrediction]: return None -def parse_action(text: str, *, ev_id: str) -> Optional[EVGridAction]: +def parse_action(text: str, *, ev_id: str) -> EVGridAction | None: m = ACTION_RE.search(text.strip()) if not m: return None @@ -84,7 +82,7 @@ def parse_action(text: str, *, ev_id: str) -> Optional[EVGridAction]: def parse_simulation_and_action( text: str, *, ev_id: str -) -> Tuple[Optional[SimulationPrediction], Optional[EVGridAction]]: +) -> tuple[SimulationPrediction | None, EVGridAction | None]: """ Parse both dream prediction and action (either can be missing). """ diff --git a/ev_grid_oracle/personas.py b/ev_grid_oracle/personas.py index eca963b8..23854148 100644 --- a/ev_grid_oracle/personas.py +++ b/ev_grid_oracle/personas.py @@ -4,7 +4,6 @@ from random import Random from typing import Literal - FleetPersona = Literal[ "TaxiFleet", "CorporateShuttle", "DeliveryFleet", "PrivateOwner", "Emergency" ] diff --git a/ev_grid_oracle/road_models.py b/ev_grid_oracle/road_models.py index 19953089..4ab8013e 100644 --- a/ev_grid_oracle/road_models.py +++ b/ev_grid_oracle/road_models.py @@ -1,6 +1,5 @@ from __future__ import annotations - from openenv.core.env_server.types import Action, Observation from pydantic import BaseModel, ConfigDict, Field, model_validator @@ -17,7 +16,7 @@ class RoadAction(Action): next_node: int = Field(..., ge=0) @model_validator(mode="after") - def _non_trivial(self) -> "RoadAction": + def _non_trivial(self) -> RoadAction: if self.current_node == self.next_node: raise ValueError("next_node must differ from current_node") return self diff --git a/ev_grid_oracle/scenarios.py b/ev_grid_oracle/scenarios.py index 22834898..ece3bb72 100644 --- a/ev_grid_oracle/scenarios.py +++ b/ev_grid_oracle/scenarios.py @@ -3,7 +3,6 @@ from dataclasses import dataclass from typing import Any, Literal, TypedDict - ScenarioName = Literal[ "baseline", "heatwave_peak", @@ -188,7 +187,7 @@ def apply_scenario_events( # Stable ids for bookmarks / UI (deterministic). for e in fired: - e.setdefault("id", f"{name}:{int(e['tick'])}:{str(e.get('type', ''))}") + e.setdefault("id", f"{name}:{int(e['tick'])}:{e.get('type', '')!s}") # Modifiers are "sticky": once an event changes a knob, it persists. for e in fired: diff --git a/ev_grid_oracle/traffic.py b/ev_grid_oracle/traffic.py index b8b74a1d..327ee4ac 100644 --- a/ev_grid_oracle/traffic.py +++ b/ev_grid_oracle/traffic.py @@ -6,7 +6,7 @@ def _clamp(x: float, lo: float, hi: float) -> float: - return lo if x < lo else hi if x > hi else x + return lo if x < lo else min(x, hi) def _stable_u01(*parts: object) -> float: diff --git a/ev_grid_oracle/world_model_verifier.py b/ev_grid_oracle/world_model_verifier.py index e0cb5894..af566423 100644 --- a/ev_grid_oracle/world_model_verifier.py +++ b/ev_grid_oracle/world_model_verifier.py @@ -95,7 +95,7 @@ def score_prediction( overlap_score = max(0.0, 1.0 - overlap_err) score = 0.40 * grid_score + 0.25 * ren_score + 0.20 * jacc + 0.15 * overlap_score - score = 0.0 if score < 0.0 else 1.0 if score > 1.0 else score + score = 0.0 if score < 0.0 else min(score, 1.0) return PredictionScore( score_0_1=float(score), breakdown={ diff --git a/server/app.py b/server/app.py index 3e14b3f3..993d271b 100644 --- a/server/app.py +++ b/server/app.py @@ -1,14 +1,15 @@ from __future__ import annotations import concurrent.futures +import hashlib +import logging import os -from pathlib import Path import time from collections import OrderedDict -import logging -from server.road_router import get_router +from pathlib import Path + from ev_grid_oracle.traffic import TrafficModel -import hashlib +from server.road_router import get_router try: from openenv.core.env_server.http_server import create_app @@ -17,43 +18,43 @@ from typing import Any, Literal, cast from uuid import uuid4 -from pydantic import BaseModel, Field +import networkx as nx from fastapi import Body, HTTPException, Query, Request from fastapi.responses import HTMLResponse from fastapi.staticfiles import StaticFiles +from pydantic import BaseModel, Field -from ev_grid_oracle.city_graph import build_city_graph, _BY_ID, _BY_SLUG -import networkx as nx +from ev_grid_oracle.city_graph import _BY_ID, _BY_SLUG, build_city_graph from ev_grid_oracle.env import EVGridCore, _build_prompt from ev_grid_oracle.models import ( ActionType, - EVRequest, EVGridAction, EVGridObservation, + EVRequest, GridDirective, MultiAgentStepRequest, NegotiationMessage, ) +from ev_grid_oracle.multi_agent import MultiAgentSession from ev_grid_oracle.oracle_agent import OracleAgent -from ev_grid_oracle.policies import baseline_policy from ev_grid_oracle.parsing import parse_simulation +from ev_grid_oracle.policies import baseline_policy from ev_grid_oracle.reward import split_role_rewards +from ev_grid_oracle.road_models import RoadAction, RoadObservation from ev_grid_oracle.scenarios import ScenarioName from ev_grid_oracle.world_model_verifier import ( rollout_deterministic_5ticks, score_prediction, ) -from ev_grid_oracle.multi_agent import MultiAgentSession from server.ev_grid_environment import EVGridEnvironment from server.ev_grid_road_environment import EVGridRoadEnvironment -from ev_grid_oracle.road_models import RoadAction, RoadObservation +from server.road_router import haversine_m from server.role_metrics import ( compute_role_kpis, compute_role_reward_breakdown, summarize_action, ) -from server.road_router import haversine_m log = logging.getLogger("ev-grid-oracle") if not log.handlers: @@ -249,7 +250,7 @@ def healthz(req: Request) -> dict[str, Any]: _DEMO_MAX_SESSIONS = int(os.getenv("DEMO_MAX_SESSIONS", "64")) # Ordered for deterministic eviction of oldest sessions. -_demo_sessions: "OrderedDict[str, tuple[float, EVGridCore]]" = OrderedDict() +_demo_sessions: OrderedDict[str, tuple[float, EVGridCore]] = OrderedDict() _demo_graph = build_city_graph() _SIM_VERSION = "2026-04-26.1" @@ -383,7 +384,7 @@ class DemoNewRequest(BaseModel): _MA_SESSION_TTL_SEC = int(os.getenv("MA_SESSION_TTL_SEC", "3600")) _MA_MAX_SESSIONS = int(os.getenv("MA_MAX_SESSIONS", "64")) -_ma_sessions: "OrderedDict[str, tuple[float, MultiAgentSession]]" = OrderedDict() +_ma_sessions: OrderedDict[str, tuple[float, MultiAgentSession]] = OrderedDict() def _ma_gc(now: float | None = None) -> None: @@ -1159,7 +1160,7 @@ def demo_step( seed_i = int(core._seed_for_bescom) scen = str(core.scenario) h = hashlib.sha1( - f"{seed_i}|{scen}|{mode}|ambient|{tick_i}".encode("utf-8"), + f"{seed_i}|{scen}|{mode}|ambient|{tick_i}".encode(), usedforsecurity=False, ).digest() a_i = int.from_bytes(h[:2], "big") % len(st.stations) diff --git a/server/road_router.py b/server/road_router.py index ee12289c..8c6fb0ae 100644 --- a/server/road_router.py +++ b/server/road_router.py @@ -1,11 +1,11 @@ from __future__ import annotations -import json import gzip +import json +from collections.abc import Callable from dataclasses import dataclass from math import asin, cos, radians, sin, sqrt from pathlib import Path -from typing import Callable, Optional import networkx as nx @@ -61,7 +61,7 @@ class RoadRouter: edge_geom: dict[tuple[int, int], list[list[float]]] @classmethod - def load(cls, path: Path) -> "RoadRouter": + def load(cls, path: Path) -> RoadRouter: if str(path).endswith(".gz"): with gzip.open(path, "rb") as f: obj = json.loads(f.read().decode("utf-8")) @@ -121,7 +121,7 @@ def route_polyline( dst_lng: float, traffic: TrafficModel | None = None, tick: int | None = None, - ) -> Optional[tuple[list[list[float]], list[int]]]: + ) -> tuple[list[list[float]], list[int]] | None: a = self.nearest_node(lat=src_lat, lng=src_lng) b = self.nearest_node(lat=dst_lat, lng=dst_lng) try: diff --git a/server/role_metrics.py b/server/role_metrics.py index 3ec1081d..f82f356b 100644 --- a/server/role_metrics.py +++ b/server/role_metrics.py @@ -95,7 +95,7 @@ def part(key: str) -> float: } for k in keys: wmap = weights.get(k, {}) - for r in out.keys(): + for r in out: out[r][k] = float(comps[k]) * float(wmap.get(r, 0.0)) totals: dict[Role, float] = {} diff --git a/tools/build_road_graph.py b/tools/build_road_graph.py index a68236ff..e9b42b4e 100644 --- a/tools/build_road_graph.py +++ b/tools/build_road_graph.py @@ -1,9 +1,9 @@ from __future__ import annotations import argparse -import json -import hashlib import gzip +import hashlib +import json from dataclasses import dataclass from math import asin, cos, radians, sin, sqrt from pathlib import Path @@ -11,7 +11,6 @@ import networkx as nx - ROOT = Path(__file__).resolve().parents[1] diff --git a/tools/build_roads_render.py b/tools/build_roads_render.py index f40f3ded..a271d8a0 100644 --- a/tools/build_roads_render.py +++ b/tools/build_roads_render.py @@ -5,7 +5,6 @@ from pathlib import Path from typing import Any - ROOT = Path(__file__).resolve().parents[1] diff --git a/tools/docs_sync.py b/tools/docs_sync.py new file mode 100644 index 00000000..b67f0f89 --- /dev/null +++ b/tools/docs_sync.py @@ -0,0 +1,54 @@ +import ast +import logging +import os + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +def sync_docs(): + os.makedirs("docs/api", exist_ok=True) + + for root, dirs, files in os.walk("."): + dirs[:] = [d for d in dirs if not d.startswith(".")] + if "node_modules" in dirs: + dirs.remove("node_modules") + + for file in files: + if file.endswith(".py"): + filepath = os.path.join(root, file) + try: + with open(filepath, "r", encoding="utf-8") as f: + source = f.read() + + tree = ast.parse(source) + + doc_content = f"# Documentation for `{filepath}`\n\n" + + for node in ast.walk(tree): + if isinstance(node, ast.ClassDef): + docstring = ast.get_docstring(node) + doc_content += f"## Class: `{node.name}`\n" + if docstring: + doc_content += f"{docstring}\n\n" + else: + doc_content += "*No documentation available.*\n\n" + elif isinstance(node, ast.FunctionDef): + docstring = ast.get_docstring(node) + doc_content += f"### Function: `{node.name}`\n" + if docstring: + doc_content += f"{docstring}\n\n" + else: + doc_content += "*No documentation available.*\n\n" + + safe_filename = filepath.replace("/", "_").replace("\\", "_") + doc_filepath = os.path.join("docs", "api", f"{safe_filename}.md") + + with open(doc_filepath, "w", encoding="utf-8") as out: + out.write(doc_content) + except Exception as e: + logger.warning(f"Failed to generate docs for {filepath}: {e}") + + +if __name__ == "__main__": + sync_docs() diff --git a/tools/fetch_bangalore_roads_overpass.py b/tools/fetch_bangalore_roads_overpass.py index 4fdaa966..120d815c 100644 --- a/tools/fetch_bangalore_roads_overpass.py +++ b/tools/fetch_bangalore_roads_overpass.py @@ -8,7 +8,6 @@ from pathlib import Path from typing import Any - ROOT = Path(__file__).resolve().parents[1] @@ -75,7 +74,7 @@ def _http_post(url: str, data: dict[str, str], *, retries: int = 3) -> bytes: try: with urllib.request.urlopen(req, timeout=240) as r: # nosec B310 return r.read() - except Exception as e: # noqa: BLE001 + except Exception as e: last_err = e if attempt >= retries: raise diff --git a/tools/fetch_osm_roads.py b/tools/fetch_osm_roads.py index e8fcd1d4..b2fb3bb5 100644 --- a/tools/fetch_osm_roads.py +++ b/tools/fetch_osm_roads.py @@ -8,7 +8,6 @@ from dataclasses import dataclass from pathlib import Path - OVERPASS_URL = "https://overpass-api.de/api/interpreter" diff --git a/tools/generate_architecture_diagrams.py b/tools/generate_architecture_diagrams.py new file mode 100644 index 00000000..a4a4c7b0 --- /dev/null +++ b/tools/generate_architecture_diagrams.py @@ -0,0 +1,61 @@ +import ast +import json +import logging +import os +from typing import Any + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +def generate_architecture_diagrams() -> None: + graph: dict[str, list[dict[str, Any]]] = {"nodes": [], "edges": []} + + modules = set() + + for root, dirs, files in os.walk("."): + dirs[:] = [d for d in dirs if not d.startswith(".")] + if "node_modules" in dirs: + dirs.remove("node_modules") + + for file in files: + if file.endswith(".py"): + filepath = os.path.join(root, file) + modules.add(filepath) + graph["nodes"].append({"id": filepath, "type": "module"}) + + try: + with open(filepath, "r", encoding="utf-8") as f: + source = f.read() + + tree = ast.parse(source) + + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + graph["edges"].append( + { + "source": filepath, + "target": alias.name, + "type": "imports", + } + ) + elif isinstance(node, ast.ImportFrom): + if node.module: + graph["edges"].append( + { + "source": filepath, + "target": node.module, + "type": "imports_from", + } + ) + except Exception as e: + logger.warning(f"Failed to parse imports in {filepath}: {e}") + + os.makedirs("artifacts", exist_ok=True) + with open("artifacts/architecture_graph.json", "w", encoding="utf-8") as out: + json.dump(graph, out, indent=2) + + +if __name__ == "__main__": + generate_architecture_diagrams() diff --git a/tools/generate_health_dashboard.py b/tools/generate_health_dashboard.py index cf321893..39fa7725 100644 --- a/tools/generate_health_dashboard.py +++ b/tools/generate_health_dashboard.py @@ -1,7 +1,8 @@ import json -import subprocess # nosec B404 import os +import subprocess # nosec B404 from datetime import datetime, timezone + from jinja2 import Environment, FileSystemLoader # Extract sensitive variables immediately to prevent child processes @@ -269,7 +270,6 @@ def generate_ai_insights(scores, complexity, vulns, lint_errors): except Exception as e: print(f"Failed to generate AI insights via OpenAI: {e}") # Fallback to static insights on error - pass # Static Fallback insights = [] diff --git a/tools/generate_knowledge_graph.py b/tools/generate_knowledge_graph.py new file mode 100644 index 00000000..813114f3 --- /dev/null +++ b/tools/generate_knowledge_graph.py @@ -0,0 +1,76 @@ +import ast +import json +import logging +import os +from typing import Any + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +def generate_knowledge_graph() -> None: + graph: dict[str, list[Any]] = { + "files": [], + "classes": [], + "functions": [], + "relationships": [], + } + + for root, dirs, files in os.walk("."): + dirs[:] = [d for d in dirs if not d.startswith(".")] + if "node_modules" in dirs: + dirs.remove("node_modules") + + for file in files: + if file.endswith(".py"): + filepath = os.path.join(root, file) + try: + with open(filepath, "r", encoding="utf-8") as f: + source = f.read() + + tree = ast.parse(source) + graph["files"].append(filepath) + + for node in ast.walk(tree): + if isinstance(node, ast.ClassDef): + docstring = ast.get_docstring(node) + graph["classes"].append( + { + "name": node.name, + "file": filepath, + "docstring": docstring, + } + ) + graph["relationships"].append( + { + "source": filepath, + "target": node.name, + "type": "contains_class", + } + ) + elif isinstance(node, ast.FunctionDef): + docstring = ast.get_docstring(node) + graph["functions"].append( + { + "name": node.name, + "file": filepath, + "docstring": docstring, + } + ) + graph["relationships"].append( + { + "source": filepath, + "target": node.name, + "type": "contains_function", + } + ) + except Exception as e: + logger.warning(f"Failed to parse {filepath}: {e}") + + os.makedirs("artifacts", exist_ok=True) + with open("artifacts/knowledge_graph.json", "w", encoding="utf-8") as out: + json.dump(graph, out, indent=2) + + +if __name__ == "__main__": + generate_knowledge_graph() diff --git a/tools/road_reward_smoke.py b/tools/road_reward_smoke.py index 41932b73..e8795b14 100644 --- a/tools/road_reward_smoke.py +++ b/tools/road_reward_smoke.py @@ -15,7 +15,7 @@ def main() -> int: ok = f"CURRENT_NODE: {st.node}\nNEXT_NODE: {int(nb)}\nREASON: go\nCONFIDENCE: 0.7\n" bad = f"CURRENT_NODE: {st.node + 999}\nNEXT_NODE: {int(nb)}\nREASON: hack\nCONFIDENCE: 0.7\n" - r = re.compile(r"CURRENT_NODE:\s*(\d+)\s*\nNEXT_NODE:\s*(\d+)\s*\n", re.I) + r = re.compile(r"CURRENT_NODE:\s*(\d+)\s*\nNEXT_NODE:\s*(\d+)\s*\n", re.IGNORECASE) def parse(t: str) -> RoadAction | None: m = r.search(t.strip()) diff --git a/training/train_grpo.ipynb b/training/train_grpo.ipynb index ef88aea4..f6ac6ae4 100644 --- a/training/train_grpo.ipynb +++ b/training/train_grpo.ipynb @@ -109,14 +109,12 @@ "outputs": [], "source": [ "import re\n", - "from typing import Optional\n", "\n", "from datasets import Dataset\n", "\n", "from ev_grid_oracle.road_env import RoadCore\n", "from ev_grid_oracle.road_models import RoadAction, RoadState\n", "\n", - "\n", "core = RoadCore(g=None, nodes=[]) # graph is loaded inside reset()" ] }, @@ -134,7 +132,7 @@ ")\n", "\n", "\n", - "def parse_action(text: str) -> Optional[RoadAction]:\n", + "def parse_action(text: str) -> RoadAction | None:\n", " m = ACTION_RE.search(text.strip())\n", " if not m:\n", " return None\n", diff --git a/viz/city_map.py b/viz/city_map.py index ac0b9907..3e75a03b 100644 --- a/viz/city_map.py +++ b/viz/city_map.py @@ -2,7 +2,6 @@ import math from dataclasses import dataclass -from typing import Optional import pygame @@ -28,7 +27,7 @@ def _norm(v: float, lo: float, hi: float) -> float: if hi <= lo: return 0.0 x = (v - lo) / (hi - lo) - return 0.0 if x < 0.0 else 1.0 if x > 1.0 else x + return 0.0 if x < 0.0 else min(x, 1.0) @dataclass @@ -91,7 +90,7 @@ def render( self, surf: pygame.Surface, *, - last_action: Optional[EVGridAction] = None, + last_action: EVGridAction | None = None, mode_label: str = "", ): cfg = self.cfg @@ -255,7 +254,7 @@ def run_live(seed: int = 123, *, mode: str = "baseline"): env.reset(seed=seed) renderer = CityMapRenderer(env, cfg) - last_action: Optional[EVGridAction] = None + last_action: EVGridAction | None = None running = True while running: for event in pygame.event.get(): diff --git a/viz/gradio_demo.py b/viz/gradio_demo.py index 26dbfa24..3b9d3fb9 100644 --- a/viz/gradio_demo.py +++ b/viz/gradio_demo.py @@ -13,7 +13,6 @@ from ev_grid_oracle.policies import baseline_policy from training.evaluate import run_episode, summarize - Mode = Literal["Untrained Baseline", "Oracle Agent"] @@ -21,7 +20,7 @@ def _norm(v: float, lo: float, hi: float) -> float: if hi <= lo: return 0.0 x = (v - lo) / (hi - lo) - return 0.0 if x < 0.0 else 1.0 if x > 1.0 else x + return 0.0 if x < 0.0 else min(x, 1.0) def _station_color(load_pct: float) -> tuple[int, int, int]: diff --git a/viz/record.py b/viz/record.py index 2f0fbc01..d05caf7d 100644 --- a/viz/record.py +++ b/viz/record.py @@ -2,7 +2,6 @@ import argparse from pathlib import Path -from typing import Optional import pygame @@ -37,7 +36,7 @@ def record( env.reset(seed=seed) renderer = CityMapRenderer(env, cfg) - last_action: Optional[EVGridAction] = None + last_action: EVGridAction | None = None frame = 0 for step in range(steps): st = env._grid_state diff --git a/viz/record_two_phase.py b/viz/record_two_phase.py index ac800cef..99402b74 100644 --- a/viz/record_two_phase.py +++ b/viz/record_two_phase.py @@ -1,8 +1,8 @@ from __future__ import annotations import argparse +from collections.abc import Callable from pathlib import Path -from typing import Callable, Optional import pygame @@ -13,7 +13,6 @@ from ev_grid_oracle.policies import baseline_policy from viz.city_map import CityMapRenderer, RenderConfig - PolicyFn = Callable[[GridState, object], EVGridAction] @@ -38,7 +37,7 @@ def record_phase( frame_start: int, policy: PolicyFn, ) -> int: - last_action: Optional[EVGridAction] = None + last_action: EVGridAction | None = None frame = frame_start for _ in range(steps): last_action = _step_action(env, policy)