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
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,8 @@ function continueApply(req: ChatRequest, state: RuntimeState, results: ToolResul
view.created_tag_ids = [...new Set(view.created_tag_ids)];
view.last_error_count = errors;
const kinds = errorKinds(payload);
const retryable = errors > 0 && kinds.length === errors && kinds.every(kind => kind === "tag_unresolved_collision");
const retryableKinds = new Set(["tag_unresolved_collision", "tag_unresolved_leader_collision"]);
const retryable = errors > 0 && kinds.length === errors && kinds.every(kind => retryableKinds.has(kind));
if (errors > 0 && retryable) retryViews.push(view);
else if (errors > 0) state.hard_failure = `${errors} non-retryable tag error(s) remained in ${view.name}.`;
appendGoalProgress(req.session_id, {
Expand Down
23 changes: 23 additions & 0 deletions apps/operator-backend/test/aec_level_tag_runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,29 @@ test("level tag runtime retains a fifth bounded tall-offset repair before final
assert.equal(done?.aec_query_receipt?.status, "complete");
}));

test("level tag runtime retries professionally unsafe leader routes with a bounded alternate placement", () => withWorkspace(() => {
__testOnlyClearAecLevelTagRuntime();
const session = "level-tag-leader-repair";
activateGoal(session);
startAecLevelTagRuntime(req(session), task(), [{ id: 101, name: "L4 HVAC", levelName: "L4" }]);
maybeContinueAecLevelTagRuntime(req(session, [result("aec-level-tag-inventory-101", { count: 1, truncated: false, elementIds: [11] })]));
maybeContinueAecLevelTagRuntime(req(session, [result("aec-level-tag-dry_run-101", { targetCount: 1, plannedToTag: 1, skippedAlreadyTagged: 0, geometry: { plans: [{ candidateCount: 180 }] } })]));

const retry = maybeContinueAecLevelTagRuntime(req(session, [
result("aec-level-tag-apply-101-v1", {
targetCount: 1,
taggedCount: 0,
skippedAlreadyTagged: 0,
errorCount: 1,
tagIds: [],
errors: [{ failureKind: "tag_unresolved_leader_collision" }]
})
]));

assert.equal(retry?.actions[0]?.action_id, "aec-level-tag-apply-101-v2");
assert.equal(body(retry!.actions[0]!).placementMode, "geometry_aware");
}));

test("level tag runtime fails closed on truncated inventories before any tag action", () => withWorkspace(() => {
__testOnlyClearAecLevelTagRuntime();
const session = "level-tag-truncated";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,141 @@ public void UsesDeterministicLanesToAvoidPriorTags()
Assert.NotEqual((first.HeadX, first.HeadY), (second.HeadX, second.HeadY));
}

[Fact]
public void Leader_segment_runs_from_nearest_target_boundary_to_tag_head()
{
var leader = TagPlacementPlanner.BuildLeaderSegment(
Rect(-0.5, -0.5, 0.5, 0.5),
Rect(1.5, -0.25, 2.5, 0.25));

Assert.Equal(0.5, leader.StartX, 6);
Assert.Equal(0, leader.StartY, 6);
Assert.Equal(2, leader.EndX, 6);
Assert.Equal(0, leader.EndY, 6);
Assert.Equal(1.5, leader.Length, 6);
}

[Fact]
public void Rejects_otherwise_clear_head_when_its_leader_crosses_an_existing_leader()
{
var target = Rect(-0.5, -0.5, 0.5, 0.5);
var ranked = TagPlacementPlanner.RankCandidates(new TagPlacementRequest
{
Target = target,
Obstacles = new[] { target },
LeaderSegments = new[] { new TagSegment2(0.8, -2, 0.8, 2) },
TagWidth = 1,
TagHeight = 0.5,
Clearance = 0.1,
Profile = "mep",
MaxCandidates = 4
});

Assert.Equal("top", ranked.First().Side);
Assert.True(ranked.First().CollisionFree);
Assert.Contains(ranked, candidate => candidate.Side == "right" && candidate.CollisionCount == 0 && candidate.LeaderCrossingCount == 1 && !candidate.CollisionFree);
}

[Fact]
public void Prevents_leader_from_running_through_protected_annotation_content()
{
var target = Rect(-0.5, -0.5, 0.5, 0.5);
var protectedText = Rect(0.52, -0.05, 0.58, 0.05);
var ranked = TagPlacementPlanner.RankCandidates(new TagPlacementRequest
{
Target = target,
Obstacles = new[] { target },
LeaderProtectedObstacles = new[] { protectedText },
TagWidth = 1,
TagHeight = 0.5,
Clearance = 0.1,
Profile = "mep",
MaxCandidates = 4
});

Assert.Equal("top", ranked.First().Side);
Assert.Contains(ranked, candidate => candidate.Side == "right" && candidate.CollisionCount == 0 && candidate.LeaderProtectedCrossingCount == 1);
}

[Fact]
public void Prefers_clear_floor_space_over_soft_wall_or_equipment_overlap()
{
var target = Rect(-0.5, -0.5, 0.5, 0.5);
var ranked = TagPlacementPlanner.RankCandidates(new TagPlacementRequest
{
Target = target,
Obstacles = new[] { target },
SoftObstacles = new[] { Rect(0.55, -1, 2, 1) },
TagWidth = 1,
TagHeight = 0.5,
Clearance = 0.1,
Profile = "mep",
MaxCandidates = 4
});

Assert.Equal("top", ranked.First().Side);
Assert.Equal(0, ranked.First().SoftObstacleCount);
Assert.Contains(ranked, candidate => candidate.Side == "right" && candidate.SoftObstacleCount > 0);
}

[Fact]
public void Keeps_the_nearest_clear_leader_ahead_of_farther_clear_variants()
{
var ranked = TagPlacementPlanner.RankCandidates(new TagPlacementRequest
{
Target = Rect(-0.5, -0.5, 0.5, 0.5),
Obstacles = new[] { Rect(-0.5, -0.5, 0.5, 0.5) },
TagWidth = 1,
TagHeight = 0.5,
Clearance = 0.2,
Profile = "mep",
MaxCandidates = 180
});

Assert.Equal(0.7, ranked.First().LeaderLength, 6);
Assert.Equal(
ranked.Where(candidate => candidate.Side == ranked.First().Side).Min(candidate => candidate.LeaderLength),
ranked.First().LeaderLength,
6);
}

[Fact]
public void Leader_disabled_uses_head_geometry_only_and_reports_no_leader_metrics()
{
var target = Rect(-0.5, -0.5, 0.5, 0.5);
var candidate = TagPlacementPlanner.RankCandidates(new TagPlacementRequest
{
Target = target,
Obstacles = new[] { target },
LeaderSegments = new[] { new TagSegment2(0.8, -2, 0.8, 2) },
LeaderProtectedObstacles = new[] { Rect(0.52, -0.05, 0.58, 0.05) },
LeaderEnabled = false,
TagWidth = 1,
TagHeight = 0.5,
Clearance = 0.1,
Profile = "mep",
MaxCandidates = 4
}).First();

Assert.True(candidate.CollisionFree);
Assert.Equal("right", candidate.Side);
Assert.Null(candidate.LeaderSegment);
Assert.Equal(0, candidate.LeaderLength);
Assert.Equal(0, candidate.LeaderCrossingCount);
Assert.Equal(0, candidate.LeaderProtectedCrossingCount);
}

[Fact]
public void Segment_geometry_distinguishes_interior_crossings_from_boundary_touches()
{
var rect = Rect(1, 1, 2, 2);
Assert.True(new TagSegment2(0, 1.5, 3, 1.5).CrossesInterior(rect));
Assert.False(new TagSegment2(0, 1, 3, 1).CrossesInterior(rect));
Assert.True(new TagSegment2(0, 0, 2, 2).Intersects(new TagSegment2(0, 2, 2, 0)));
Assert.True(new TagSegment2(0, 0, 2, 0).Intersects(new TagSegment2(2, 0, 3, 1)));
Assert.True(new TagSegment2(0, 0, 3, 0).Intersects(new TagSegment2(1, 0, 2, 0)));
}

[Fact]
public void ReportsLeastBadCandidateWhenEverySideIsBlocked()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,101 @@ public double IntersectionArea(TagRect2 other)
private static bool IsFinite(double value) => !double.IsNaN(value) && !double.IsInfinity(value);
}

public sealed class TagSegment2
{
public TagSegment2(double startX, double startY, double endX, double endY)
{
if (!IsFinite(startX) || !IsFinite(startY) || !IsFinite(endX) || !IsFinite(endY))
throw new ArgumentException("Tag leader coordinates must be finite.");
StartX = startX;
StartY = startY;
EndX = endX;
EndY = endY;
}

public double StartX { get; }
public double StartY { get; }
public double EndX { get; }
public double EndY { get; }
public double Length => Math.Sqrt(Math.Pow(EndX - StartX, 2) + Math.Pow(EndY - StartY, 2));

public bool Intersects(TagSegment2 other, double tolerance = 1e-9)
{
if (other == null) throw new ArgumentNullException(nameof(other));
var o1 = Orientation(StartX, StartY, EndX, EndY, other.StartX, other.StartY, tolerance);
var o2 = Orientation(StartX, StartY, EndX, EndY, other.EndX, other.EndY, tolerance);
var o3 = Orientation(other.StartX, other.StartY, other.EndX, other.EndY, StartX, StartY, tolerance);
var o4 = Orientation(other.StartX, other.StartY, other.EndX, other.EndY, EndX, EndY, tolerance);
if (o1 != o2 && o3 != o4) return true;
return (o1 == 0 && OnSegment(StartX, StartY, EndX, EndY, other.StartX, other.StartY, tolerance)) ||
(o2 == 0 && OnSegment(StartX, StartY, EndX, EndY, other.EndX, other.EndY, tolerance)) ||
(o3 == 0 && OnSegment(other.StartX, other.StartY, other.EndX, other.EndY, StartX, StartY, tolerance)) ||
(o4 == 0 && OnSegment(other.StartX, other.StartY, other.EndX, other.EndY, EndX, EndY, tolerance));
}

public bool CrossesInterior(TagRect2 rect, double tolerance = 1e-9)
{
if (rect == null) throw new ArgumentNullException(nameof(rect));
var minX = rect.MinX + tolerance;
var minY = rect.MinY + tolerance;
var maxX = rect.MaxX - tolerance;
var maxY = rect.MaxY - tolerance;
if (maxX <= minX || maxY <= minY) return false;
if (Inside(StartX, StartY, minX, minY, maxX, maxY) || Inside(EndX, EndY, minX, minY, maxX, maxY)) return true;

var t0 = 0.0;
var t1 = 1.0;
var dx = EndX - StartX;
var dy = EndY - StartY;
if (!Clip(-dx, StartX - minX, ref t0, ref t1) ||
!Clip(dx, maxX - StartX, ref t0, ref t1) ||
!Clip(-dy, StartY - minY, ref t0, ref t1) ||
!Clip(dy, maxY - StartY, ref t0, ref t1)) return false;
return t1 - t0 > tolerance;
}

private static bool Clip(double p, double q, ref double t0, ref double t1)
{
if (Math.Abs(p) <= 1e-12) return q >= 0;
var r = q / p;
if (p < 0)
{
if (r > t1) return false;
if (r > t0) t0 = r;
}
else
{
if (r < t0) return false;
if (r < t1) t1 = r;
}
return true;
}

private static int Orientation(double ax, double ay, double bx, double by, double cx, double cy, double tolerance)
{
var value = (by - ay) * (cx - bx) - (bx - ax) * (cy - by);
if (Math.Abs(value) <= tolerance) return 0;
return value > 0 ? 1 : 2;
}

private static bool OnSegment(double ax, double ay, double bx, double by, double px, double py, double tolerance) =>
px <= Math.Max(ax, bx) + tolerance && px >= Math.Min(ax, bx) - tolerance &&
py <= Math.Max(ay, by) + tolerance && py >= Math.Min(ay, by) - tolerance;

private static bool Inside(double x, double y, double minX, double minY, double maxX, double maxY) =>
x > minX && x < maxX && y > minY && y < maxY;

private static bool IsFinite(double value) => !double.IsNaN(value) && !double.IsInfinity(value);
}

public sealed class TagPlacementRequest
{
public TagRect2 Target { get; set; } = new TagRect2(0, 0, 0, 0);
public IReadOnlyList<TagRect2> Obstacles { get; set; } = Array.Empty<TagRect2>();
public IReadOnlyList<TagRect2> SoftObstacles { get; set; } = Array.Empty<TagRect2>();
public IReadOnlyList<TagRect2> LeaderProtectedObstacles { get; set; } = Array.Empty<TagRect2>();
public IReadOnlyList<TagSegment2> LeaderSegments { get; set; } = Array.Empty<TagSegment2>();
public bool LeaderEnabled { get; set; } = true;
public double TagWidth { get; set; }
public double TagHeight { get; set; }
public double Clearance { get; set; }
Expand All @@ -64,8 +155,14 @@ public sealed class TagPlacementCandidate
public TagRect2 Bounds { get; set; } = new TagRect2(0, 0, 0, 0);
public int CollisionCount { get; set; }
public double CollisionArea { get; set; }
public int SoftObstacleCount { get; set; }
public double SoftObstacleArea { get; set; }
public TagSegment2? LeaderSegment { get; set; }
public double LeaderLength { get; set; }
public int LeaderCrossingCount { get; set; }
public int LeaderProtectedCrossingCount { get; set; }
public double Score { get; set; }
public bool CollisionFree => CollisionCount == 0;
public bool CollisionFree => CollisionCount == 0 && LeaderCrossingCount == 0 && LeaderProtectedCrossingCount == 0;
}

public static class TagPlacementPlanner
Expand All @@ -90,6 +187,9 @@ public static IReadOnlyList<TagPlacementCandidate> RankCandidates(TagPlacementRe
// dense production views instead of truncating it after the first 128 positions.
var maxCandidates = Math.Max(1, Math.Min(180, request.MaxCandidates));
var obstacles = (request.Obstacles ?? Array.Empty<TagRect2>()).Where(x => x != null).ToList();
var softObstacles = (request.SoftObstacles ?? Array.Empty<TagRect2>()).Where(x => x != null).ToList();
var leaderProtectedObstacles = (request.LeaderProtectedObstacles ?? Array.Empty<TagRect2>()).Where(x => x != null).ToList();
var leaderSegments = (request.LeaderSegments ?? Array.Empty<TagSegment2>()).Where(x => x != null).ToList();
var directions = DirectionsFor(request.Profile);
var candidates = new List<TagPlacementCandidate>();
var halfWidth = request.TagWidth * 0.5;
Expand Down Expand Up @@ -118,6 +218,22 @@ public static IReadOnlyList<TagPlacementCandidate> RankCandidates(TagPlacementRe
collisionArea += bounds.IntersectionArea(inflated);
}

var softObstacleCount = 0;
var softObstacleArea = 0.0;
foreach (var obstacle in softObstacles)
{
if (!bounds.Intersects(obstacle)) continue;
softObstacleCount++;
softObstacleArea += bounds.IntersectionArea(obstacle);
}

var leaderSegment = request.LeaderEnabled ? BuildLeaderSegment(request.Target, bounds) : null;
var leaderCrossingCount = leaderSegment == null ? 0 : leaderSegments.Count(segment => leaderSegment.Intersects(segment));
var leaderProtectedCrossingCount = leaderSegment == null
? 0
: leaderProtectedObstacles.Count(obstacle => !SameRect(obstacle, request.Target) && leaderSegment.CrossesInterior(obstacle.Inflate(request.Clearance)));
var leaderLength = leaderSegment?.Length ?? 0.0;

var distance = Math.Sqrt(
Math.Pow(headX - request.Target.CenterX, 2) +
Math.Pow(headY - request.Target.CenterY, 2));
Expand All @@ -129,7 +245,22 @@ public static IReadOnlyList<TagPlacementCandidate> RankCandidates(TagPlacementRe
Bounds = bounds,
CollisionCount = collisionCount,
CollisionArea = collisionArea,
Score = collisionCount * 1000000.0 + collisionArea * 1000.0 + direction.Penalty * 300.0 + Math.Pow(distanceScale - 1.0, 2.0) * 10.0 + Math.Abs(lane) * 10.0 + distance
SoftObstacleCount = softObstacleCount,
SoftObstacleArea = softObstacleArea,
LeaderSegment = leaderSegment,
LeaderLength = leaderLength,
LeaderCrossingCount = leaderCrossingCount,
LeaderProtectedCrossingCount = leaderProtectedCrossingCount,
Score = collisionCount * 1000000000000.0 +
leaderCrossingCount * 100000000000.0 +
leaderProtectedCrossingCount * 10000000000.0 +
collisionArea * 1000000.0 +
softObstacleCount * 100000.0 +
softObstacleArea * 1000.0 +
leaderLength * 2.0 +
direction.Penalty * 300.0 +
Math.Pow(distanceScale - 1.0, 2.0) * 10.0 +
Math.Abs(lane) * 10.0 + distance
});
}
if (candidates.Count >= maxCandidates) break;
Expand All @@ -147,6 +278,23 @@ public static IReadOnlyList<TagPlacementCandidate> RankCandidates(TagPlacementRe
.ToList();
}

public static TagSegment2 BuildLeaderSegment(TagRect2 target, TagRect2 tagBounds)
{
if (target == null) throw new ArgumentNullException(nameof(target));
if (tagBounds == null) throw new ArgumentNullException(nameof(tagBounds));
var startX = Clamp(tagBounds.CenterX, target.MinX, target.MaxX);
var startY = Clamp(tagBounds.CenterY, target.MinY, target.MaxY);
return new TagSegment2(startX, startY, tagBounds.CenterX, tagBounds.CenterY);
}

private static bool SameRect(TagRect2 left, TagRect2 right, double tolerance = 1e-9) =>
Math.Abs(left.MinX - right.MinX) <= tolerance &&
Math.Abs(left.MinY - right.MinY) <= tolerance &&
Math.Abs(left.MaxX - right.MaxX) <= tolerance &&
Math.Abs(left.MaxY - right.MaxY) <= tolerance;

private static double Clamp(double value, double min, double max) => Math.Max(min, Math.Min(max, value));

private static IReadOnlyList<Direction> DirectionsFor(string? profile)
{
var normalized = (profile ?? "auto").Trim().ToLowerInvariant();
Expand Down
Loading
Loading