Skip to content
Merged
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
4 changes: 2 additions & 2 deletions .github/workflows/docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -103,15 +103,15 @@ jobs:
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>AOBench — benchmark for AI agents that operate HPC systems</title>
<meta name="description" content="Open-source benchmark for AI agents operating HPC systems: 88 tasks, 29 deterministic snapshots, RBAC-enforced, trace-scored. No cluster needed.">
<meta name="description" content="Open-source benchmark for AI agents operating HPC systems: 90 tasks, 29 deterministic snapshots, RBAC-enforced, trace-scored. No cluster needed.">
<link rel="canonical" href="https://mskazemi.com/aobench/latest/">
<meta http-equiv="refresh" content="0; url=latest/">
<script>window.location.replace("latest/" + window.location.search + window.location.hash);</script>
</head>
<body>
<h1>AOBench — Agent Operations Benchmark</h1>
<p>AOBench is an open-source benchmark for AI agents that operate High-Performance
Computing systems: 88 tasks across 10 question categories and 5 operator roles, 29
Computing systems: 90 tasks across 10 question categories and 5 operator roles, 29
deterministic environment snapshots — 6 of them rebuilt from real CINECA Marconi100
ExaData — role-based access control enforced so that a policy violation hard-fails
the task, and 12 scorers over 7 weighted dimensions. It runs offline, with no
Expand Down
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,18 @@

## Unreleased

### Fixed — structured data now matches Google's breadcrumb and Dataset requirements

- Breadcrumb JSON-LD now emits only real navigable ancestors, gives every emitted
`ListItem` an absolute `item` URL, and keeps positions contiguous when a grouping
section has no index page. The docs homepage no longer publishes an ineligible
single-item `BreadcrumbList`.
- The AOBench `Dataset.creator` is explicitly typed as `Person`, and structured
discovery surfaces now report the current 90-task corpus.
- `scripts/seo_check.py` now audits JSON-LD on every built documentation page, including
breadcrumb shape/link resolution and typed Dataset creators/distributions, so this
Search Console failure class is regression-gated.

### Added — a second README demo, recorded on Windows

- `docs/assets/demo-windows.gif` ([issue #9](https://github.com/MSKazemi/aobench/issues/9),
Expand Down
37 changes: 19 additions & 18 deletions docs/overrides/main.html
Original file line number Diff line number Diff line change
Expand Up @@ -75,11 +75,11 @@
"@type": "Dataset",
"@id": "https://mskazemi.com/aobench/#dataset",
"name": "AOBench benchmark corpus",
"description": "88 HPC operational task specifications across 10 question categories and 5 operator roles, paired with 29 deterministic environment snapshot bundles. Six environments and eight tasks are grounded in the public Marconi100 ExaData release from CINECA.",
"description": "90 HPC operational task specifications across 10 question categories and 5 operator roles, paired with 29 deterministic environment snapshot bundles. Six environments and eight tasks are grounded in the public Marconi100 ExaData release from CINECA.",
"url": "https://mskazemi.com/aobench/reference/task-catalog/",
"license": "https://www.apache.org/licenses/LICENSE-2.0",
"identifier": "https://doi.org/10.5281/zenodo.21854862",
"creator": { "@id": "https://mskazemi.com/#person" },
"creator": { "@type": "Person", "@id": "https://mskazemi.com/#person" },
"isAccessibleForFree": true,
"measurementTechnique": "Trace-based scoring across outcome correctness, tool-use correctness, governance/RBAC, grounding, robustness (pass^k), and efficiency",
"variableMeasured": [
Expand Down Expand Up @@ -140,47 +140,48 @@
"about": { "@id": "https://mskazemi.com/aobench/#software" },
"author": { "@id": "https://mskazemi.com/#person" },
"license": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
}
{%- if page and page.canonical_url != config.site_url %}
,{
"@type": "BreadcrumbList",
"@id": "{{ page.canonical_url }}#breadcrumb",
"itemListElement": [
{
"@type": "ListItem",
"position": 1,
"name": "AOBench",
"item": "https://mskazemi.com/aobench/"
"item": "{{ config.site_url }}"
}
{%- if page and page.ancestors %}
{%- set crumb = namespace(position=1) %}
{%- if page.ancestors %}
{%- for ancestor in page.ancestors | reverse %}
{%- if ancestor.url %}
{%- set crumb.position = crumb.position + 1 %}
,{
"@type": "ListItem",
"position": {{ loop.index + 1 }},
"name": {{ ancestor.title | string | tojson }}
"position": {{ crumb.position }},
"name": {{ ancestor.title | string | tojson }},
"item": "{{ config.site_url }}{{ ancestor.url }}"
}
{%- endif %}
{%- endfor %}
{%- endif %}
{%- set crumb.position = crumb.position + 1 %}
,{
"@type": "ListItem",
"position": {{ page.ancestors | length + 2 }},
"name": {{ page_title | tojson }},
"item": "{{ page.canonical_url }}"
}
{%- elif page and page.canonical_url != config.site_url %}
,{
"@type": "ListItem",
"position": 2,
"position": {{ crumb.position }},
"name": {{ page_title | tojson }},
"item": "{{ page.canonical_url }}"
}
{%- endif %}
]
}
{%- endif %}
]
}
</script>
{% endblock %}

{% block announce %}
<strong>AOBench v0.4.1</strong> — 88 tasks · 29 environments · 6 grounded in real Marconi100 data.
<strong>AOBench v0.4.1</strong> — 90 tasks · 29 environments · 6 grounded in real Marconi100 data.
&nbsp;<a href="{{ config.site_url }}about/changelog/">Read the changelog →</a>
{% endblock %}
122 changes: 120 additions & 2 deletions scripts/seo_check.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
import re
import sys
from pathlib import Path
from urllib.parse import urlparse
from xml.etree import ElementTree

ROOT = Path(__file__).resolve().parent.parent
Expand All @@ -32,7 +33,6 @@
"Person",
"WebSite",
"TechArticle",
"BreadcrumbList",
}

#: Crawlers that must not be blocked. Being absent from robots.txt is fine (the
Expand Down Expand Up @@ -173,9 +173,126 @@ def check_page(self, rel: str) -> None:
for node in data.get("@graph", [data]):
if isinstance(node, dict) and "@type" in node:
types.add(str(node["@type"]))
missing = REQUIRED_SCHEMA_TYPES - types
required = set(REQUIRED_SCHEMA_TYPES)
if rel != "index.html":
required.add("BreadcrumbList")
missing = required - types
self.check(not missing, f"{rel}: JSON-LD schema types", f"missing {sorted(missing)}")

def _breadcrumb_target_exists(self, url: str) -> bool:
parsed = urlparse(url)
if parsed.scheme != "https" or parsed.netloc != "mskazemi.com":
return False
prefix = "/aobench/"
if not parsed.path.startswith(prefix):
return False
suffix = parsed.path[len(prefix) :].lstrip("/")
if not suffix:
return (self.site / "index.html").is_file()
target = self.site / suffix
if parsed.path.endswith("/"):
target = target / "index.html"
return target.is_file()

def check_structured_data(self, rel: str, html: str) -> None:
blocks = re.findall(
r'<script type=[\'"]?application/ld\+json[\'"]?>(.*?)</script>',
html,
re.DOTALL,
)
nodes: list[dict[str, object]] = []
for block in blocks:
try:
data = json.loads(block)
except json.JSONDecodeError:
continue
if not isinstance(data, dict):
continue
graph = data.get("@graph", [data])
if isinstance(graph, list):
nodes.extend(node for node in graph if isinstance(node, dict))

breadcrumbs = [node for node in nodes if node.get("@type") == "BreadcrumbList"]
if rel == "index.html":
self.check(
not breadcrumbs,
f"{rel}: homepage omits single-item BreadcrumbList",
"Google requires at least two ListItems",
)
else:
self.check(bool(breadcrumbs), f"{rel}: has BreadcrumbList")

for breadcrumb in breadcrumbs:
items = breadcrumb.get("itemListElement")
valid_items = isinstance(items, list) and len(items) >= 2
self.check(valid_items, f"{rel}: breadcrumb has at least two ListItems")
if not valid_items:
continue

positions = [
item.get("position") if isinstance(item, dict) else None for item in items
]
self.check(
positions == list(range(1, len(items) + 1)),
f"{rel}: breadcrumb positions are contiguous",
f"found {positions}",
)

for position, item in enumerate(items, start=1):
is_list_item = isinstance(item, dict) and item.get("@type") == "ListItem"
self.check(is_list_item, f"{rel}: breadcrumb {position} is a ListItem")
if not isinstance(item, dict):
continue
self.check(
isinstance(item.get("name"), str) and bool(item.get("name")),
f"{rel}: breadcrumb {position} has a name",
)
url = item.get("item")
valid_url = isinstance(url, str) and url.startswith("https://")
self.check(valid_url, f"{rel}: breadcrumb {position} has an absolute item URL")
if valid_url:
self.check(
self._breadcrumb_target_exists(url),
f"{rel}: breadcrumb {position} resolves inside the built site",
str(url),
)

datasets = [node for node in nodes if node.get("@type") == "Dataset"]
for dataset in datasets:
creators = dataset.get("creator")
if not isinstance(creators, list):
creators = [creators]
typed_creators = bool(creators) and all(
isinstance(creator, dict)
and creator.get("@type") in {"Person", "Organization"}
for creator in creators
)
self.check(
typed_creators,
f"{rel}: Dataset.creator is typed Person/Organization",
)

distributions = dataset.get("distribution")
if not isinstance(distributions, list):
distributions = [distributions]
valid_distributions = bool(distributions) and all(
isinstance(distribution, dict)
and bool(distribution.get("contentUrl"))
and bool(distribution.get("encodingFormat"))
for distribution in distributions
)
self.check(
valid_distributions,
f"{rel}: Dataset distributions declare URL and encodingFormat",
)

def check_sitewide_structured_data(self) -> None:
pages = sorted(self.site.rglob("index.html"))
self.check(bool(pages), "structured-data audit found built pages")
for path in pages:
rel = path.relative_to(self.site).as_posix()
self.check_structured_data(rel, path.read_text(encoding="utf-8"))


def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
Expand All @@ -195,6 +312,7 @@ def main() -> int:
checker.check_llms_txt()
for page in KEY_PAGES:
checker.check_page(page)
checker.check_sitewide_structured_data()

if checker.failures:
print(f"SEO check FAILED — {len(checker.failures)} problem(s):\n", file=sys.stderr)
Expand Down
Loading